| '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 = `<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);
|
| }
|
|
|
| // ---------------------------------------------------------------------------
|
| // 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 = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
|
|
|
| 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 = '<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>';
|
| } 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>';
|
| }
|
| } else {
|
| const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' };
|
| html = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
|
| }
|
|
|
| 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 = '<div class="page-enter" style="padding:0;">';
|
|
|
| // 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>';
|
|
|
| // 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)}')">
|
| <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>
|
| </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('')}
|
| </div>
|
| <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
|
| </div>`;
|
| }
|
| html += '</div></div>';
|
| 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 = '<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)}')">
|
| <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>
|
| </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('')}
|
| </div>
|
| <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
|
| </div>`;
|
| }
|
|
|
| html += '</div></div>';
|
| content.innerHTML = html;
|
| }
|
|
|
| function renderServices() {
|
| const content = document.getElementById('page-content');
|
| const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
|
|
|
| let html = '<div class="page-enter" style="padding:0;">';
|
| html += '<div class="service-grid">';
|
|
|
| for (const svc of services) {
|
| html += serviceCard(svc);
|
| }
|
|
|
| html += '</div></div>';
|
| 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 `<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>
|
| </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')}
|
| </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>
|
| </div>`;
|
| }
|
|
|
| function statCard(label, value, color) {
|
| return `<div class="card" style="text-align:center;">
|
| <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;
|
| }
|
|
|
| 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;
|
| }
|
|
|
| // ---------------------------------------------------------------------------
|
| // 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 = '<div class="page-enter" style="padding:0;">';
|
|
|
| // 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>`;
|
| }
|
| }
|
| html += '</div></div>';
|
|
|
| // Local backups
|
| html += '<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>';
|
| } 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>';
|
| 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>`;
|
| }
|
| html += '</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>';
|
| if (offsite.length === 0) {
|
| html += '<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>';
|
| 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>`;
|
| }
|
| html += '</tbody></table></div>';
|
| }
|
|
|
| html += '</div>';
|
| content.innerHTML = html;
|
| } catch (e) {
|
| content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + escapeHtml(e.message) + '</div>';
|
| }
|
| }
|
|
|
| // ---------------------------------------------------------------------------
|
| // 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 = '<div class="page-enter" style="padding:0;">';
|
|
|
| // 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>';
|
|
|
| // 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) {
|
| const pct = parseInt(fs.use_percent) || 0;
|
| html += `<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>
|
| </div>
|
| <div class="progress-bar-track">
|
| <div class="progress-bar-fill ${diskColorClass(fs.use_percent)}" style="width:${pct}%;"></div>
|
| </div>
|
| </div>`;
|
| }
|
| html += '</div>';
|
| } else {
|
| html += '<div class="card" style="color:#6b7280;">No disk data available.</div>';
|
| }
|
|
|
| // Health checks
|
| html += '<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();
|
| 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>
|
| </div>`;
|
| }
|
| html += '</div>';
|
| } else {
|
| html += '<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>';
|
| 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>';
|
| 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>`;
|
| }
|
| html += '</tbody></table></div>';
|
| } else {
|
| html += '<div class="card" style="color:#6b7280;">No timers found.</div>';
|
| }
|
|
|
| html += '</div>';
|
| content.innerHTML = html;
|
| } catch (e) {
|
| content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + escapeHtml(e.message) + '</div>';
|
| }
|
| }
|
|
|
| // ---------------------------------------------------------------------------
|
| // Restore Page
|
| // ---------------------------------------------------------------------------
|
| 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;
|
| }
|
|
|
| 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();
|
| }
|
| });
|
| })();
|