From ed26def7d76ac011075c11e8c1679ed1f7a08abc Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 21 Feb 2026 16:48:40 +0100
Subject: [PATCH] feat: Clickable stat tiles, view toggle, CPU/memory metrics, restore fix
---
static/js/app.js | 864 ++++++++++++++++++++++++++-------------------------------
1 files changed, 398 insertions(+), 466 deletions(-)
diff --git a/static/js/app.js b/static/js/app.js
index 41fd842..0415eb7 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,7 +1,7 @@
'use strict';
// ============================================================
-// OPS Dashboard — Vanilla JS Application
+// OPS Dashboard — Vanilla JS Application (v3)
// ============================================================
// ---------------------------------------------------------------------------
@@ -9,99 +9,95 @@
// ---------------------------------------------------------------------------
let allServices = [];
let currentPage = 'dashboard';
-let drillLevel = 0; // 0=projects, 1=environments, 2=services
+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 logModalProject = null;
-let logModalEnv = null;
-let logModalService = null;
+let logCtx = { project: null, env: null, service: null };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
-function formatBytes(bytes) {
- if (bytes == null || bytes === '') return '\u2014';
- const n = Number(bytes);
+function fmtBytes(b) {
+ if (b == null) return '\u2014';
+ const n = Number(b);
if (isNaN(n) || n === 0) return '0 B';
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ 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) + ' ' + sizes[i];
+ return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + s[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 esc(str) {
+ const d = document.createElement('div');
+ d.textContent = str;
+ return d.innerHTML;
}
-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';
+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 badgeClass(status, health) {
- const s = (status || '').toLowerCase();
- const h = (health || '').toLowerCase();
- if (s === 'up' && (h === 'healthy' || h === '')) return 'badge-green';
+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 diskColorClass(pct) {
+function diskColor(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';
}
+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 getToken() { return localStorage.getItem('ops_token'); }
function doLogin() {
const input = document.getElementById('login-token');
- const errEl = document.getElementById('login-error');
+ const err = 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
+ 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('Invalid token');
- return r.json();
- })
+ .then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(data => {
localStorage.setItem('ops_token', token);
allServices = data;
@@ -110,10 +106,7 @@
showPage('dashboard');
startAutoRefresh();
})
- .catch(() => {
- errEl.textContent = 'Invalid token. Try again.';
- errEl.style.display = 'block';
- });
+ .catch(() => { err.textContent = 'Invalid token.'; err.style.display = 'block'; });
}
function doLogout() {
@@ -125,46 +118,34 @@
}
// ---------------------------------------------------------------------------
-// API Helper
+// 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 body = await resp.text();
- throw new Error(body || 'HTTP ' + resp.status);
- }
+ 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') || '';
- if (ct.includes('json')) return resp.json();
- return resp.text();
+ return ct.includes('json') ? resp.json() : resp.text();
}
-async function fetchStatus() {
- allServices = await api('/api/status/');
-}
+async function fetchStatus() { allServices = await api('/api/status/'); }
// ---------------------------------------------------------------------------
-// Toast Notifications
+// Toast
// ---------------------------------------------------------------------------
-function toast(message, type = 'info') {
- const container = document.getElementById('toast-container');
+function toast(msg, type = 'info') {
+ const c = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast toast-' + type;
- el.innerHTML = `<span>${escapeHtml(message)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">×</span>`;
- container.appendChild(el);
- setTimeout(() => {
- el.classList.add('toast-out');
- setTimeout(() => el.remove(), 200);
- }, 4000);
+ el.innerHTML = `<span>${esc(msg)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">×</span>`;
+ c.appendChild(el);
+ setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 200); }, 4000);
}
// ---------------------------------------------------------------------------
-// Sidebar & Navigation
+// Navigation
// ---------------------------------------------------------------------------
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
@@ -173,16 +154,11 @@
function showPage(page) {
currentPage = page;
- drillLevel = 0;
- drillProject = null;
- drillEnv = null;
+ drillLevel = 0; drillProject = null; drillEnv = null;
+ if (page !== 'dashboard') { viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; }
- // Update sidebar active
- document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => {
- el.classList.toggle('active', el.dataset.page === page);
- });
-
- // Close mobile sidebar
+ 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');
@@ -190,12 +166,12 @@
}
function renderPage() {
- const content = document.getElementById('page-content');
- content.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
+ const c = document.getElementById('page-content');
+ c.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
+ updateViewToggle();
switch (currentPage) {
case 'dashboard': renderDashboard(); break;
- case 'services': renderServicesFlat(); break;
case 'backups': renderBackups(); break;
case 'system': renderSystem(); break;
case 'restore': renderRestore(); break;
@@ -204,11 +180,44 @@
}
function refreshCurrentPage() {
- showRefreshSpinner();
- fetchStatus()
- .then(() => renderPage())
- .catch(e => toast('Refresh failed: ' + e.message, 'error'))
- .finally(() => hideRefreshSpinner());
+ 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';
+ }
}
// ---------------------------------------------------------------------------
@@ -217,321 +226,298 @@
function startAutoRefresh() {
stopAutoRefresh();
refreshTimer = setInterval(() => {
- fetchStatus()
- .then(() => {
- if (currentPage === 'dashboard' || currentPage === 'services') renderPage();
- })
- .catch(() => {});
+ fetchStatus().then(() => { if (currentPage === 'dashboard') 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');
-}
+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 html = '';
+ let h = '';
if (currentPage === 'dashboard') {
- if (drillLevel === 0) {
- html = '<span class="current">Dashboard</span>';
+ if (viewMode === 'table') {
+ h = '<a onclick="setViewMode(\'cards\')">Dashboard</a><span class="sep">/</span>';
+ h += '<span class="current">All Services</span>';
+ if (tableFilter) {
+ h += ' <span class="filter-badge">' + esc(tableFilterLabel) +
+ ' <button onclick="clearFilter()">×</button></span>';
+ }
+ } else if (drillLevel === 0) {
+ h = '<span class="current">Dashboard</span>';
} else if (drillLevel === 1) {
- html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + escapeHtml(drillProject) + '</span>';
+ h = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + esc(drillProject) + '</span>';
} else if (drillLevel === 2) {
- html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + escapeHtml(drillProject) + '</a><span class="sep">/</span><span class="current">' + escapeHtml(drillEnv) + '</span>';
+ h = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + esc(drillProject) + '</a><span class="sep">/</span><span class="current">' + esc(drillEnv) + '</span>';
}
} else {
- const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' };
- html = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
+ const names = { backups: 'Backups', system: 'System', restore: 'Restore' };
+ h = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
}
-
- bc.innerHTML = html;
+ bc.innerHTML = h;
}
function drillBack(level) {
- if (level === 0) {
- drillLevel = 0;
- drillProject = null;
- drillEnv = null;
- } else if (level === 1) {
- drillLevel = 1;
- drillEnv = null;
- }
+ if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; }
+ else if (level === 1) { drillLevel = 1; drillEnv = null; }
renderDashboard();
}
// ---------------------------------------------------------------------------
-// Dashboard — 3-level Drill
+// Dashboard — Cards + Table modes
// ---------------------------------------------------------------------------
function renderDashboard() {
currentPage = 'dashboard';
- if (drillLevel === 0) renderProjects();
- else if (drillLevel === 1) renderEnvironments();
- else if (drillLevel === 2) renderServices();
+ if (viewMode === 'table') { renderDashboardTable(); }
+ else if (drillLevel === 0) { renderProjects(); }
+ else if (drillLevel === 1) { renderEnvironments(); }
+ else { renderDrillServices(); }
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 c = document.getElementById('page-content');
+ const projects = groupBy(allServices, 'project');
+ const totalUp = allServices.filter(isHealthy).length;
const totalDown = allServices.length - totalUp;
- let html = '<div class="page-enter" style="padding:0;">';
+ let h = '<div class="page-enter">';
- // Summary bar
- html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
- 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 += '</div>';
+ // Stat tiles — clickable
+ h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
+ 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 += '</div>';
// Project cards
- html += '<div class="project-grid">';
- 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 += `<div class="card card-clickable" onclick="drillToProject('${escapeHtml(name)}')">
+ h += '<div class="grid-auto">';
+ 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 += `<div class="card card-clickable" onclick="drillToProject('${esc(name)}')">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
- <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
- <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(name)}</span>
- <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
+ <span class="status-dot ${up === total ? 'status-dot-green' : 'status-dot-red'}"></span>
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${esc(name)}</span>
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} svc</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
- ${envNames.map(e => `<span class="badge badge-blue">${escapeHtml(e)}</span>`).join('')}
+ ${envs.map(e => `<span class="badge badge-blue">${esc(e)}</span>`).join('')}
</div>
- <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
+ <div style="font-size:0.8125rem;color:#9ca3af;">${up}/${total} healthy</div>
</div>`;
}
- html += '</div></div>';
- content.innerHTML = html;
+ h += '</div></div>';
+ c.innerHTML = h;
}
function renderEnvironments() {
- const content = document.getElementById('page-content');
- const projServices = allServices.filter(s => s.project === drillProject);
- const envs = groupByEnv(projServices);
+ const c = document.getElementById('page-content');
+ const envs = groupBy(allServices.filter(s => s.project === drillProject), 'env');
- let html = '<div class="page-enter" style="padding:0;">';
- html += '<div class="env-grid">';
-
- 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 += `<div class="card card-clickable" onclick="drillToEnv('${escapeHtml(envName)}')">
+ let h = '<div class="page-enter"><div class="grid-auto">';
+ for (const [envName, svcs] of Object.entries(envs)) {
+ const up = svcs.filter(isHealthy).length;
+ const total = svcs.length;
+ h += `<div class="card card-clickable" onclick="drillToEnv('${esc(envName)}')">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
- <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
- <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(envName).toUpperCase()}</span>
- <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
+ <span class="status-dot ${up === total ? 'status-dot-green' : 'status-dot-red'}"></span>
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${esc(envName).toUpperCase()}</span>
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} svc</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
- ${services.map(s => `<span class="badge ${badgeClass(s.status, s.health)}">${escapeHtml(s.service)}</span>`).join('')}
+ ${svcs.map(s => `<span class="badge ${badgeCls(s.status, s.health)}">${esc(s.service)}</span>`).join('')}
</div>
- <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
+ <div style="font-size:0.8125rem;color:#9ca3af;">${up}/${total} healthy</div>
</div>`;
}
-
- html += '</div></div>';
- content.innerHTML = html;
+ h += '</div></div>';
+ c.innerHTML = h;
}
-function renderServices() {
- const content = document.getElementById('page-content');
- const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
+function renderDrillServices() {
+ const c = document.getElementById('page-content');
+ const svcs = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
+ let h = '<div class="page-enter"><div class="grid-auto">';
+ for (const svc of svcs) h += serviceCard(svc);
+ h += '</div></div>';
+ c.innerHTML = h;
+}
- let html = '<div class="page-enter" style="padding:0;">';
- html += '<div class="service-grid">';
+function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); }
+function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); }
- for (const svc of services) {
- html += serviceCard(svc);
+// ---------------------------------------------------------------------------
+// Dashboard — Table View
+// ---------------------------------------------------------------------------
+function renderDashboardTable() {
+ const c = document.getElementById('page-content');
+ const svcs = filterServices(allServices);
+
+ let h = '<div class="page-enter">';
+
+ // Quick filter row
+ h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:1rem;">';
+ h += filterBtn('All', null);
+ h += filterBtn('Healthy', 'healthy');
+ h += filterBtn('Down', 'down');
+ h += '<span style="color:#374151;">|</span>';
+ const projects = [...new Set(allServices.map(s => s.project))].sort();
+ for (const p of projects) {
+ h += filterBtn(p, 'project:' + p);
+ }
+ h += '</div>';
+
+ // Table
+ if (svcs.length === 0) {
+ h += '<div class="card" style="text-align:center;color:#6b7280;padding:2rem;">No services match this filter.</div>';
+ } else {
+ h += '<div class="table-wrapper"><table class="ops-table">';
+ h += '<thead><tr><th>Project</th><th>Env</th><th>Service</th><th>Status</th><th>Health</th><th>Uptime</th><th>Actions</th></tr></thead><tbody>';
+ for (const svc of svcs) {
+ h += `<tr>
+ <td><a style="color:#60a5fa;cursor:pointer;" onclick="setTableFilter('project:${esc(svc.project)}','${esc(svc.project)}')">${esc(svc.project)}</a></td>
+ <td><span class="badge badge-blue">${esc(svc.env)}</span></td>
+ <td class="mono">${esc(svc.service)}</td>
+ <td><span class="badge ${badgeCls(svc.status, svc.health)}">${esc(svc.status)}</span></td>
+ <td>${esc(svc.health || 'n/a')}</td>
+ <td>${esc(svc.uptime || 'n/a')}</td>
+ <td style="white-space:nowrap;">
+ <button class="btn btn-ghost btn-xs" onclick="viewLogs('${esc(svc.project)}','${esc(svc.env)}','${esc(svc.service)}')">Logs</button>
+ <button class="btn btn-warning btn-xs" onclick="restartService('${esc(svc.project)}','${esc(svc.env)}','${esc(svc.service)}')">Restart</button>
+ </td>
+ </tr>`;
+ }
+ h += '</tbody></table></div>';
}
- html += '</div></div>';
- content.innerHTML = html;
-}
-
-function drillToProject(name) {
- drillProject = name;
- drillLevel = 1;
- renderDashboard();
-}
-
-function drillToEnv(name) {
- drillEnv = name;
- drillLevel = 2;
- renderDashboard();
+ h += '</div>';
+ c.innerHTML = h;
}
// ---------------------------------------------------------------------------
-// Service Card (shared component)
+// Shared Components
// ---------------------------------------------------------------------------
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);
-
+ const p = esc(svc.project), e = esc(svc.env), s = esc(svc.service);
return `<div class="card">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
- <span class="status-dot ${dc}"></span>
- <span style="font-weight:600;color:#f3f4f6;">${service}</span>
- <span class="badge ${bc}" style="margin-left:auto;">${escapeHtml(svc.status)}</span>
+ <span class="status-dot ${dotClass(svc.status, svc.health)}"></span>
+ <span style="font-weight:600;color:#f3f4f6;">${s}</span>
+ <span class="badge ${badgeCls(svc.status, svc.health)}" style="margin-left:auto;">${esc(svc.status)}</span>
</div>
<div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;">
- Health: ${escapeHtml(svc.health || 'n/a')} · Uptime: ${escapeHtml(svc.uptime || 'n/a')}
+ Health: ${esc(svc.health || 'n/a')} · Uptime: ${esc(svc.uptime || 'n/a')}
</div>
- <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
- <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
- <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
+ <div style="display:flex;gap:0.5rem;">
+ <button class="btn btn-ghost btn-xs" onclick="viewLogs('${p}','${e}','${s}')">Logs</button>
+ <button class="btn btn-warning btn-xs" onclick="restartService('${p}','${e}','${s}')">Restart</button>
</div>
</div>`;
}
-function statCard(label, value, color) {
- return `<div class="card" style="text-align:center;">
+function statTile(label, value, color, onclick) {
+ const click = onclick ? ` onclick="${onclick}"` : '';
+ const cls = onclick ? ' stat-tile' : '';
+ return `<div class="card${cls}" style="text-align:center;"${click}>
<div style="font-size:1.75rem;font-weight:700;color:${color};">${value}</div>
<div style="font-size:0.8125rem;color:#9ca3af;">${label}</div>
</div>`;
}
-// ---------------------------------------------------------------------------
-// Services (flat list page)
-// ---------------------------------------------------------------------------
-function renderServicesFlat() {
- updateBreadcrumbs();
- const content = document.getElementById('page-content');
-
- if (allServices.length === 0) {
- content.innerHTML = '<div style="text-align:center;padding:3rem;color:#6b7280;">No services found.</div>';
- return;
+function filterBtn(label, filter) {
+ const active = tableFilter === filter;
+ const cls = active ? 'btn btn-primary btn-xs' : 'btn btn-ghost btn-xs';
+ if (filter === null) {
+ return `<button class="${cls}" onclick="tableFilter=null;tableFilterLabel='';renderDashboard()">${label}</button>`;
}
+ return `<button class="${cls}" onclick="setTableFilter('${filter}','${label}')">${label}</button>`;
+}
- let html = '<div class="page-enter" style="padding:0;">';
- html += '<div class="table-wrapper"><table class="ops-table">';
- html += '<thead><tr><th>Project</th><th>Env</th><th>Service</th><th>Status</th><th>Health</th><th>Uptime</th><th>Actions</th></tr></thead>';
- html += '<tbody>';
-
- 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 += `<tr>
- <td style="font-weight:500;">${proj}</td>
- <td><span class="badge badge-blue">${env}</span></td>
- <td class="mono">${service}</td>
- <td><span class="badge ${bc}">${escapeHtml(svc.status)}</span></td>
- <td>${escapeHtml(svc.health || 'n/a')}</td>
- <td>${escapeHtml(svc.uptime || 'n/a')}</td>
- <td style="white-space:nowrap;">
- <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
- <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
- </td>
- </tr>`;
- }
-
- html += '</tbody></table></div></div>';
- content.innerHTML = html;
+function metricBar(label, used, total, unit, color) {
+ if (!total || total === 0) return '';
+ const pct = Math.round(used / total * 100);
+ const cls = pct >= 90 ? 'disk-danger' : pct >= 75 ? 'disk-warn' : color || 'disk-ok';
+ return `<div class="card">
+ <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
+ <span style="font-weight:500;color:#f3f4f6;">${label}</span>
+ <span style="font-size:0.8125rem;color:#9ca3af;">${fmtBytes(used)} / ${fmtBytes(total)} (${pct}%)</span>
+ </div>
+ <div class="progress-bar-track">
+ <div class="progress-bar-fill ${cls}" style="width:${pct}%;"></div>
+ </div>
+ </div>`;
}
// ---------------------------------------------------------------------------
-// Backups Page
+// Backups
// ---------------------------------------------------------------------------
async function renderBackups() {
updateBreadcrumbs();
- const content = document.getElementById('page-content');
-
+ const c = document.getElementById('page-content');
try {
const [local, offsite] = await Promise.all([
api('/api/backups/'),
api('/api/backups/offsite').catch(() => []),
]);
- let html = '<div class="page-enter" style="padding:0;">';
+ let h = '<div class="page-enter">';
// Quick backup buttons
- html += '<div style="margin-bottom:1.5rem;">';
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
- html += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
- for (const proj of ['mdf', 'seriousletter']) {
- for (const env of ['dev', 'int', 'prod']) {
- html += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${proj}','${env}')">${proj}/${env}</button>`;
+ h += '<div style="margin-bottom:1.5rem;">';
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
+ h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
+ for (const p of ['mdf', 'seriousletter']) {
+ for (const e of ['dev', 'int', 'prod']) {
+ h += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${p}','${e}')">${p}/${e}</button>`;
}
}
- html += '</div></div>';
+ h += '</div></div>';
- // Local backups
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
+ // Local
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
if (local.length === 0) {
- html += '<div class="card" style="color:#6b7280;">No local backups found.</div>';
+ h += '<div class="card" style="color:#6b7280;">No local backups found.</div>';
} else {
- html += '<div class="table-wrapper"><table class="ops-table">';
- html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th><th>Files</th></tr></thead><tbody>';
+ h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th><th>Files</th></tr></thead><tbody>';
for (const b of local) {
- html += `<tr>
- <td>${escapeHtml(b.project || '')}</td>
- <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
- <td>${escapeHtml(b.date || b.timestamp || '')}</td>
- <td>${escapeHtml(b.size || '')}</td>
- <td class="mono" style="font-size:0.75rem;">${escapeHtml(b.file || b.files || '')}</td>
- </tr>`;
+ h += `<tr><td>${esc(b.project||'')}</td><td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td><td>${esc(b.date||b.timestamp||'')}</td><td>${esc(b.size||'')}</td><td class="mono" style="font-size:0.75rem;">${esc(b.file||b.files||'')}</td></tr>`;
}
- html += '</tbody></table></div>';
+ h += '</tbody></table></div>';
}
- // Offsite backups
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';
+ // Offsite
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';
if (offsite.length === 0) {
- html += '<div class="card" style="color:#6b7280;">No offsite backups found.</div>';
+ h += '<div class="card" style="color:#6b7280;">No offsite backups found.</div>';
} else {
- html += '<div class="table-wrapper"><table class="ops-table">';
- html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th></tr></thead><tbody>';
+ h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th></tr></thead><tbody>';
for (const b of offsite) {
- html += `<tr>
- <td>${escapeHtml(b.project || '')}</td>
- <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
- <td>${escapeHtml(b.date || b.timestamp || '')}</td>
- <td>${escapeHtml(b.size || '')}</td>
- </tr>`;
+ h += `<tr><td>${esc(b.project||'')}</td><td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td><td>${esc(b.date||b.timestamp||'')}</td><td>${esc(b.size||'')}</td></tr>`;
}
- html += '</tbody></table></div>';
+ h += '</tbody></table></div>';
}
- html += '</div>';
- content.innerHTML = html;
+ h += '</div>';
+ c.innerHTML = h;
} catch (e) {
- content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + escapeHtml(e.message) + '</div>';
+ c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + esc(e.message) + '</div>';
}
}
// ---------------------------------------------------------------------------
-// System Page
+// System
// ---------------------------------------------------------------------------
async function renderSystem() {
updateBreadcrumbs();
- const content = document.getElementById('page-content');
-
+ const c = document.getElementById('page-content');
try {
const [disk, health, timers, info] = await Promise.all([
api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })),
@@ -540,120 +526,119 @@
api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })),
]);
- let html = '<div class="page-enter" style="padding:0;">';
+ let h = '<div class="page-enter">';
- // System info bar
- html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
- html += statCard('Uptime', info.uptime || 'n/a', '#3b82f6');
- html += statCard('Load', info.load || 'n/a', '#8b5cf6');
- html += '</div>';
+ // Resource metrics (CPU, Memory, Swap)
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Resources</h2>';
+ h += '<div class="grid-metrics" style="margin-bottom:1.5rem;">';
- // Disk usage
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>';
- if (disk.filesystems && disk.filesystems.length > 0) {
- html += '<div style="display:grid;gap:0.75rem;margin-bottom:1.5rem;">';
- for (const fs of disk.filesystems) {
+ if (info.cpu) {
+ const cpu = info.cpu;
+ const cpuPct = cpu.usage_percent || 0;
+ const cpuCls = cpuPct >= 90 ? 'disk-danger' : cpuPct >= 75 ? 'disk-warn' : 'disk-ok';
+ h += `<div class="card">
+ <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
+ <span style="font-weight:500;color:#f3f4f6;">CPU</span>
+ <span style="font-size:0.8125rem;color:#9ca3af;">${cpuPct}% (${cpu.cores} cores)</span>
+ </div>
+ <div class="progress-bar-track">
+ <div class="progress-bar-fill ${cpuCls}" style="width:${cpuPct}%;"></div>
+ </div>
+ </div>`;
+ }
+
+ if (info.memory) {
+ h += metricBar('Memory', info.memory.used, info.memory.total);
+ }
+
+ if (info.swap && info.swap.total > 0) {
+ h += metricBar('Swap', info.swap.used, info.swap.total);
+ }
+
+ h += '</div>';
+
+ // Quick stats row
+ h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
+ h += statTile('Uptime', info.uptime || 'n/a', '#3b82f6');
+ h += statTile('Load', info.load || 'n/a', '#8b5cf6');
+ h += '</div>';
+
+ // Disk usage — only real filesystems
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>';
+ const realFs = (disk.filesystems || []).filter(f => f.filesystem && f.filesystem.startsWith('/dev'));
+ if (realFs.length > 0) {
+ h += '<div class="grid-metrics" style="margin-bottom:1.5rem;">';
+ for (const fs of realFs) {
const pct = parseInt(fs.use_percent) || 0;
- html += `<div class="card">
+ h += `<div class="card">
<div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
- <span class="mono" style="font-size:0.8125rem;">${escapeHtml(fs.mount || fs.filesystem)}</span>
- <span style="font-size:0.8125rem;color:#9ca3af;">${escapeHtml(fs.used)} / ${escapeHtml(fs.size)} (${escapeHtml(fs.use_percent)})</span>
+ <span class="mono" style="font-size:0.8125rem;">${esc(fs.mount || fs.filesystem)}</span>
+ <span style="font-size:0.8125rem;color:#9ca3af;">${esc(fs.used)} / ${esc(fs.size)} (${esc(fs.use_percent)})</span>
</div>
<div class="progress-bar-track">
- <div class="progress-bar-fill ${diskColorClass(fs.use_percent)}" style="width:${pct}%;"></div>
+ <div class="progress-bar-fill ${diskColor(fs.use_percent)}" style="width:${pct}%;"></div>
</div>
</div>`;
}
- html += '</div>';
+ h += '</div>';
} else {
- html += '<div class="card" style="color:#6b7280;">No disk data available.</div>';
+ h += '<div class="card" style="color:#6b7280;">No disk data.</div>';
}
// Health checks
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>';
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>';
if (health.checks && health.checks.length > 0) {
- html += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">';
- for (const c of health.checks) {
- const st = (c.status || '').toUpperCase();
+ h += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">';
+ for (const ck of health.checks) {
+ const st = (ck.status || '').toUpperCase();
const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray';
- html += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;">
- <span class="badge ${cls}">${escapeHtml(st)}</span>
- <span style="font-size:0.875rem;">${escapeHtml(c.check)}</span>
+ h += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;">
+ <span class="badge ${cls}">${esc(st)}</span>
+ <span style="font-size:0.875rem;">${esc(ck.check)}</span>
</div>`;
}
- html += '</div>';
+ h += '</div>';
} else {
- html += '<div class="card" style="color:#6b7280;">No health check data.</div>';
+ h += '<div class="card" style="color:#6b7280;">No health check data.</div>';
}
// Timers
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Systemd Timers</h2>';
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Systemd Timers</h2>';
if (timers.timers && timers.timers.length > 0) {
- html += '<div class="table-wrapper"><table class="ops-table">';
- html += '<thead><tr><th>Unit</th><th>Next</th><th>Left</th><th>Last</th><th>Passed</th></tr></thead><tbody>';
+ h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Unit</th><th>Next</th><th>Left</th><th>Last</th><th>Passed</th></tr></thead><tbody>';
for (const t of timers.timers) {
- html += `<tr>
- <td class="mono">${escapeHtml(t.unit)}</td>
- <td>${escapeHtml(t.next)}</td>
- <td>${escapeHtml(t.left)}</td>
- <td>${escapeHtml(t.last)}</td>
- <td>${escapeHtml(t.passed)}</td>
- </tr>`;
+ h += `<tr><td class="mono">${esc(t.unit)}</td><td>${esc(t.next)}</td><td>${esc(t.left)}</td><td>${esc(t.last)}</td><td>${esc(t.passed)}</td></tr>`;
}
- html += '</tbody></table></div>';
+ h += '</tbody></table></div>';
} else {
- html += '<div class="card" style="color:#6b7280;">No timers found.</div>';
+ h += '<div class="card" style="color:#6b7280;">No timers found.</div>';
}
- html += '</div>';
- content.innerHTML = html;
+ h += '</div>';
+ c.innerHTML = h;
} catch (e) {
- content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + escapeHtml(e.message) + '</div>';
+ c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + esc(e.message) + '</div>';
}
}
// ---------------------------------------------------------------------------
-// Restore Page
+// Restore
// ---------------------------------------------------------------------------
function renderRestore() {
updateBreadcrumbs();
- const content = document.getElementById('page-content');
-
- let html = '<div class="page-enter" style="padding:0;">';
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';
- html += '<div class="card" style="max-width:480px;">';
-
- html += '<div style="margin-bottom:1rem;">';
- html += '<label class="form-label">Project</label>';
- html += '<select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select>';
- html += '</div>';
-
- html += '<div style="margin-bottom:1rem;">';
- html += '<label class="form-label">Environment</label>';
- html += '<select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select>';
- html += '</div>';
-
- html += '<div style="margin-bottom:1rem;">';
- html += '<label class="form-label">Source</label>';
- html += '<select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select>';
- html += '</div>';
-
- html += '<div style="margin-bottom:1rem;">';
- html += '<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;">';
- html += '<input type="checkbox" id="restore-dry" checked> Dry run (preview only)';
- html += '</label>';
- html += '</div>';
-
- html += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';
- html += '</div>';
-
- html += '<div id="restore-output" style="display:none;margin-top:1rem;">';
- html += '<h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3>';
- html += '<div id="restore-terminal" class="terminal" style="max-height:400px;"></div>';
- html += '</div>';
-
- html += '</div>';
- content.innerHTML = html;
+ const c = document.getElementById('page-content');
+ let h = '<div class="page-enter">';
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';
+ h += '<div class="card" style="max-width:480px;">';
+ h += '<div style="margin-bottom:1rem;"><label class="form-label">Project</label><select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select></div>';
+ h += '<div style="margin-bottom:1rem;"><label class="form-label">Environment</label><select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select></div>';
+ h += '<div style="margin-bottom:1rem;"><label class="form-label">Source</label><select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select></div>';
+ h += '<div style="margin-bottom:1rem;"><label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;"><input type="checkbox" id="restore-dry" checked> Dry run (preview only)</label></div>';
+ h += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';
+ h += '</div>';
+ h += '<div id="restore-output" style="display:none;margin-top:1rem;"><h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3><div id="restore-terminal" class="terminal" style="max-height:400px;"></div></div>';
+ h += '</div>';
+ c.innerHTML = h;
}
async function startRestore() {
@@ -661,36 +646,26 @@
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)' : ''}?`)) return;
- 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 out = document.getElementById('restore-output');
+ const term = document.getElementById('restore-terminal');
+ out.style.display = 'block';
+ term.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');
+ const es = new EventSource(url);
+ es.onmessage = function(e) {
+ const d = JSON.parse(e.data);
+ if (d.done) {
+ es.close();
+ term.textContent += d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
+ toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error');
return;
}
- if (data.line) {
- terminal.textContent += data.line + '\n';
- terminal.scrollTop = terminal.scrollHeight;
- }
+ if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; }
};
-
- evtSource.onerror = function() {
- evtSource.close();
- terminal.textContent += '\n--- Connection lost ---\n';
- toast('Restore connection lost', 'error');
- };
+ es.onerror = function() { es.close(); term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); };
}
// ---------------------------------------------------------------------------
@@ -698,84 +673,54 @@
// ---------------------------------------------------------------------------
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');
- }
+ const r = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
+ toast(r.message || 'Restarted', 'success');
+ setTimeout(refreshCurrentPage, 3000);
+ } catch (e) { toast('Restart failed: ' + e.message, 'error'); }
}
async function viewLogs(project, env, service) {
- logModalProject = project;
- logModalEnv = env;
- logModalService = service;
-
+ logCtx = { project, env, 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;
+ if (!logCtx.project) 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;
- }
+ const d = await api(`/api/services/logs/${logCtx.project}/${logCtx.env}/${logCtx.service}?lines=200`);
+ const t = document.getElementById('log-modal-content');
+ t.textContent = d.logs || 'No logs available.';
+ t.scrollTop = t.scrollHeight;
+ } catch (e) { document.getElementById('log-modal-content').textContent = 'Error: ' + e.message; }
}
function closeLogModal() {
document.getElementById('log-modal').style.display = 'none';
- logModalProject = null;
- logModalEnv = null;
- logModalService = null;
+ logCtx = { project: null, env: null, service: null };
}
-// ---------------------------------------------------------------------------
-// Backup Actions
-// ---------------------------------------------------------------------------
async function createBackup(project, env) {
if (!confirm(`Create backup for ${project}/${env}?`)) return;
- toast('Creating backup for ' + project + '/' + env + '...', 'info');
+ toast('Creating backup...', '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');
- }
+ } catch (e) { toast('Backup failed: ' + e.message, 'error'); }
}
// ---------------------------------------------------------------------------
-// Data Grouping
+// Utilities
// ---------------------------------------------------------------------------
-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;
+function groupBy(arr, key) {
+ const m = {};
+ for (const item of arr) { const k = item[key] || 'other'; (m[k] = m[k] || []).push(item); }
+ return m;
}
// ---------------------------------------------------------------------------
@@ -784,12 +729,8 @@
(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(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(data => {
allServices = data;
document.getElementById('login-overlay').style.display = 'none';
@@ -797,16 +738,7 @@
showPage('dashboard');
startAutoRefresh();
})
- .catch(() => {
- localStorage.removeItem('ops_token');
- document.getElementById('login-overlay').style.display = 'flex';
- });
+ .catch(() => { localStorage.removeItem('ops_token'); });
}
-
- // ESC to close modals
- document.addEventListener('keydown', e => {
- if (e.key === 'Escape') {
- closeLogModal();
- }
- });
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLogModal(); });
})();
--
Gitblit v1.3.1