'use strict';
// ============================================================
// OPS Dashboard — Vanilla JS Application (v3)
// ============================================================
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let allServices = [];
let currentPage = 'dashboard';
let viewMode = 'cards'; // 'cards' | 'table'
let tableFilter = null; // null | 'healthy' | 'down' | 'project:name' | 'env:name'
let tableFilterLabel = '';
let drillLevel = 0; // 0=projects, 1=environments, 2=services
let drillProject = null;
let drillEnv = null;
let refreshTimer = null;
const REFRESH_INTERVAL = 30000;
// Log modal state
let logCtx = { project: null, env: null, service: null };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmtBytes(b) {
if (b == null) return '\u2014';
const n = Number(b);
if (isNaN(n) || n === 0) return '0 B';
const k = 1024, s = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(Math.abs(n)) / Math.log(k));
return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + s[i];
}
function esc(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function dotClass(status, health) {
const s = (status || '').toLowerCase(), h = (health || '').toLowerCase();
if (s === 'up' && (h === 'healthy' || !h)) return 'status-dot-green';
if (s === 'up' && h === 'unhealthy') return 'status-dot-red';
if (s === 'up' && h === 'starting') return 'status-dot-yellow';
if (s === 'down' || s === 'exited') return 'status-dot-red';
return 'status-dot-gray';
}
function badgeCls(status, health) {
const s = (status || '').toLowerCase(), h = (health || '').toLowerCase();
if (s === 'up' && (h === 'healthy' || !h)) return 'badge-green';
if (s === 'up' && h === 'unhealthy') return 'badge-red';
if (s === 'up' && h === 'starting') return 'badge-yellow';
if (s === 'down' || s === 'exited') return 'badge-red';
return 'badge-gray';
}
function diskColor(pct) {
const n = parseInt(pct);
if (n >= 90) return 'disk-danger';
if (n >= 75) return 'disk-warn';
return 'disk-ok';
}
function isHealthy(svc) {
return svc.status === 'Up' && (svc.health === 'healthy' || !svc.health);
}
function isDown(svc) { return !isHealthy(svc); }
function filterServices(list) {
if (!tableFilter) return list;
if (tableFilter === 'healthy') return list.filter(isHealthy);
if (tableFilter === 'down') return list.filter(isDown);
if (tableFilter.startsWith('project:')) {
const p = tableFilter.slice(8);
return list.filter(s => s.project === p);
}
if (tableFilter.startsWith('env:')) {
const e = tableFilter.slice(4);
return list.filter(s => s.env === e);
}
return list;
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
function getToken() { return localStorage.getItem('ops_token'); }
function doLogin() {
const input = document.getElementById('login-token');
const err = document.getElementById('login-error');
const token = input.value.trim();
if (!token) { err.textContent = 'Please enter a token'; err.style.display = 'block'; return; }
err.style.display = 'none';
fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(data => {
localStorage.setItem('ops_token', token);
allServices = data;
document.getElementById('login-overlay').style.display = 'none';
document.getElementById('app').style.display = 'flex';
showPage('dashboard');
startAutoRefresh();
})
.catch(() => { err.textContent = 'Invalid token.'; err.style.display = 'block'; });
}
function doLogout() {
localStorage.removeItem('ops_token');
stopAutoRefresh();
document.getElementById('app').style.display = 'none';
document.getElementById('login-overlay').style.display = 'flex';
document.getElementById('login-token').value = '';
}
// ---------------------------------------------------------------------------
// API
// ---------------------------------------------------------------------------
async function api(path, opts = {}) {
const token = getToken();
const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token };
const resp = await fetch(path, { ...opts, headers });
if (resp.status === 401) { doLogout(); throw new Error('Session expired'); }
if (!resp.ok) { const b = await resp.text(); throw new Error(b || 'HTTP ' + resp.status); }
const ct = resp.headers.get('content-type') || '';
return ct.includes('json') ? resp.json() : resp.text();
}
async function fetchStatus() { allServices = await api('/api/status/'); }
// ---------------------------------------------------------------------------
// Toast
// ---------------------------------------------------------------------------
function toast(msg, type = 'info') {
const c = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast toast-' + type;
el.innerHTML = `${esc(msg)}×`;
c.appendChild(el);
setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 200); }, 4000);
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('mobile-overlay').classList.toggle('open');
}
function showPage(page) {
currentPage = page;
drillLevel = 0; drillProject = null; drillEnv = null;
if (page !== 'dashboard') { viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; }
document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el =>
el.classList.toggle('active', el.dataset.page === page));
document.getElementById('sidebar').classList.remove('open');
document.getElementById('mobile-overlay').classList.remove('open');
renderPage();
}
function renderPage() {
const c = document.getElementById('page-content');
c.innerHTML = '
';
updateViewToggle();
switch (currentPage) {
case 'dashboard': renderDashboard(); break;
case 'backups': renderBackups(); break;
case 'system': renderSystem(); break;
case 'restore': renderRestore(); break;
default: renderDashboard();
}
}
function refreshCurrentPage() {
showSpin();
fetchStatus().then(() => renderPage()).catch(e => toast('Refresh failed: ' + e.message, 'error')).finally(hideSpin);
}
// ---------------------------------------------------------------------------
// View Mode & Filters
// ---------------------------------------------------------------------------
function setViewMode(mode) {
viewMode = mode;
if (mode === 'cards') { tableFilter = null; tableFilterLabel = ''; }
updateViewToggle();
renderDashboard();
}
function setTableFilter(filter, label) {
tableFilter = filter;
tableFilterLabel = label || filter;
viewMode = 'table';
updateViewToggle();
renderDashboard();
}
function clearFilter() {
tableFilter = null; tableFilterLabel = '';
renderDashboard();
}
function updateViewToggle() {
const wrap = document.getElementById('view-toggle-wrap');
const btnCards = document.getElementById('btn-view-cards');
const btnTable = document.getElementById('btn-view-table');
if (currentPage === 'dashboard') {
wrap.style.display = '';
btnCards.classList.toggle('active', viewMode === 'cards');
btnTable.classList.toggle('active', viewMode === 'table');
} else {
wrap.style.display = 'none';
}
}
// ---------------------------------------------------------------------------
// Auto-refresh
// ---------------------------------------------------------------------------
function startAutoRefresh() {
stopAutoRefresh();
refreshTimer = setInterval(() => {
fetchStatus().then(() => { if (currentPage === 'dashboard') renderPage(); }).catch(() => {});
}, REFRESH_INTERVAL);
}
function stopAutoRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
function showSpin() { document.getElementById('refresh-indicator').classList.remove('paused'); }
function hideSpin() { document.getElementById('refresh-indicator').classList.add('paused'); }
// ---------------------------------------------------------------------------
// Breadcrumbs
// ---------------------------------------------------------------------------
function updateBreadcrumbs() {
const bc = document.getElementById('breadcrumbs');
let h = '';
if (currentPage === 'dashboard') {
if (viewMode === 'table') {
h = 'Dashboard/';
h += 'All Services';
if (tableFilter) {
h += ' ' + esc(tableFilterLabel) +
' ';
}
} else if (drillLevel === 0) {
h = 'Dashboard';
} else if (drillLevel === 1) {
h = 'Dashboard/' + esc(drillProject) + '';
} else if (drillLevel === 2) {
h = 'Dashboard/' + esc(drillProject) + '/' + esc(drillEnv) + '';
}
} else {
const names = { backups: 'Backups', system: 'System', restore: 'Restore' };
h = '' + (names[currentPage] || currentPage) + '';
}
bc.innerHTML = h;
}
function drillBack(level) {
if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; }
else if (level === 1) { drillLevel = 1; drillEnv = null; }
renderDashboard();
}
// ---------------------------------------------------------------------------
// Dashboard — Cards + Table modes
// ---------------------------------------------------------------------------
function renderDashboard() {
currentPage = 'dashboard';
if (viewMode === 'table') { renderDashboardTable(); }
else if (drillLevel === 0) { renderProjects(); }
else if (drillLevel === 1) { renderEnvironments(); }
else { renderDrillServices(); }
updateBreadcrumbs();
}
function renderProjects() {
const c = document.getElementById('page-content');
const projects = groupBy(allServices, 'project');
const totalUp = allServices.filter(isHealthy).length;
const totalDown = allServices.length - totalUp;
let h = '
';
// Stat tiles — clickable
h += '
';
h += statTile('Projects', Object.keys(projects).length, '#3b82f6');
h += statTile('Services', allServices.length, '#8b5cf6', "setViewMode('table')");
h += statTile('Healthy', totalUp, '#10b981', "setTableFilter('healthy','Healthy')");
h += statTile('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280', totalDown > 0 ? "setTableFilter('down','Down')" : null);
h += '
';
// Project cards
h += '
';
for (const [name, svcs] of Object.entries(projects)) {
const up = svcs.filter(isHealthy).length;
const total = svcs.length;
const envs = [...new Set(svcs.map(s => s.env))];
h += `
${esc(name)}${total} svc
${envs.map(e => `${esc(e)}`).join('')}
${up}/${total} healthy
`;
}
h += '
';
c.innerHTML = h;
}
function renderEnvironments() {
const c = document.getElementById('page-content');
const envs = groupBy(allServices.filter(s => s.project === drillProject), 'env');
let h = '
';
for (const [envName, svcs] of Object.entries(envs)) {
const up = svcs.filter(isHealthy).length;
const total = svcs.length;
h += `
${esc(envName).toUpperCase()}${total} svc
${svcs.map(s => `${esc(s.service)}`).join('')}
${up}/${total} healthy
`;
}
h += '
';
c.innerHTML = h;
}
function renderDrillServices() {
const c = document.getElementById('page-content');
const svcs = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
let h = '
';
for (const svc of svcs) h += serviceCard(svc);
h += '
';
c.innerHTML = h;
}
function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); }
function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); }
// ---------------------------------------------------------------------------
// Dashboard — Table View
// ---------------------------------------------------------------------------
function renderDashboardTable() {
const c = document.getElementById('page-content');
const svcs = filterServices(allServices);
let h = '
';
// Quick filter row
h += '
';
h += filterBtn('All', null);
h += filterBtn('Healthy', 'healthy');
h += filterBtn('Down', 'down');
h += '|';
const projects = [...new Set(allServices.map(s => s.project))].sort();
for (const p of projects) {
h += filterBtn(p, 'project:' + p);
}
h += '
';
if (health.checks && health.checks.length > 0) {
h += '
';
for (const ck of health.checks) {
const st = (ck.status || '').toUpperCase();
const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray';
h += `
${esc(st)}${esc(ck.check)}
`;
}
h += '
';
} else {
h += '
No health check data.
';
}
// Timers
h += '
Systemd Timers
';
if (timers.timers && timers.timers.length > 0) {
h += '
Unit
Next
Left
Last
Passed
';
for (const t of timers.timers) {
h += `
${esc(t.unit)}
${esc(t.next)}
${esc(t.left)}
${esc(t.last)}
${esc(t.passed)}
`;
}
h += '
';
} else {
h += '
No timers found.
';
}
h += '
';
c.innerHTML = h;
} catch (e) {
c.innerHTML = '
Failed to load system info: ' + esc(e.message) + '
';
}
}
// ---------------------------------------------------------------------------
// Restore
// ---------------------------------------------------------------------------
function renderRestore() {
updateBreadcrumbs();
const c = document.getElementById('page-content');
let h = '
';
h += '
Restore Backup
';
h += '
';
h += '';
h += '';
h += '';
h += '';
h += '';
h += '