From 485476a297c111e37fec9913535a63a2383ca06e Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 21 Feb 2026 16:32:53 +0100
Subject: [PATCH] feat: Rewrite dashboard with sidebar nav, drill-down, and registry-based container resolution

---
 static/js/app.js | 1515 +++++++++++++++++++++++++++++----------------------------
 1 files changed, 777 insertions(+), 738 deletions(-)

diff --git a/static/js/app.js b/static/js/app.js
index 256b139..41fd842 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,15 +1,30 @@
-/* ============================================================
-   OPS Dashboard — Alpine.js Application Logic
-   ============================================================ */
-
 'use strict';
 
-// ----------------------------------------------------------------
-// Helpers
-// ----------------------------------------------------------------
+// ============================================================
+// 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 '—';
+  if (bytes == null || bytes === '') return '\u2014';
   const n = Number(bytes);
   if (isNaN(n) || n === 0) return '0 B';
   const k = 1024;
@@ -19,755 +34,779 @@
 }
 
 function timeAgo(dateInput) {
-  if (!dateInput) return '—';
+  if (!dateInput) return '\u2014';
   const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
-  if (isNaN(date)) return '—';
+  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';
+  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 ageHours(dateInput) {
-  if (!dateInput) return 0;
-  const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
-  if (isNaN(date)) return 0;
-  return (Date.now() - date.getTime()) / 3600000;
-}
-
-function ageBadgeClass(dateInput) {
-  const h = ageHours(dateInput);
-  if (h >= 48) return 'badge-red';
-  if (h >= 24) return 'badge-yellow';
-  return 'badge-green';
-}
-
-function statusBadgeClass(status, health) {
-  const s = (status || '').toLowerCase();
-  const h = (health || '').toLowerCase();
-  if (s === 'running' && (h === 'healthy' || h === ''))  return 'badge-green';
-  if (s === 'running' && h === 'unhealthy')              return 'badge-red';
-  if (s === 'running' && h === 'starting')               return 'badge-yellow';
-  if (s === 'restarting' || h === 'starting')            return 'badge-yellow';
-  if (s === 'exited' || s === 'dead' || s === 'removed') return 'badge-red';
-  if (s === 'paused')                                    return 'badge-yellow';
-  return 'badge-gray';
+function escapeHtml(str) {
+  const div = document.createElement('div');
+  div.textContent = str;
+  return div.innerHTML;
 }
 
 function statusDotClass(status, health) {
-  const cls = statusBadgeClass(status, health);
-  return cls.replace('badge-', 'status-dot-');
+  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 diskBarClass(pct) {
-  if (pct >= 90) return 'disk-danger';
-  if (pct >= 75) return 'disk-warn';
+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 Store
-// ----------------------------------------------------------------
-
-function authStore() {
-  return {
-    token: localStorage.getItem('ops_token') || '',
-    loginInput: '',
-    loginError: '',
-    loading: false,
-
-    get isAuthenticated() {
-      return !!this.token;
-    },
-
-    async login() {
-      this.loginError = '';
-      if (!this.loginInput.trim()) {
-        this.loginError = 'Please enter your access token.';
-        return;
-      }
-      this.loading = true;
-      try {
-        const res = await fetch('/api/status/', {
-          headers: { 'Authorization': 'Bearer ' + this.loginInput.trim() }
-        });
-        if (res.ok || res.status === 200) {
-          this.token = this.loginInput.trim();
-          localStorage.setItem('ops_token', this.token);
-          this.loginInput = '';
-          // Trigger page load
-          this.$dispatch('authenticated');
-        } else if (res.status === 401) {
-          this.loginError = 'Invalid token. Please try again.';
-        } else {
-          this.loginError = 'Server error (' + res.status + '). Please try again.';
-        }
-      } catch {
-        this.loginError = 'Could not reach the server.';
-      } finally {
-        this.loading = false;
-      }
-    },
-
-    logout() {
-      this.token = '';
-      localStorage.removeItem('ops_token');
-    }
-  };
+// ---------------------------------------------------------------------------
+// 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
-// ----------------------------------------------------------------
-
-function api(path, options = {}) {
-  const token = localStorage.getItem('ops_token') || '';
-  const headers = Object.assign({ 'Authorization': 'Bearer ' + token }, options.headers || {});
-  if (options.json) {
-    headers['Content-Type'] = 'application/json';
-    options.body = JSON.stringify(options.json);
-    delete options.json;
+// ---------------------------------------------------------------------------
+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');
   }
-  return fetch(path, Object.assign({}, options, { headers })).then(res => {
-    if (res.status === 401) {
-      localStorage.removeItem('ops_token');
-      window.dispatchEvent(new CustomEvent('unauthorized'));
-      throw new Error('Unauthorized');
-    }
-    return res;
+  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()">&times;</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();
 }
 
-// ----------------------------------------------------------------
-// Toast Store
-// ----------------------------------------------------------------
+function renderPage() {
+  const content = document.getElementById('page-content');
+  content.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
 
-function toastStore() {
-  return {
-    toasts: [],
-    _counter: 0,
-
-    add(msg, type = 'info', duration = 4000) {
-      const id = ++this._counter;
-      this.toasts.push({ id, msg, type });
-      if (duration > 0) {
-        setTimeout(() => this.remove(id), duration);
-      }
-      return id;
-    },
-
-    remove(id) {
-      const idx = this.toasts.findIndex(t => t.id === id);
-      if (idx !== -1) this.toasts.splice(idx, 1);
-    },
-
-    success(msg) { return this.add(msg, 'toast-success'); },
-    error(msg)   { return this.add(msg, 'toast-error', 6000); },
-    warn(msg)    { return this.add(msg, 'toast-warning'); },
-    info(msg)    { return this.add(msg, 'toast-info'); },
-
-    iconFor(type) {
-      const icons = {
-        'toast-success': '✓',
-        'toast-error':   '✕',
-        'toast-warning': '⚠',
-        'toast-info':    'ℹ'
-      };
-      return icons[type] || 'ℹ';
-    }
-  };
-}
-
-// ----------------------------------------------------------------
-// App Root Store
-// ----------------------------------------------------------------
-
-function appStore() {
-  return {
-    page: 'dashboard',
-    sidebarOpen: false,
-    toast: null,
-
-    init() {
-      this.toast = Alpine.store('toast');
-      window.addEventListener('unauthorized', () => {
-        Alpine.store('auth').logout();
-      });
-      window.addEventListener('authenticated', () => {
-        this.loadPage('dashboard');
-      });
-    },
-
-    navigate(page) {
-      this.page = page;
-      this.sidebarOpen = false;
-      this.loadPage(page);
-    },
-
-    loadPage(page) {
-      const storeMap = {
-        dashboard: 'dashboard',
-        backups:   'backups',
-        restore:   'restore',
-        services:  'services',
-        system:    'system'
-      };
-      const storeName = storeMap[page];
-      if (storeName && Alpine.store(storeName) && Alpine.store(storeName).load) {
-        Alpine.store(storeName).load();
-      }
-    }
-  };
-}
-
-// ----------------------------------------------------------------
-// Dashboard Store
-// ----------------------------------------------------------------
-
-function dashboardStore() {
-  return {
-    projects: [],
-    loading: false,
-    error: null,
-    lastRefresh: null,
-    refreshInterval: null,
-    autoRefreshEnabled: true,
-    refreshing: false,
-
-    load() {
-      this.fetch();
-      this.startAutoRefresh();
-    },
-
-    async fetch() {
-      if (this.loading) return;
-      this.loading = true;
-      this.error = null;
-      try {
-        const res = await api('/api/status/');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.projects = this.groupByProject(data);
-        this.lastRefresh = new Date();
-      } catch (e) {
-        if (e.message !== 'Unauthorized') {
-          this.error = e.message || 'Failed to load status';
-        }
-      } finally {
-        this.loading = false;
-        this.refreshing = false;
-      }
-    },
-
-    groupByProject(data) {
-      // data may be array of containers or object keyed by project
-      let containers = Array.isArray(data) ? data : Object.values(data).flat();
-      const map = {};
-      for (const c of containers) {
-        const proj = c.project || 'Other';
-        if (!map[proj]) map[proj] = [];
-        map[proj].push(c);
-      }
-      return Object.entries(map).map(([name, services]) => ({ name, services }))
-        .sort((a, b) => a.name.localeCompare(b.name));
-    },
-
-    manualRefresh() {
-      this.refreshing = true;
-      this.fetch();
-    },
-
-    startAutoRefresh() {
-      this.stopAutoRefresh();
-      if (this.autoRefreshEnabled) {
-        this.refreshInterval = setInterval(() => this.fetch(), 30000);
-      }
-    },
-
-    stopAutoRefresh() {
-      if (this.refreshInterval) {
-        clearInterval(this.refreshInterval);
-        this.refreshInterval = null;
-      }
-    },
-
-    toggleAutoRefresh() {
-      this.autoRefreshEnabled = !this.autoRefreshEnabled;
-      if (this.autoRefreshEnabled) {
-        this.startAutoRefresh();
-      } else {
-        this.stopAutoRefresh();
-      }
-    },
-
-    destroy() {
-      this.stopAutoRefresh();
-    },
-
-    badgeClass: statusBadgeClass,
-    dotClass: statusDotClass,
-    timeAgo
-  };
-}
-
-// ----------------------------------------------------------------
-// Backups Store
-// ----------------------------------------------------------------
-
-function backupsStore() {
-  return {
-    local: [],
-    offsite: [],
-    loading: false,
-    loadingOffsite: false,
-    error: null,
-    ops: {},  // track per-row operation state: key -> { loading, done, error }
-
-    load() {
-      this.fetchLocal();
-      this.fetchOffsite();
-    },
-
-    async fetchLocal() {
-      this.loading = true;
-      this.error = null;
-      try {
-        const res = await api('/api/backups/');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.local = Array.isArray(data) ? data : Object.values(data).flat();
-      } catch (e) {
-        if (e.message !== 'Unauthorized') this.error = e.message;
-      } finally {
-        this.loading = false;
-      }
-    },
-
-    async fetchOffsite() {
-      this.loadingOffsite = true;
-      try {
-        const res = await api('/api/backups/offsite');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.offsite = Array.isArray(data) ? data : Object.values(data).flat();
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error('Offsite: ' + (e.message || 'Failed'));
-      } finally {
-        this.loadingOffsite = false;
-      }
-    },
-
-    opKey(project, env, action) {
-      return `${action}::${project}::${env}`;
-    },
-
-    isRunning(project, env, action) {
-      return !!(this.ops[this.opKey(project, env, action)]?.loading);
-    },
-
-    async backupNow(project, env) {
-      const key = this.opKey(project, env, 'backup');
-      this.ops = { ...this.ops, [key]: { loading: true } };
-      try {
-        const res = await api(`/api/backups/${project}/${env}`, { method: 'POST' });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        Alpine.store('toast').success(`Backup started: ${project}/${env}`);
-        setTimeout(() => this.fetchLocal(), 2000);
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error(`Backup failed: ${e.message}`);
-      } finally {
-        this.ops = { ...this.ops, [key]: { loading: false } };
-      }
-    },
-
-    async uploadOffsite(project, env) {
-      const key = this.opKey(project, env, 'upload');
-      this.ops = { ...this.ops, [key]: { loading: true } };
-      try {
-        const res = await api(`/api/backups/offsite/upload/${project}/${env}`, { method: 'POST' });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        Alpine.store('toast').success(`Upload started: ${project}/${env}`);
-        setTimeout(() => this.fetchOffsite(), 3000);
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error(`Upload failed: ${e.message}`);
-      } finally {
-        this.ops = { ...this.ops, [key]: { loading: false } };
-      }
-    },
-
-    retentionRunning: false,
-
-    async applyRetention() {
-      this.retentionRunning = true;
-      try {
-        const res = await api('/api/backups/offsite/retention', { method: 'POST' });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        Alpine.store('toast').success('Retention policy applied');
-        setTimeout(() => this.fetchOffsite(), 2000);
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error(`Retention failed: ${e.message}`);
-      } finally {
-        this.retentionRunning = false;
-      }
-    },
-
-    ageBadge: ageBadgeClass,
-    timeAgo,
-    formatBytes
-  };
-}
-
-// ----------------------------------------------------------------
-// Restore Store
-// ----------------------------------------------------------------
-
-function restoreStore() {
-  return {
-    source: 'local',
-    project: '',
-    env: '',
-    dryRun: false,
-    confirming: false,
-    running: false,
-    output: [],
-    sseSource: null,
-    projects: [],
-    envs: [],
-    loadingProjects: false,
-    error: null,
-
-    load() {
-      this.loadProjectList();
-    },
-
-    async loadProjectList() {
-      this.loadingProjects = true;
-      try {
-        const endpoint = this.source === 'offsite' ? '/api/backups/offsite' : '/api/backups/';
-        const res = await api(endpoint);
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        const items = Array.isArray(data) ? data : Object.values(data).flat();
-        const projSet = new Set(items.map(i => i.project).filter(Boolean));
-        this.projects = Array.from(projSet).sort();
-        this.project = this.projects[0] || '';
-        this.updateEnvs(items);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') this.error = e.message;
-      } finally {
-        this.loadingProjects = false;
-      }
-    },
-
-    updateEnvs(items) {
-      if (!items) return;
-      const envSet = new Set(
-        items.filter(i => i.project === this.project).map(i => i.env).filter(Boolean)
-      );
-      this.envs = Array.from(envSet).sort();
-      this.env = this.envs[0] || '';
-    },
-
-    onSourceChange() {
-      this.project = '';
-      this.env = '';
-      this.envs = [];
-      this.loadProjectList();
-    },
-
-    onProjectChange() {
-      // Re-fetch envs for this project from the already loaded data
-      this.loadProjectList();
-    },
-
-    confirm() {
-      if (!this.project || !this.env) {
-        Alpine.store('toast').warn('Select project and environment first');
-        return;
-      }
-      this.confirming = true;
-    },
-
-    cancel() {
-      this.confirming = false;
-    },
-
-    async execute() {
-      this.confirming = false;
-      this.running = true;
-      this.output = [];
-
-      const params = new URLSearchParams({
-        source: this.source,
-        dry_run: this.dryRun ? '1' : '0'
-      });
-      const url = `/api/restore/${this.project}/${this.env}?${params}`;
-
-      try {
-        this.sseSource = new EventSource(url + '&token=' + encodeURIComponent(localStorage.getItem('ops_token') || ''));
-        this.sseSource.onmessage = (e) => {
-          try {
-            const msg = JSON.parse(e.data);
-            if (msg.done) {
-              this.sseSource.close();
-              this.sseSource = null;
-              this.running = false;
-              if (msg.success) {
-                Alpine.store('toast').success('Restore completed');
-              } else {
-                Alpine.store('toast').error('Restore finished with errors');
-              }
-              return;
-            }
-            const text = msg.line || e.data;
-            this.output.push({ text, cls: this.classifyLine(text) });
-          } catch {
-            this.output.push({ text: e.data, cls: this.classifyLine(e.data) });
-          }
-          this.$nextTick(() => {
-            const el = document.getElementById('restore-output');
-            if (el) el.scrollTop = el.scrollHeight;
-          });
-        };
-        this.sseSource.onerror = () => {
-          if (this.running) {
-            this.running = false;
-            if (this.sseSource) this.sseSource.close();
-            this.sseSource = null;
-          }
-        };
-      } catch (e) {
-        this.running = false;
-        Alpine.store('toast').error('Restore failed: ' + (e.message || 'Unknown error'));
-      }
-    },
-
-    classifyLine(text) {
-      const t = text.toLowerCase();
-      if (t.includes('error') || t.includes('fail') || t.includes('critical')) return 'line-error';
-      if (t.includes('warn'))  return 'line-warn';
-      if (t.includes('ok') || t.includes('success') || t.includes('done')) return 'line-ok';
-      if (t.startsWith('$') || t.startsWith('#') || t.startsWith('>')) return 'line-cmd';
-      return '';
-    },
-
-    abort() {
-      if (this.sseSource) {
-        this.sseSource.close();
-        this.sseSource = null;
-      }
-      this.running = false;
-      this.output.push({ text: '--- aborted by user ---', cls: 'line-warn' });
-    }
-  };
-}
-
-// ----------------------------------------------------------------
-// Services Store
-// ----------------------------------------------------------------
-
-function servicesStore() {
-  return {
-    projects: [],
-    loading: false,
-    error: null,
-    logModal: { open: false, title: '', lines: [], loading: false },
-    confirmRestart: { open: false, project: '', env: '', service: '', running: false },
-
-    load() {
-      this.fetch();
-    },
-
-    async fetch() {
-      this.loading = true;
-      this.error = null;
-      try {
-        const res = await api('/api/status/');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.projects = this.groupByProject(data);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') this.error = e.message;
-      } finally {
-        this.loading = false;
-      }
-    },
-
-    groupByProject(data) {
-      let containers = Array.isArray(data) ? data : Object.values(data).flat();
-      const map = {};
-      for (const c of containers) {
-        const proj = c.project || 'Other';
-        if (!map[proj]) map[proj] = [];
-        map[proj].push(c);
-      }
-      return Object.entries(map).map(([name, services]) => ({ name, services }))
-        .sort((a, b) => a.name.localeCompare(b.name));
-    },
-
-    async viewLogs(project, env, service) {
-      this.logModal = {
-        open: true,
-        title: `${service} — logs`,
-        lines: [],
-        loading: true
-      };
-      try {
-        const res = await api(`/api/services/logs/${project}/${env}/${service}?lines=150`);
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.logModal.lines = (data.logs || '').split('\n');
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          this.logModal.lines = ['Error: ' + e.message];
-      } finally {
-        this.logModal.loading = false;
-        this.$nextTick(() => {
-          const el = document.getElementById('log-output');
-          if (el) el.scrollTop = el.scrollHeight;
-        });
-      }
-    },
-
-    closeLogs() {
-      this.logModal.open = false;
-    },
-
-    askRestart(project, env, service) {
-      this.confirmRestart = { open: true, project, env, service, running: false };
-    },
-
-    cancelRestart() {
-      this.confirmRestart.open = false;
-    },
-
-    async doRestart() {
-      const { project, env, service } = this.confirmRestart;
-      this.confirmRestart.running = true;
-      try {
-        const res = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        Alpine.store('toast').success(`${service} restarted`);
-        this.confirmRestart.open = false;
-        setTimeout(() => this.fetch(), 2000);
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error(`Restart failed: ${e.message}`);
-      } finally {
-        this.confirmRestart.running = false;
-      }
-    },
-
-    badgeClass: statusBadgeClass,
-    dotClass: statusDotClass
-  };
-}
-
-// ----------------------------------------------------------------
-// System Store
-// ----------------------------------------------------------------
-
-function systemStore() {
-  return {
-    disk: [],
-    health: [],
-    timers: [],
-    info: {},
-    loading: { disk: false, health: false, timers: false, info: false },
-    error: null,
-
-    load() {
-      this.fetchDisk();
-      this.fetchHealth();
-      this.fetchTimers();
-      this.fetchInfo();
-    },
-
-    async fetchDisk() {
-      this.loading.disk = true;
-      try {
-        const res = await api('/api/system//disk');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.disk = Array.isArray(data) ? data : (data.filesystems || []);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') this.error = e.message;
-      } finally {
-        this.loading.disk = false;
-      }
-    },
-
-    async fetchHealth() {
-      this.loading.health = true;
-      try {
-        const res = await api('/api/system//health');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.health = Array.isArray(data) ? data : (data.checks || []);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') {}
-      } finally {
-        this.loading.health = false;
-      }
-    },
-
-    async fetchTimers() {
-      this.loading.timers = true;
-      try {
-        const res = await api('/api/system//timers');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.timers = Array.isArray(data) ? data : (data.timers || []);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') {}
-      } finally {
-        this.loading.timers = false;
-      }
-    },
-
-    async fetchInfo() {
-      this.loading.info = true;
-      try {
-        const res = await api('/api/system//info');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        this.info = await res.json();
-      } catch (e) {
-        if (e.message !== 'Unauthorized') {}
-      } finally {
-        this.loading.info = false;
-      }
-    },
-
-    diskBarClass,
-    formatBytes,
-    timeAgo
-  };
-}
-
-// ----------------------------------------------------------------
-// Alpine initialization
-// ----------------------------------------------------------------
-
-document.addEventListener('alpine:init', () => {
-  Alpine.store('auth',      authStore());
-  Alpine.store('toast',     toastStore());
-  Alpine.store('app',       appStore());
-  Alpine.store('dashboard', dashboardStore());
-  Alpine.store('backups',   backupsStore());
-  Alpine.store('restore',   restoreStore());
-  Alpine.store('services',  servicesStore());
-  Alpine.store('system',    systemStore());
-
-  // Init app store
-  Alpine.store('app').init();
-
-  // Load the dashboard if already authenticated
-  if (Alpine.store('auth').isAuthenticated) {
-    Alpine.store('dashboard').load();
+  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')} &middot; 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();
+    }
+  });
+})();

--
Gitblit v1.3.1