'use strict';
// ============================================================
// OPS Dashboard — Vanilla JS Application
// ============================================================
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let allServices = [];
let currentPage = 'dashboard';
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 logModalProject = null;
let logModalEnv = null;
let logModalService = null;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatBytes(bytes) {
if (bytes == null || bytes === '') return '\u2014';
const n = Number(bytes);
if (isNaN(n) || n === 0) return '0 B';
const k = 1024;
const sizes = ['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) + ' ' + sizes[i];
}
function timeAgo(dateInput) {
if (!dateInput) return '\u2014';
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
if (isNaN(date)) return '\u2014';
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
if (secs < 60) return secs + 's ago';
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
return Math.floor(secs / 86400) + 'd ago';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function statusDotClass(status, health) {
const s = (status || '').toLowerCase();
const 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 badgeClass(status, health) {
const s = (status || '').toLowerCase();
const 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 diskColorClass(pct) {
const n = parseInt(pct);
if (isNaN(n)) return 'disk-ok';
if (n >= 90) return 'disk-danger';
if (n >= 75) return 'disk-warn';
return 'disk-ok';
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
function getToken() {
return localStorage.getItem('ops_token');
}
function doLogin() {
const input = document.getElementById('login-token');
const errEl = document.getElementById('login-error');
const token = input.value.trim();
if (!token) {
errEl.textContent = 'Please enter a token';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
// Validate token by calling the API
fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => {
if (!r.ok) throw new Error('Invalid token');
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(() => {
errEl.textContent = 'Invalid token. Try again.';
errEl.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 Helper
// ---------------------------------------------------------------------------
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 body = await resp.text();
throw new Error(body || 'HTTP ' + resp.status);
}
const ct = resp.headers.get('content-type') || '';
if (ct.includes('json')) return resp.json();
return resp.text();
}
async function fetchStatus() {
allServices = await api('/api/status/');
}
// ---------------------------------------------------------------------------
// Toast Notifications
// ---------------------------------------------------------------------------
function toast(message, type = 'info') {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast toast-' + type;
el.innerHTML = `${escapeHtml(message)} × `;
container.appendChild(el);
setTimeout(() => {
el.classList.add('toast-out');
setTimeout(() => el.remove(), 200);
}, 4000);
}
// ---------------------------------------------------------------------------
// Sidebar & 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;
// Update sidebar active
document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => {
el.classList.toggle('active', el.dataset.page === page);
});
// Close mobile sidebar
document.getElementById('sidebar').classList.remove('open');
document.getElementById('mobile-overlay').classList.remove('open');
renderPage();
}
function renderPage() {
const content = document.getElementById('page-content');
content.innerHTML = '
';
switch (currentPage) {
case 'dashboard': renderDashboard(); break;
case 'services': renderServicesFlat(); break;
case 'backups': renderBackups(); break;
case 'system': renderSystem(); break;
case 'restore': renderRestore(); break;
default: renderDashboard();
}
}
function refreshCurrentPage() {
showRefreshSpinner();
fetchStatus()
.then(() => renderPage())
.catch(e => toast('Refresh failed: ' + e.message, 'error'))
.finally(() => hideRefreshSpinner());
}
// ---------------------------------------------------------------------------
// Auto-refresh
// ---------------------------------------------------------------------------
function startAutoRefresh() {
stopAutoRefresh();
refreshTimer = setInterval(() => {
fetchStatus()
.then(() => {
if (currentPage === 'dashboard' || currentPage === 'services') renderPage();
})
.catch(() => {});
}, REFRESH_INTERVAL);
}
function stopAutoRefresh() {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
}
function showRefreshSpinner() {
document.getElementById('refresh-indicator').classList.remove('paused');
}
function hideRefreshSpinner() {
document.getElementById('refresh-indicator').classList.add('paused');
}
// ---------------------------------------------------------------------------
// Breadcrumbs
// ---------------------------------------------------------------------------
function updateBreadcrumbs() {
const bc = document.getElementById('breadcrumbs');
let html = '';
if (currentPage === 'dashboard') {
if (drillLevel === 0) {
html = 'Dashboard ';
} else if (drillLevel === 1) {
html = 'Dashboard / ' + escapeHtml(drillProject) + ' ';
} else if (drillLevel === 2) {
html = 'Dashboard / ' + escapeHtml(drillProject) + ' / ' + escapeHtml(drillEnv) + ' ';
}
} else {
const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' };
html = '' + (names[currentPage] || currentPage) + ' ';
}
bc.innerHTML = html;
}
function drillBack(level) {
if (level === 0) {
drillLevel = 0;
drillProject = null;
drillEnv = null;
} else if (level === 1) {
drillLevel = 1;
drillEnv = null;
}
renderDashboard();
}
// ---------------------------------------------------------------------------
// Dashboard — 3-level Drill
// ---------------------------------------------------------------------------
function renderDashboard() {
currentPage = 'dashboard';
if (drillLevel === 0) renderProjects();
else if (drillLevel === 1) renderEnvironments();
else if (drillLevel === 2) renderServices();
updateBreadcrumbs();
}
function renderProjects() {
const content = document.getElementById('page-content');
const projects = groupByProject(allServices);
// Summary stats
const totalUp = allServices.filter(s => s.status === 'Up').length;
const totalDown = allServices.length - totalUp;
let html = '';
// Summary bar
html += '
';
html += statCard('Projects', Object.keys(projects).length, '#3b82f6');
html += statCard('Services', allServices.length, '#8b5cf6');
html += statCard('Healthy', totalUp, '#10b981');
html += statCard('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280');
html += '
';
// Project cards
html += '
';
for (const [name, proj] of Object.entries(projects)) {
const upCount = proj.services.filter(s => s.status === 'Up').length;
const total = proj.services.length;
const allUp = upCount === total;
const envNames = [...new Set(proj.services.map(s => s.env))];
html += `
${escapeHtml(name)}
${total} services
${envNames.map(e => `${escapeHtml(e)} `).join('')}
${upCount}/${total} healthy
`;
}
html += '
';
content.innerHTML = html;
}
function renderEnvironments() {
const content = document.getElementById('page-content');
const projServices = allServices.filter(s => s.project === drillProject);
const envs = groupByEnv(projServices);
let html = '';
html += '
';
for (const [envName, services] of Object.entries(envs)) {
const upCount = services.filter(s => s.status === 'Up').length;
const total = services.length;
const allUp = upCount === total;
html += `
${escapeHtml(envName).toUpperCase()}
${total} services
${services.map(s => `${escapeHtml(s.service)} `).join('')}
${upCount}/${total} healthy
`;
}
html += '
';
content.innerHTML = html;
}
function renderServices() {
const content = document.getElementById('page-content');
const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
let html = '';
html += '
';
for (const svc of services) {
html += serviceCard(svc);
}
html += '
';
content.innerHTML = html;
}
function drillToProject(name) {
drillProject = name;
drillLevel = 1;
renderDashboard();
}
function drillToEnv(name) {
drillEnv = name;
drillLevel = 2;
renderDashboard();
}
// ---------------------------------------------------------------------------
// Service Card (shared component)
// ---------------------------------------------------------------------------
function serviceCard(svc) {
const proj = escapeHtml(svc.project);
const env = escapeHtml(svc.env);
const service = escapeHtml(svc.service);
const bc = badgeClass(svc.status, svc.health);
const dc = statusDotClass(svc.status, svc.health);
return `
${service}
${escapeHtml(svc.status)}
Health: ${escapeHtml(svc.health || 'n/a')} · Uptime: ${escapeHtml(svc.uptime || 'n/a')}
Logs
Restart
`;
}
function statCard(label, value, color) {
return ``;
}
// ---------------------------------------------------------------------------
// Services (flat list page)
// ---------------------------------------------------------------------------
function renderServicesFlat() {
updateBreadcrumbs();
const content = document.getElementById('page-content');
if (allServices.length === 0) {
content.innerHTML = 'No services found.
';
return;
}
let html = '';
html += '
';
html += 'Project Env Service Status Health Uptime Actions ';
html += '';
for (const svc of allServices) {
const bc = badgeClass(svc.status, svc.health);
const proj = escapeHtml(svc.project);
const env = escapeHtml(svc.env);
const service = escapeHtml(svc.service);
html += `
${proj}
${env}
${service}
${escapeHtml(svc.status)}
${escapeHtml(svc.health || 'n/a')}
${escapeHtml(svc.uptime || 'n/a')}
Logs
Restart
`;
}
html += '
';
content.innerHTML = html;
}
// ---------------------------------------------------------------------------
// Backups Page
// ---------------------------------------------------------------------------
async function renderBackups() {
updateBreadcrumbs();
const content = document.getElementById('page-content');
try {
const [local, offsite] = await Promise.all([
api('/api/backups/'),
api('/api/backups/offsite').catch(() => []),
]);
let html = '';
// Quick backup buttons
html += '
';
html += '
Create Backup ';
html += '
';
for (const proj of ['mdf', 'seriousletter']) {
for (const env of ['dev', 'int', 'prod']) {
html += `${proj}/${env} `;
}
}
html += '
';
// Local backups
html += '
Local Backups ';
if (local.length === 0) {
html += '
No local backups found.
';
} else {
html += '
';
html += 'Project Env Date Size Files ';
for (const b of local) {
html += `
${escapeHtml(b.project || '')}
${escapeHtml(b.env || b.environment || '')}
${escapeHtml(b.date || b.timestamp || '')}
${escapeHtml(b.size || '')}
${escapeHtml(b.file || b.files || '')}
`;
}
html += '
';
}
// Offsite backups
html += '
Offsite Backups ';
if (offsite.length === 0) {
html += '
No offsite backups found.
';
} else {
html += '
';
html += 'Project Env Date Size ';
for (const b of offsite) {
html += `
${escapeHtml(b.project || '')}
${escapeHtml(b.env || b.environment || '')}
${escapeHtml(b.date || b.timestamp || '')}
${escapeHtml(b.size || '')}
`;
}
html += '
';
}
html += '
';
content.innerHTML = html;
} catch (e) {
content.innerHTML = 'Failed to load backups: ' + escapeHtml(e.message) + '
';
}
}
// ---------------------------------------------------------------------------
// System Page
// ---------------------------------------------------------------------------
async function renderSystem() {
updateBreadcrumbs();
const content = document.getElementById('page-content');
try {
const [disk, health, timers, info] = await Promise.all([
api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })),
api('/api/system/health').catch(e => ({ checks: [], raw: e.message })),
api('/api/system/timers').catch(e => ({ timers: [], raw: e.message })),
api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })),
]);
let html = '';
// System info bar
html += '
';
html += statCard('Uptime', info.uptime || 'n/a', '#3b82f6');
html += statCard('Load', info.load || 'n/a', '#8b5cf6');
html += '
';
// Disk usage
html += '
Disk Usage ';
if (disk.filesystems && disk.filesystems.length > 0) {
html += '
';
for (const fs of disk.filesystems) {
const pct = parseInt(fs.use_percent) || 0;
html += `
${escapeHtml(fs.mount || fs.filesystem)}
${escapeHtml(fs.used)} / ${escapeHtml(fs.size)} (${escapeHtml(fs.use_percent)})
`;
}
html += '
';
} else {
html += '
No disk data available.
';
}
// Health checks
html += '
Health Checks ';
if (health.checks && health.checks.length > 0) {
html += '
';
for (const c of health.checks) {
const st = (c.status || '').toUpperCase();
const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray';
html += `
${escapeHtml(st)}
${escapeHtml(c.check)}
`;
}
html += '
';
} else {
html += '
No health check data.
';
}
// Timers
html += '
Systemd Timers ';
if (timers.timers && timers.timers.length > 0) {
html += '
';
html += 'Unit Next Left Last Passed ';
for (const t of timers.timers) {
html += `
${escapeHtml(t.unit)}
${escapeHtml(t.next)}
${escapeHtml(t.left)}
${escapeHtml(t.last)}
${escapeHtml(t.passed)}
`;
}
html += '
';
} else {
html += '
No timers found.
';
}
html += '
';
content.innerHTML = html;
} catch (e) {
content.innerHTML = 'Failed to load system info: ' + escapeHtml(e.message) + '
';
}
}
// ---------------------------------------------------------------------------
// Restore Page
// ---------------------------------------------------------------------------
function renderRestore() {
updateBreadcrumbs();
const content = document.getElementById('page-content');
let html = '';
html += '
Restore Backup ';
html += '
';
html += '
';
html += 'Project ';
html += 'mdf seriousletter ';
html += '
';
html += '
';
html += 'Environment ';
html += 'dev int prod ';
html += '
';
html += '
';
html += 'Source ';
html += 'Local Offsite ';
html += '
';
html += '
';
html += '';
html += ' Dry run (preview only)';
html += ' ';
html += '
';
html += '
Start Restore ';
html += '
';
html += '
';
html += '
Output ';
html += '
';
html += '
';
html += '
';
content.innerHTML = html;
}
async function startRestore() {
const project = document.getElementById('restore-project').value;
const env = document.getElementById('restore-env').value;
const source = document.getElementById('restore-source').value;
const dryRun = document.getElementById('restore-dry').checked;
if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}? This may overwrite data.`)) return;
const outputDiv = document.getElementById('restore-output');
const terminal = document.getElementById('restore-terminal');
outputDiv.style.display = 'block';
terminal.textContent = 'Starting restore...\n';
const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`;
const evtSource = new EventSource(url);
evtSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.done) {
evtSource.close();
terminal.textContent += data.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
toast(data.success ? 'Restore completed' : 'Restore failed', data.success ? 'success' : 'error');
return;
}
if (data.line) {
terminal.textContent += data.line + '\n';
terminal.scrollTop = terminal.scrollHeight;
}
};
evtSource.onerror = function() {
evtSource.close();
terminal.textContent += '\n--- Connection lost ---\n';
toast('Restore connection lost', 'error');
};
}
// ---------------------------------------------------------------------------
// Service Actions
// ---------------------------------------------------------------------------
async function restartService(project, env, service) {
if (!confirm(`Restart ${service} in ${project}/${env}?`)) return;
toast('Restarting ' + service + '...', 'info');
try {
const result = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
toast(result.message || 'Restarted successfully', 'success');
setTimeout(() => refreshCurrentPage(), 3000);
} catch (e) {
toast('Restart failed: ' + e.message, 'error');
}
}
async function viewLogs(project, env, service) {
logModalProject = project;
logModalEnv = env;
logModalService = service;
document.getElementById('log-modal-title').textContent = `Logs: ${project}/${env}/${service}`;
document.getElementById('log-modal-content').textContent = 'Loading...';
document.getElementById('log-modal').style.display = 'flex';
await refreshLogs();
}
async function refreshLogs() {
if (!logModalProject) return;
try {
const data = await api(`/api/services/logs/${logModalProject}/${logModalEnv}/${logModalService}?lines=200`);
const terminal = document.getElementById('log-modal-content');
terminal.textContent = data.logs || 'No logs available.';
terminal.scrollTop = terminal.scrollHeight;
} catch (e) {
document.getElementById('log-modal-content').textContent = 'Error loading logs: ' + e.message;
}
}
function closeLogModal() {
document.getElementById('log-modal').style.display = 'none';
logModalProject = null;
logModalEnv = null;
logModalService = null;
}
// ---------------------------------------------------------------------------
// Backup Actions
// ---------------------------------------------------------------------------
async function createBackup(project, env) {
if (!confirm(`Create backup for ${project}/${env}?`)) return;
toast('Creating backup for ' + project + '/' + env + '...', 'info');
try {
await api(`/api/backups/${project}/${env}`, { method: 'POST' });
toast('Backup created for ' + project + '/' + env, 'success');
if (currentPage === 'backups') renderBackups();
} catch (e) {
toast('Backup failed: ' + e.message, 'error');
}
}
// ---------------------------------------------------------------------------
// Data Grouping
// ---------------------------------------------------------------------------
function groupByProject(services) {
const map = {};
for (const s of services) {
const key = s.project || 'other';
if (!map[key]) map[key] = { name: key, services: [] };
map[key].services.push(s);
}
return map;
}
function groupByEnv(services) {
const map = {};
for (const s of services) {
const key = s.env || 'default';
if (!map[key]) map[key] = [];
map[key].push(s);
}
return map;
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
(function init() {
const token = getToken();
if (token) {
// Validate and load
fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => {
if (!r.ok) throw new Error('Invalid token');
return r.json();
})
.then(data => {
allServices = data;
document.getElementById('login-overlay').style.display = 'none';
document.getElementById('app').style.display = 'flex';
showPage('dashboard');
startAutoRefresh();
})
.catch(() => {
localStorage.removeItem('ops_token');
document.getElementById('login-overlay').style.display = 'flex';
});
}
// ESC to close modals
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeLogModal();
}
});
})();