Matthias Nott
2026-02-21 485476a297c111e37fec9913535a63a2383ca06e
feat: Rewrite dashboard with sidebar nav, drill-down, and registry-based container resolution

- Replace broken alert() onclick with proper 3-level drill navigation (projects > envs > services)
- Add left sidebar with Dashboard, Services, Backups, System, Restore pages
- Add breadcrumb navigation and mobile hamburger menu
- Fix services.py container name resolution using ops registry name_prefix mapping
- Remove 9 debug/emergency files from session 0022
- Keep existing CSS (style.css) unchanged
3 files modified
changed files
app/app/routers/services.py patch | view | blame | history
static/index.html patch | view | blame | history
static/js/app.js patch | view | blame | history
app/app/routers/services.py
....@@ -1,5 +1,7 @@
1
+import os
12 from typing import Any
23
4
+import yaml
35 from fastapi import APIRouter, Depends, HTTPException, Query
46
57 from app.auth import verify_token
....@@ -8,14 +10,111 @@
810 router = APIRouter()
911
1012 _DOCKER = "docker"
13
+_REGISTRY_PATH = os.environ.get(
14
+ "REGISTRY_PATH",
15
+ "/opt/infrastructure/servers/hetzner-vps/registry.yaml",
16
+)
17
+
18
+# ---------------------------------------------------------------------------
19
+# Registry-based name prefix lookup (cached)
20
+# ---------------------------------------------------------------------------
21
+_prefix_cache: dict[str, str] | None = None
1122
1223
13
-def _container_name(project: str, env: str, service: str) -> str:
24
+def _load_prefixes() -> dict[str, str]:
25
+ """Load project -> name_prefix mapping from the ops registry."""
26
+ global _prefix_cache
27
+ if _prefix_cache is not None:
28
+ return _prefix_cache
29
+
30
+ try:
31
+ with open(_REGISTRY_PATH) as f:
32
+ data = yaml.safe_load(f)
33
+ _prefix_cache = {}
34
+ for proj_name, cfg in data.get("projects", {}).items():
35
+ _prefix_cache[proj_name] = cfg.get("name_prefix", proj_name)
36
+ return _prefix_cache
37
+ except Exception:
38
+ return {}
39
+
40
+
41
+# ---------------------------------------------------------------------------
42
+# Container name resolution
43
+# ---------------------------------------------------------------------------
44
+
45
+
46
+async def _find_by_prefix(pattern: str) -> str | None:
47
+ """Find first running container whose name starts with `pattern`."""
48
+ result = await run_command(
49
+ [_DOCKER, "ps", "--filter", f"name={pattern}", "--format", "{{.Names}}"],
50
+ timeout=10,
51
+ )
52
+ if not result["success"]:
53
+ return None
54
+ for name in result["output"].strip().splitlines():
55
+ name = name.strip()
56
+ if name and name.startswith(pattern):
57
+ return name
58
+ return None
59
+
60
+
61
+async def _find_exact(name: str) -> str | None:
62
+ """Find a running container with exactly this name."""
63
+ result = await run_command(
64
+ [_DOCKER, "ps", "--filter", f"name={name}", "--format", "{{.Names}}"],
65
+ timeout=10,
66
+ )
67
+ if not result["success"]:
68
+ return None
69
+ for n in result["output"].strip().splitlines():
70
+ if n.strip() == name:
71
+ return name
72
+ return None
73
+
74
+
75
+async def _resolve_container(project: str, env: str, service: str) -> str:
1476 """
15
- Derive the Docker container name from project, env, and service.
16
- Docker Compose v2 default: {project}-{env}-{service}-1
77
+ Resolve the actual Docker container name from project/env/service.
78
+
79
+ Uses the ops registry name_prefix mapping and tries patterns in order:
80
+ 1. {env}-{prefix}-{service} (mdf, seriousletter: dev-mdf-mysql-UUID)
81
+ 2. {prefix}-{service} (ringsaday: ringsaday-website-UUID, coolify: coolify-db)
82
+ 3. {prefix}-{env} (ringsaday: ringsaday-dev-UUID)
83
+ 4. exact {prefix} (coolify infra: coolify)
1784 """
18
- return f"{project}-{env}-{service}-1"
85
+ prefixes = _load_prefixes()
86
+ prefix = prefixes.get(project, project)
87
+
88
+ # Pattern 1: {env}-{prefix}-{service}
89
+ hit = await _find_by_prefix(f"{env}-{prefix}-{service}")
90
+ if hit:
91
+ return hit
92
+
93
+ # Pattern 2: {prefix}-{service}
94
+ hit = await _find_by_prefix(f"{prefix}-{service}")
95
+ if hit:
96
+ return hit
97
+
98
+ # Pattern 3: {prefix}-{env}
99
+ hit = await _find_by_prefix(f"{prefix}-{env}")
100
+ if hit:
101
+ return hit
102
+
103
+ # Pattern 4: exact match when service == prefix (e.g., coolify)
104
+ if service == prefix:
105
+ hit = await _find_exact(prefix)
106
+ if hit:
107
+ return hit
108
+
109
+ raise HTTPException(
110
+ status_code=404,
111
+ detail=f"Container not found for {project}/{env}/{service}",
112
+ )
113
+
114
+
115
+# ---------------------------------------------------------------------------
116
+# Endpoints
117
+# ---------------------------------------------------------------------------
19118
20119
21120 @router.get("/logs/{project}/{env}/{service}", summary="Get container logs")
....@@ -23,20 +122,19 @@
23122 project: str,
24123 env: str,
25124 service: str,
26
- lines: int = Query(default=100, ge=1, le=10000, description="Number of log lines to return"),
125
+ lines: int = Query(
126
+ default=100, ge=1, le=10000, description="Number of log lines to return"
127
+ ),
27128 _: str = Depends(verify_token),
28129 ) -> dict[str, Any]:
29
- """
30
- Fetch the last N lines of logs from a container.
31
- Uses `docker logs --tail {lines} {container}`.
32
- """
33
- container = _container_name(project, env, service)
130
+ """Fetch the last N lines of logs from a container."""
131
+ container = await _resolve_container(project, env, service)
34132 result = await run_command(
35133 [_DOCKER, "logs", "--tail", str(lines), container],
36134 timeout=30,
37135 )
38136
39
- # docker logs writes to stderr by default; treat combined output as logs
137
+ # docker logs writes to stderr by default; combine both streams
40138 combined = result["output"] + result["error"]
41139
42140 if not result["success"] and not combined.strip():
....@@ -59,10 +157,8 @@
59157 service: str,
60158 _: str = Depends(verify_token),
61159 ) -> dict[str, Any]:
62
- """
63
- Restart a Docker container via `docker restart {container}`.
64
- """
65
- container = _container_name(project, env, service)
160
+ """Restart a Docker container."""
161
+ container = await _resolve_container(project, env, service)
66162 result = await run_command(
67163 [_DOCKER, "restart", container],
68164 timeout=60,
static/index.html
....@@ -1,843 +1,131 @@
11 <!DOCTYPE html>
2
-<html lang="en" class="h-full">
2
+<html lang="en">
33 <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
66 <title>OPS Dashboard</title>
7
-
8
- <!-- Tailwind CSS Play CDN -->
97 <script src="https://cdn.tailwindcss.com"></script>
10
- <script>
11
- tailwind.config = {
12
- theme: {
13
- extend: {
14
- colors: {
15
- gray: {
16
- 950: '#0a0e1a'
17
- }
18
- }
19
- }
20
- }
21
- };
22
- </script>
23
-
24
- <!-- Custom styles -->
25
- <link rel="stylesheet" href="/static/css/style.css" />
26
-
27
- <!-- Alpine.js v3 -->
28
- <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
29
-
30
- <!-- App logic -->
31
- <script src="/static/js/app.js"></script>
8
+ <link rel="stylesheet" href="/static/css/style.css">
9
+ <style>
10
+ body { background: #0f172a; color: #e2e8f0; margin: 0; }
11
+ #app { display: flex; min-height: 100vh; }
12
+ #sidebar { width: 240px; background: #111827; border-right: 1px solid #1f2937; display: flex; flex-direction: column; flex-shrink: 0; }
13
+ #main { flex: 1; display: flex; flex-direction: column; overflow-x: hidden; }
14
+ #topbar { background: #111827; border-bottom: 1px solid #1f2937; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem; }
15
+ #page-content { flex: 1; padding: 1.5rem; overflow-y: auto; }
16
+ .breadcrumb { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #9ca3af; }
17
+ .breadcrumb a { color: #60a5fa; cursor: pointer; text-decoration: none; }
18
+ .breadcrumb a:hover { text-decoration: underline; }
19
+ .breadcrumb .sep { color: #4b5563; }
20
+ .breadcrumb .current { color: #e2e8f0; font-weight: 500; }
21
+ .hamburger { display: none; background: none; border: none; color: #9ca3af; font-size: 1.5rem; cursor: pointer; padding: 0.25rem; }
22
+ .sidebar-logo { padding: 1.25rem 1rem; font-size: 1.125rem; font-weight: 700; color: #f3f4f6; border-bottom: 1px solid #1f2937; display: flex; align-items: center; gap: 0.5rem; }
23
+ .sidebar-nav { padding: 0.75rem 0.5rem; flex: 1; }
24
+ .sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid #1f2937; font-size: 0.75rem; color: #6b7280; }
25
+ .project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
26
+ .env-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
27
+ .service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
28
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
29
+ .card-clickable { cursor: pointer; transition: border-color 0.2s, transform 0.15s; }
30
+ .card-clickable:hover { border-color: #60a5fa; transform: translateY(-1px); }
31
+ .mobile-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; }
32
+ @media (max-width: 768px) {
33
+ #sidebar { position: fixed; left: -240px; top: 0; bottom: 0; z-index: 50; transition: left 0.2s; }
34
+ #sidebar.open { left: 0; }
35
+ .mobile-overlay.open { display: block; }
36
+ .hamburger { display: block; }
37
+ .project-grid, .env-grid, .service-grid { grid-template-columns: 1fr; }
38
+ }
39
+ </style>
3240 </head>
41
+<body>
3342
34
-<body class="h-full bg-gray-900 text-gray-100 antialiased">
35
-
36
-<!-- ============================================================
37
- Toast Notifications
38
- ============================================================ -->
39
-<div class="toast-container" x-data x-show="$store.toast.toasts.length > 0">
40
- <template x-for="t in $store.toast.toasts" :key="t.id">
41
- <div class="toast" :class="t.type">
42
- <span class="text-base leading-none" x-text="$store.toast.iconFor(t.type)"></span>
43
- <span x-text="t.msg" class="flex-1"></span>
44
- <button class="toast-dismiss" @click="$store.toast.remove(t.id)">&#x2715;</button>
45
- </div>
46
- </template>
47
-</div>
48
-
49
-<!-- ============================================================
50
- Login Screen
51
- ============================================================ -->
52
-<div x-data x-show="!$store.auth.isAuthenticated" class="flex h-full items-center justify-center">
53
- <div class="w-full max-w-sm px-4">
54
- <div class="card text-center">
55
- <!-- Server icon -->
56
- <div class="flex justify-center mb-4">
57
- <div class="bg-blue-600 rounded-xl p-3">
58
- <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
59
- <path stroke-linecap="round" stroke-linejoin="round"
60
- d="M21.75 17.25v.75A2.25 2.25 0 0119.5 20.25h-15a2.25 2.25 0 01-2.25-2.25v-.75m19.5 0A2.25 2.25 0 0019.5 15h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v-3A2.25 2.25 0 0019.5 9.75h-15A2.25 2.25 0 002.25 12v3M12 12.75h.008v.008H12v-.008zM12 6.75h.008v.008H12V6.75z"/>
61
- </svg>
62
- </div>
63
- </div>
64
-
65
- <h1 class="text-2xl font-bold text-white mb-1">OPS Dashboard</h1>
66
- <p class="text-sm text-gray-500 mb-6">Enter your access token to continue</p>
67
-
68
- <form @submit.prevent="$store.auth.login()" class="space-y-4">
69
- <div>
70
- <input
71
- type="password"
72
- class="form-input text-center tracking-widest"
73
- placeholder="••••••••••••••••"
74
- x-model="$store.auth.loginInput"
75
- autofocus
76
- />
77
- </div>
78
-
79
- <div x-show="$store.auth.loginError" class="text-red-400 text-sm" x-text="$store.auth.loginError"></div>
80
-
81
- <button type="submit" class="btn btn-primary w-full" :disabled="$store.auth.loading">
82
- <span x-show="$store.auth.loading" class="spinner"></span>
83
- <span x-text="$store.auth.loading ? 'Verifying…' : 'Sign In'"></span>
84
- </button>
85
- </form>
86
- </div>
43
+<!-- Login Overlay -->
44
+<div id="login-overlay" style="position:fixed;inset:0;background:#0f172a;z-index:100;display:flex;align-items:center;justify-content:center;">
45
+ <div class="card" style="width:100%;max-width:380px;text-align:center;">
46
+ <div style="font-size:1.5rem;font-weight:700;margin-bottom:1.5rem;color:#f3f4f6;">OPS Dashboard</div>
47
+ <input type="password" id="login-token" placeholder="Enter access token" class="form-input" style="margin-bottom:1rem;"
48
+ onkeydown="if(event.key==='Enter')doLogin()">
49
+ <button onclick="doLogin()" class="btn btn-primary" style="width:100%;">Login</button>
50
+ <div id="login-error" style="color:#f87171;font-size:0.875rem;margin-top:0.75rem;display:none;"></div>
8751 </div>
8852 </div>
8953
90
-<!-- ============================================================
91
- Main App Shell
92
- ============================================================ -->
93
-<div x-data x-show="$store.auth.isAuthenticated" class="flex h-full">
54
+<!-- App Shell -->
55
+<div id="app" style="display:none;">
56
+ <!-- Mobile overlay -->
57
+ <div id="mobile-overlay" class="mobile-overlay" onclick="toggleSidebar()"></div>
9458
95
- <!-- Mobile sidebar overlay -->
96
- <div
97
- x-show="$store.app.sidebarOpen"
98
- class="sidebar-mobile-overlay md:hidden"
99
- @click="$store.app.sidebarOpen = false"
100
- ></div>
101
-
102
- <!-- ---- Sidebar ---- -->
103
- <aside
104
- class="fixed inset-y-0 left-0 z-50 flex flex-col w-60 bg-gray-950 border-r border-gray-800 transform transition-transform duration-200 md:relative md:translate-x-0"
105
- :class="$store.app.sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
106
- >
107
- <!-- App name -->
108
- <div class="flex items-center gap-3 px-4 py-5 border-b border-gray-800">
109
- <div class="bg-blue-600 rounded-lg p-1.5 flex-shrink-0">
110
- <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
111
- <path stroke-linecap="round" stroke-linejoin="round"
112
- d="M21.75 17.25v.75A2.25 2.25 0 0119.5 20.25h-15A2.25 2.25 0 012.25 18v-.75m19.5 0A2.25 2.25 0 0019.5 15h-15A2.25 2.25 0 002.25 17.25m19.5 0v-3A2.25 2.25 0 0019.5 9.75h-15A2.25 2.25 0 002.25 12v3M12 12.75h.008v.008H12v-.008zM12 6.75h.008v.008H12V6.75z"/>
113
- </svg>
114
- </div>
115
- <div>
116
- <div class="font-bold text-white text-base tracking-widest">OPS</div>
117
- <div class="text-xs text-gray-500">tekmidian.com</div>
118
- </div>
59
+ <!-- Sidebar -->
60
+ <aside id="sidebar">
61
+ <div class="sidebar-logo">
62
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1" fill="#60a5fa"/><circle cx="6" cy="18" r="1" fill="#60a5fa"/></svg>
63
+ OPS Dashboard
11964 </div>
120
-
121
- <!-- Nav links -->
122
- <nav class="flex-1 p-3 space-y-1 overflow-y-auto">
123
-
124
- <button class="sidebar-link w-full text-left"
125
- :class="$store.app.page === 'dashboard' ? 'active' : ''"
126
- @click="$store.app.navigate('dashboard')">
127
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
128
- <path stroke-linecap="round" stroke-linejoin="round"
129
- d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/>
130
- </svg>
65
+ <nav class="sidebar-nav" id="sidebar-nav">
66
+ <a class="sidebar-link active" data-page="dashboard" onclick="showPage('dashboard')">
67
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
13168 Dashboard
132
- </button>
133
-
134
- <button class="sidebar-link w-full text-left"
135
- :class="$store.app.page === 'backups' ? 'active' : ''"
136
- @click="$store.app.navigate('backups')">
137
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
138
- <path stroke-linecap="round" stroke-linejoin="round"
139
- d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"/>
140
- </svg>
141
- Backups
142
- </button>
143
-
144
- <button class="sidebar-link w-full text-left"
145
- :class="$store.app.page === 'restore' ? 'active' : ''"
146
- @click="$store.app.navigate('restore')">
147
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
148
- <path stroke-linecap="round" stroke-linejoin="round"
149
- d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/>
150
- </svg>
151
- Restore
152
- </button>
153
-
154
- <button class="sidebar-link w-full text-left"
155
- :class="$store.app.page === 'services' ? 'active' : ''"
156
- @click="$store.app.navigate('services')">
157
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
158
- <path stroke-linecap="round" stroke-linejoin="round"
159
- d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"/>
160
- </svg>
69
+ </a>
70
+ <a class="sidebar-link" data-page="services" onclick="showPage('services')">
71
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
16172 Services
162
- </button>
163
-
164
- <button class="sidebar-link w-full text-left"
165
- :class="$store.app.page === 'system' ? 'active' : ''"
166
- @click="$store.app.navigate('system')">
167
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
168
- <path stroke-linecap="round" stroke-linejoin="round"
169
- d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"/>
170
- </svg>
73
+ </a>
74
+ <a class="sidebar-link" data-page="backups" onclick="showPage('backups')">
75
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
76
+ Backups
77
+ </a>
78
+ <a class="sidebar-link" data-page="system" onclick="showPage('system')">
79
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
17180 System
172
- </button>
81
+ </a>
82
+ <a class="sidebar-link" data-page="restore" onclick="showPage('restore')">
83
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
84
+ Restore
85
+ </a>
17386 </nav>
174
-
175
- <!-- Logout -->
176
- <div class="p-3 border-t border-gray-800">
177
- <button class="sidebar-link w-full text-left text-red-400 hover:text-red-300"
178
- @click="$store.auth.logout()">
179
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
180
- <path stroke-linecap="round" stroke-linejoin="round"
181
- d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75"/>
182
- </svg>
183
- Sign Out
184
- </button>
87
+ <div class="sidebar-footer">
88
+ <a onclick="doLogout()" style="color:#9ca3af;cursor:pointer;">Logout</a>
18589 </div>
18690 </aside>
18791
188
- <!-- ---- Main content ---- -->
189
- <div class="flex-1 flex flex-col min-w-0 overflow-hidden">
190
-
191
- <!-- Top header -->
192
- <header class="flex items-center justify-between px-4 py-3 bg-gray-950 border-b border-gray-800 flex-shrink-0">
193
-
194
- <!-- Mobile hamburger -->
195
- <button class="md:hidden p-1 rounded text-gray-400 hover:text-white hover:bg-gray-800"
196
- @click="$store.app.sidebarOpen = !$store.app.sidebarOpen">
197
- <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
198
- <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
199
- </svg>
200
- </button>
201
-
202
- <!-- Page title -->
203
- <h2 class="text-sm font-semibold text-gray-300 capitalize"
204
- x-text="$store.app.page"></h2>
205
-
206
- <!-- Right side: refresh indicator -->
207
- <div class="flex items-center gap-3">
208
-
209
- <!-- Auto-refresh for dashboard -->
210
- <div x-show="$store.app.page === 'dashboard'" class="flex items-center gap-2 text-xs text-gray-500">
211
- <button
212
- @click="$store.dashboard.toggleAutoRefresh()"
213
- class="flex items-center gap-1.5 hover:text-gray-300 transition-colors"
214
- :title="$store.dashboard.autoRefreshEnabled ? 'Auto-refresh on (click to pause)' : 'Auto-refresh off (click to resume)'">
215
- <div class="refresh-ring" :class="!$store.dashboard.autoRefreshEnabled ? 'paused' : ''"></div>
216
- <span x-show="$store.dashboard.lastRefresh"
217
- x-text="'Updated ' + $store.dashboard.timeAgo($store.dashboard.lastRefresh)"></span>
218
- </button>
219
-
220
- <button @click="$store.dashboard.manualRefresh()"
221
- class="btn btn-ghost btn-xs"
222
- :disabled="$store.dashboard.loading">
223
- <svg class="w-3 h-3" :class="$store.dashboard.refreshing ? 'animate-spin' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
224
- <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-4.566l3.181 3.182m0-4.991v4.99"/>
225
- </svg>
226
- Refresh
227
- </button>
228
- </div>
229
-
92
+ <!-- Main Content -->
93
+ <div id="main">
94
+ <!-- Top bar -->
95
+ <div id="topbar">
96
+ <button class="hamburger" onclick="toggleSidebar()">&#9776;</button>
97
+ <div id="breadcrumbs" class="breadcrumb" style="flex:1;"></div>
98
+ <div style="display:flex;align-items:center;gap:0.75rem;">
99
+ <div id="refresh-indicator" class="refresh-ring paused" title="Auto-refresh"></div>
100
+ <button class="btn btn-ghost btn-xs" onclick="refreshCurrentPage()" title="Refresh now">Refresh</button>
230101 </div>
231
- </header>
102
+ </div>
232103
233
- <!-- ---- Page content area ---- -->
234
- <main class="flex-1 overflow-y-auto">
235
-
236
- <!-- ======================================================
237
- DASHBOARD PAGE
238
- ====================================================== -->
239
- <div x-show="$store.app.page === 'dashboard'" class="p-4 md:p-6 page-enter">
240
-
241
- <!-- Loading -->
242
- <div x-show="$store.dashboard.loading && !$store.dashboard.projects.length"
243
- class="flex items-center justify-center h-48 text-gray-500">
244
- <div class="text-center">
245
- <div class="spinner spinner-lg mx-auto mb-3"></div>
246
- <div class="text-sm">Loading containers…</div>
247
- </div>
248
- </div>
249
-
250
- <!-- Error -->
251
- <div x-show="$store.dashboard.error" class="card border-red-800 text-red-400 mb-4">
252
- <div class="flex items-center gap-2">
253
- <span class="text-lg">&#9888;</span>
254
- <span x-text="$store.dashboard.error"></span>
255
- </div>
256
- </div>
257
-
258
- <!-- Projects -->
259
- <template x-for="proj in $store.dashboard.projects" :key="proj.name">
260
- <div class="mb-8">
261
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3"
262
- x-text="proj.name"></h3>
263
-
264
- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
265
- <template x-for="svc in proj.services" :key="svc.name || svc.service">
266
- <div class="card flex flex-col gap-2">
267
- <!-- Name + badge -->
268
- <div class="flex items-start justify-between gap-2">
269
- <div class="font-medium text-white text-sm truncate"
270
- x-text="svc.name || svc.service"></div>
271
- <div class="badge flex-shrink-0"
272
- :class="$store.dashboard.badgeClass(svc.status, svc.health)">
273
- <span class="status-dot"
274
- :class="$store.dashboard.dotClass(svc.status, svc.health)"></span>
275
- <span x-text="svc.status || 'unknown'"></span>
276
- </div>
277
- </div>
278
-
279
- <!-- Health + uptime row -->
280
- <div class="flex items-center gap-3 text-xs text-gray-500">
281
- <template x-if="svc.health && svc.health !== ''">
282
- <span class="flex items-center gap-1">
283
- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
284
- <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
285
- </svg>
286
- <span x-text="svc.health"></span>
287
- </span>
288
- </template>
289
- <template x-if="svc.uptime || svc.started">
290
- <span x-text="svc.uptime || ('Up ' + $store.dashboard.timeAgo(svc.started))"></span>
291
- </template>
292
- </div>
293
-
294
- <!-- Domain link -->
295
- <template x-if="svc.domain">
296
- <a :href="'https://' + svc.domain" target="_blank" rel="noopener"
297
- class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 truncate">
298
- <svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
299
- <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
300
- </svg>
301
- <span x-text="svc.domain" class="truncate"></span>
302
- </a>
303
- </template>
304
- </div>
305
- </template>
306
- </div>
307
- </div>
308
- </template>
309
-
310
- <!-- Empty state -->
311
- <div x-show="!$store.dashboard.loading && !$store.dashboard.projects.length && !$store.dashboard.error"
312
- class="flex flex-col items-center justify-center h-48 text-gray-600">
313
- <svg class="w-10 h-10 mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
314
- <path stroke-linecap="round" stroke-linejoin="round"
315
- d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7"/>
316
- </svg>
317
- <p class="text-sm">No containers found</p>
318
- </div>
319
- </div>
320
-
321
- <!-- ======================================================
322
- BACKUPS PAGE
323
- ====================================================== -->
324
- <div x-show="$store.app.page === 'backups'" class="p-4 md:p-6 page-enter space-y-8">
325
-
326
- <!-- ---- Local Backups ---- -->
327
- <section>
328
- <div class="flex items-center justify-between mb-4">
329
- <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-widest">Local Backups</h3>
330
- <button class="btn btn-ghost btn-sm" @click="$store.backups.fetchLocal()"
331
- :disabled="$store.backups.loading">
332
- <span x-show="$store.backups.loading" class="spinner"></span>
333
- Refresh
334
- </button>
335
- </div>
336
-
337
- <div x-show="$store.backups.loading && !$store.backups.local.length"
338
- class="flex items-center gap-2 text-gray-500 text-sm py-6 justify-center">
339
- <div class="spinner"></div> Loading…
340
- </div>
341
-
342
- <div x-show="!$store.backups.loading && !$store.backups.local.length"
343
- class="text-gray-600 text-sm py-6 text-center">No local backups found</div>
344
-
345
- <div x-show="$store.backups.local.length > 0" class="table-wrapper">
346
- <table class="ops-table">
347
- <thead>
348
- <tr>
349
- <th>Project</th>
350
- <th>Env</th>
351
- <th>File</th>
352
- <th>Size</th>
353
- <th>Age</th>
354
- <th>Actions</th>
355
- </tr>
356
- </thead>
357
- <tbody>
358
- <template x-for="(b, idx) in $store.backups.local" :key="idx">
359
- <tr>
360
- <td class="font-medium text-white" x-text="b.project || '—'"></td>
361
- <td>
362
- <span class="badge badge-blue" x-text="b.env || '—'"></span>
363
- </td>
364
- <td class="mono text-xs text-gray-400 max-w-xs truncate" x-text="b.filename || b.file || '—'"></td>
365
- <td class="text-gray-400 text-xs" x-text="$store.backups.formatBytes(b.size)"></td>
366
- <td>
367
- <span class="badge"
368
- :class="$store.backups.ageBadge(b.created_at || b.modified)"
369
- x-text="$store.backups.timeAgo(b.created_at || b.modified)"></span>
370
- </td>
371
- <td>
372
- <div class="flex items-center gap-2">
373
- <button class="btn btn-success btn-xs"
374
- @click="$store.backups.backupNow(b.project, b.env)"
375
- :disabled="$store.backups.isRunning(b.project, b.env, 'backup')">
376
- <span x-show="$store.backups.isRunning(b.project, b.env, 'backup')" class="spinner"></span>
377
- <span x-text="$store.backups.isRunning(b.project, b.env, 'backup') ? 'Running…' : 'Backup Now'"></span>
378
- </button>
379
- <button class="btn btn-ghost btn-xs"
380
- @click="$store.backups.uploadOffsite(b.project, b.env)"
381
- :disabled="$store.backups.isRunning(b.project, b.env, 'upload')">
382
- <span x-show="$store.backups.isRunning(b.project, b.env, 'upload')" class="spinner"></span>
383
- <span x-text="$store.backups.isRunning(b.project, b.env, 'upload') ? 'Uploading…' : 'Upload Offsite'"></span>
384
- </button>
385
- </div>
386
- </td>
387
- </tr>
388
- </template>
389
- </tbody>
390
- </table>
391
- </div>
392
- </section>
393
-
394
- <!-- ---- Offsite Backups ---- -->
395
- <section>
396
- <div class="flex items-center justify-between mb-4">
397
- <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-widest">Offsite Backups</h3>
398
- <div class="flex items-center gap-2">
399
- <button class="btn btn-warning btn-sm"
400
- @click="$store.backups.applyRetention()"
401
- :disabled="$store.backups.retentionRunning">
402
- <span x-show="$store.backups.retentionRunning" class="spinner"></span>
403
- Apply Retention
404
- </button>
405
- <button class="btn btn-ghost btn-sm" @click="$store.backups.fetchOffsite()"
406
- :disabled="$store.backups.loadingOffsite">
407
- <span x-show="$store.backups.loadingOffsite" class="spinner"></span>
408
- Refresh
409
- </button>
410
- </div>
411
- </div>
412
-
413
- <div x-show="$store.backups.loadingOffsite && !$store.backups.offsite.length"
414
- class="flex items-center gap-2 text-gray-500 text-sm py-6 justify-center">
415
- <div class="spinner"></div> Loading…
416
- </div>
417
-
418
- <div x-show="!$store.backups.loadingOffsite && !$store.backups.offsite.length"
419
- class="text-gray-600 text-sm py-6 text-center">No offsite backups found</div>
420
-
421
- <div x-show="$store.backups.offsite.length > 0" class="table-wrapper">
422
- <table class="ops-table">
423
- <thead>
424
- <tr>
425
- <th>Project</th>
426
- <th>Env</th>
427
- <th>File</th>
428
- <th>Size</th>
429
- <th>Age</th>
430
- </tr>
431
- </thead>
432
- <tbody>
433
- <template x-for="(b, idx) in $store.backups.offsite" :key="idx">
434
- <tr>
435
- <td class="font-medium text-white" x-text="b.project || '—'"></td>
436
- <td>
437
- <span class="badge badge-blue" x-text="b.env || '—'"></span>
438
- </td>
439
- <td class="mono text-xs text-gray-400 max-w-xs truncate" x-text="b.filename || b.file || '—'"></td>
440
- <td class="text-gray-400 text-xs" x-text="$store.backups.formatBytes(b.size)"></td>
441
- <td>
442
- <span class="badge"
443
- :class="$store.backups.ageBadge(b.created_at || b.modified)"
444
- x-text="$store.backups.timeAgo(b.created_at || b.modified)"></span>
445
- </td>
446
- </tr>
447
- </template>
448
- </tbody>
449
- </table>
450
- </div>
451
- </section>
452
- </div>
453
-
454
- <!-- ======================================================
455
- RESTORE PAGE
456
- ====================================================== -->
457
- <div x-show="$store.app.page === 'restore'" class="p-4 md:p-6 page-enter">
458
- <div class="max-w-2xl">
459
-
460
- <!-- Config card -->
461
- <div class="card mb-6">
462
- <h3 class="text-sm font-semibold text-gray-300 mb-5">Restore Configuration</h3>
463
-
464
- <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
465
-
466
- <!-- Source -->
467
- <div>
468
- <label class="form-label">Source</label>
469
- <div class="flex gap-4 mt-1">
470
- <label class="flex items-center gap-2 cursor-pointer">
471
- <input type="radio" name="restore-source" value="local"
472
- x-model="$store.restore.source"
473
- @change="$store.restore.onSourceChange()"
474
- class="accent-blue-500 w-4 h-4" />
475
- <span class="text-sm text-gray-300">Local</span>
476
- </label>
477
- <label class="flex items-center gap-2 cursor-pointer">
478
- <input type="radio" name="restore-source" value="offsite"
479
- x-model="$store.restore.source"
480
- @change="$store.restore.onSourceChange()"
481
- class="accent-blue-500 w-4 h-4" />
482
- <span class="text-sm text-gray-300">Offsite</span>
483
- </label>
484
- </div>
485
- </div>
486
-
487
- <!-- Dry run -->
488
- <div class="flex items-center">
489
- <label class="flex items-center gap-2 cursor-pointer mt-5 sm:mt-0">
490
- <input type="checkbox" x-model="$store.restore.dryRun"
491
- class="accent-blue-500 w-4 h-4 rounded" />
492
- <span class="text-sm text-gray-300">Dry run (simulate only)</span>
493
- </label>
494
- </div>
495
-
496
- <!-- Project -->
497
- <div>
498
- <label class="form-label">Project</label>
499
- <div x-show="$store.restore.loadingProjects" class="flex items-center gap-2 text-gray-500 text-sm mt-1">
500
- <div class="spinner"></div> Loading…
501
- </div>
502
- <select class="form-select" x-show="!$store.restore.loadingProjects"
503
- x-model="$store.restore.project"
504
- @change="$store.restore.onProjectChange()">
505
- <option value="" disabled>Select project</option>
506
- <template x-for="p in $store.restore.projects" :key="p">
507
- <option :value="p" x-text="p"></option>
508
- </template>
509
- </select>
510
- </div>
511
-
512
- <!-- Environment -->
513
- <div>
514
- <label class="form-label">Environment</label>
515
- <select class="form-select" x-model="$store.restore.env"
516
- :disabled="!$store.restore.project">
517
- <option value="" disabled>Select environment</option>
518
- <template x-for="e in $store.restore.envs" :key="e">
519
- <option :value="e" x-text="e"></option>
520
- </template>
521
- </select>
522
- </div>
523
- </div>
524
-
525
- <!-- Action bar -->
526
- <div class="flex items-center gap-3 mt-6 pt-5 border-t border-gray-700">
527
- <button class="btn btn-danger"
528
- @click="$store.restore.confirm()"
529
- :disabled="$store.restore.running || !$store.restore.project || !$store.restore.env">
530
- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
531
- <path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/>
532
- </svg>
533
- <span x-text="$store.restore.dryRun ? 'Dry Run' : 'Restore'"></span>
534
- </button>
535
- <button x-show="$store.restore.running" class="btn btn-ghost"
536
- @click="$store.restore.abort()">Abort</button>
537
- <span x-show="$store.restore.running" class="flex items-center gap-2 text-sm text-yellow-400">
538
- <div class="spinner"></div> Running…
539
- </span>
540
- </div>
541
- </div>
542
-
543
- <!-- Output terminal -->
544
- <div x-show="$store.restore.output.length > 0">
545
- <div class="flex items-center justify-between mb-2">
546
- <span class="text-xs text-gray-500 font-semibold uppercase tracking-widest">Output</span>
547
- <button class="btn btn-ghost btn-xs" @click="$store.restore.output = []">Clear</button>
548
- </div>
549
- <div id="restore-output" class="terminal" style="height: 360px;">
550
- <template x-for="(line, i) in $store.restore.output" :key="i">
551
- <div :class="line.cls" x-text="line.text"></div>
552
- </template>
553
- </div>
554
- </div>
555
-
556
- <!-- Error -->
557
- <div x-show="$store.restore.error"
558
- class="mt-4 card border-red-800 text-red-400 text-sm"
559
- x-text="$store.restore.error"></div>
560
- </div>
561
-
562
- <!-- Confirm dialog -->
563
- <div x-show="$store.restore.confirming" class="modal-overlay" @click.self="$store.restore.cancel()">
564
- <div class="modal-box max-w-md">
565
- <div class="modal-header">
566
- <h3 class="font-semibold text-white">Confirm Restore</h3>
567
- </div>
568
- <div class="modal-body text-sm text-gray-300 space-y-3">
569
- <p>You are about to restore:</p>
570
- <div class="bg-gray-900 rounded-lg p-3 space-y-2 text-xs font-mono">
571
- <div><span class="text-gray-500">Project:</span> <span class="text-white" x-text="$store.restore.project"></span></div>
572
- <div><span class="text-gray-500">Environment:</span> <span class="text-white" x-text="$store.restore.env"></span></div>
573
- <div><span class="text-gray-500">Source:</span> <span class="text-white" x-text="$store.restore.source"></span></div>
574
- <div><span class="text-gray-500">Dry Run:</span> <span :class="$store.restore.dryRun ? 'text-yellow-400' : 'text-white'" x-text="$store.restore.dryRun ? 'YES — simulate only' : 'NO — live restore'"></span></div>
575
- </div>
576
- <p x-show="!$store.restore.dryRun" class="text-yellow-400 font-medium">
577
- This will overwrite existing data. This action cannot be undone.
578
- </p>
579
- </div>
580
- <div class="modal-footer">
581
- <button class="btn btn-ghost" @click="$store.restore.cancel()">Cancel</button>
582
- <button class="btn btn-danger" @click="$store.restore.execute()">
583
- <span x-text="$store.restore.dryRun ? 'Run Dry Test' : 'Yes, Restore Now'"></span>
584
- </button>
585
- </div>
586
- </div>
587
- </div>
588
- </div>
589
-
590
- <!-- ======================================================
591
- SERVICES PAGE
592
- ====================================================== -->
593
- <div x-show="$store.app.page === 'services'" class="p-4 md:p-6 page-enter">
594
-
595
- <!-- Loading -->
596
- <div x-show="$store.services.loading && !$store.services.projects.length"
597
- class="flex items-center justify-center h-48 text-gray-500">
598
- <div class="spinner spinner-lg"></div>
599
- </div>
600
-
601
- <!-- Error -->
602
- <div x-show="$store.services.error" class="card border-red-800 text-red-400 text-sm mb-4"
603
- x-text="$store.services.error"></div>
604
-
605
- <!-- Projects -->
606
- <template x-for="proj in $store.services.projects" :key="proj.name">
607
- <div class="mb-8">
608
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3"
609
- x-text="proj.name"></h3>
610
-
611
- <div class="space-y-2">
612
- <template x-for="svc in proj.services" :key="svc.name || svc.service">
613
- <div class="card flex flex-col sm:flex-row sm:items-center gap-3">
614
- <!-- Status + name -->
615
- <div class="flex items-center gap-3 flex-1 min-w-0">
616
- <div class="badge flex-shrink-0"
617
- :class="$store.services.badgeClass(svc.status, svc.health)">
618
- <span class="status-dot"
619
- :class="$store.services.dotClass(svc.status, svc.health)"></span>
620
- <span x-text="svc.status || 'unknown'"></span>
621
- </div>
622
- <div>
623
- <div class="font-medium text-white text-sm" x-text="svc.name || svc.service"></div>
624
- <div class="text-xs text-gray-500" x-text="svc.uptime || ''"></div>
625
- </div>
626
- </div>
627
-
628
- <!-- Actions -->
629
- <div class="flex items-center gap-2 flex-shrink-0">
630
- <template x-if="svc.domain">
631
- <a :href="'https://' + svc.domain" target="_blank" rel="noopener"
632
- class="btn btn-ghost btn-xs">
633
- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
634
- <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
635
- </svg>
636
- Open
637
- </a>
638
- </template>
639
-
640
- <button class="btn btn-ghost btn-xs"
641
- @click="$store.services.viewLogs(svc.project || proj.name, svc.env || 'prod', svc.name || svc.service)">
642
- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
643
- <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5"/>
644
- </svg>
645
- Logs
646
- </button>
647
-
648
- <button class="btn btn-warning btn-xs"
649
- @click="$store.services.askRestart(svc.project || proj.name, svc.env || 'prod', svc.name || svc.service)">
650
- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
651
- <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-4.566l3.181 3.182m0-4.991v4.99"/>
652
- </svg>
653
- Restart
654
- </button>
655
- </div>
656
- </div>
657
- </template>
658
- </div>
659
- </div>
660
- </template>
661
-
662
- <!-- Log Modal -->
663
- <div x-show="$store.services.logModal.open" class="modal-overlay" @click.self="$store.services.closeLogs()">
664
- <div class="modal-box" style="max-width: 780px;">
665
- <div class="modal-header">
666
- <h3 class="font-semibold text-white text-sm" x-text="$store.services.logModal.title"></h3>
667
- <button class="text-gray-400 hover:text-white text-lg leading-none" @click="$store.services.closeLogs()">&#x2715;</button>
668
- </div>
669
- <div class="modal-body p-0">
670
- <div x-show="$store.services.logModal.loading"
671
- class="flex items-center justify-center h-32 text-gray-500">
672
- <div class="spinner spinner-lg"></div>
673
- </div>
674
- <div id="log-output" class="terminal rounded-none rounded-b-xl"
675
- x-show="!$store.services.logModal.loading"
676
- style="height: 480px; border-radius: 0 0 0.875rem 0.875rem; border: none;">
677
- <template x-for="(line, i) in $store.services.logModal.lines" :key="i">
678
- <div x-text="typeof line === 'string' ? line : (line.text || JSON.stringify(line))"></div>
679
- </template>
680
- </div>
681
- </div>
682
- </div>
683
- </div>
684
-
685
- <!-- Restart Confirm Modal -->
686
- <div x-show="$store.services.confirmRestart.open" class="modal-overlay"
687
- @click.self="$store.services.cancelRestart()">
688
- <div class="modal-box max-w-md">
689
- <div class="modal-header">
690
- <h3 class="font-semibold text-white">Confirm Restart</h3>
691
- </div>
692
- <div class="modal-body text-sm text-gray-300">
693
- <p>Restart service <strong class="text-white" x-text="$store.services.confirmRestart.service"></strong>?</p>
694
- <p class="text-gray-500 mt-1 text-xs">This will briefly interrupt the service.</p>
695
- </div>
696
- <div class="modal-footer">
697
- <button class="btn btn-ghost" @click="$store.services.cancelRestart()">Cancel</button>
698
- <button class="btn btn-warning" @click="$store.services.doRestart()"
699
- :disabled="$store.services.confirmRestart.running">
700
- <span x-show="$store.services.confirmRestart.running" class="spinner"></span>
701
- <span x-text="$store.services.confirmRestart.running ? 'Restarting…' : 'Restart'"></span>
702
- </button>
703
- </div>
704
- </div>
705
- </div>
706
- </div>
707
-
708
- <!-- ======================================================
709
- SYSTEM PAGE
710
- ====================================================== -->
711
- <div x-show="$store.app.page === 'system'" class="p-4 md:p-6 page-enter space-y-8">
712
-
713
- <!-- System Info -->
714
- <section>
715
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">System Info</h3>
716
- <div x-show="$store.system.loading.info" class="flex items-center gap-2 text-gray-500 text-sm">
717
- <div class="spinner"></div> Loading…
718
- </div>
719
- <div x-show="!$store.system.loading.info" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
720
- <div class="card text-center">
721
- <div class="text-xs text-gray-500 mb-1">Uptime</div>
722
- <div class="text-white font-medium text-sm" x-text="$store.system.info.uptime || '—'"></div>
723
- </div>
724
- <div class="card text-center">
725
- <div class="text-xs text-gray-500 mb-1">Load (1m)</div>
726
- <div class="text-white font-medium text-sm" x-text="$store.system.info.load_1 ?? $store.system.info.load_avg?.[0] ?? '—'"></div>
727
- </div>
728
- <div class="card text-center">
729
- <div class="text-xs text-gray-500 mb-1">Load (5m)</div>
730
- <div class="text-white font-medium text-sm" x-text="$store.system.info.load_5 ?? $store.system.info.load_avg?.[1] ?? '—'"></div>
731
- </div>
732
- <div class="card text-center">
733
- <div class="text-xs text-gray-500 mb-1">Hostname</div>
734
- <div class="text-white font-medium text-sm truncate" x-text="$store.system.info.hostname || '—'"></div>
735
- </div>
736
- </div>
737
- </section>
738
-
739
- <!-- Disk Usage -->
740
- <section>
741
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Disk Usage</h3>
742
- <div x-show="$store.system.loading.disk" class="flex items-center gap-2 text-gray-500 text-sm">
743
- <div class="spinner"></div> Loading…
744
- </div>
745
- <div x-show="!$store.system.loading.disk && !$store.system.disk.length"
746
- class="text-gray-600 text-sm">No disk data</div>
747
- <div x-show="!$store.system.loading.disk && $store.system.disk.length > 0" class="space-y-3">
748
- <template x-for="(d, i) in $store.system.disk" :key="i">
749
- <div class="card">
750
- <div class="flex items-center justify-between mb-2">
751
- <div>
752
- <span class="text-white font-medium text-sm" x-text="d.mount || d.filesystem || '?'"></span>
753
- <span class="text-gray-500 text-xs ml-2" x-text="d.filesystem || d.device || ''"></span>
754
- </div>
755
- <span class="text-sm font-semibold"
756
- :class="(d.use_pct || d.pct || 0) >= 90 ? 'text-red-400' : (d.use_pct || d.pct || 0) >= 75 ? 'text-yellow-400' : 'text-green-400'"
757
- x-text="(d.use_pct || d.pct || 0) + '%'"></span>
758
- </div>
759
- <div class="progress-bar-track">
760
- <div class="progress-bar-fill"
761
- :class="$store.system.diskBarClass(d.use_pct || d.pct || 0)"
762
- :style="'width: ' + Math.min(d.use_pct || d.pct || 0, 100) + '%'"></div>
763
- </div>
764
- <div class="flex justify-between text-xs text-gray-500 mt-1">
765
- <span x-text="'Used: ' + $store.system.formatBytes(d.used)"></span>
766
- <span x-text="'Total: ' + $store.system.formatBytes(d.total || d.size)"></span>
767
- </div>
768
- </div>
769
- </template>
770
- </div>
771
- </section>
772
-
773
- <!-- Health Checks -->
774
- <section>
775
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Health Checks</h3>
776
- <div x-show="$store.system.loading.health" class="flex items-center gap-2 text-gray-500 text-sm">
777
- <div class="spinner"></div> Loading…
778
- </div>
779
- <div x-show="!$store.system.loading.health && !$store.system.health.length"
780
- class="text-gray-600 text-sm">No health data</div>
781
- <div x-show="!$store.system.loading.health && $store.system.health.length > 0" class="space-y-2">
782
- <template x-for="(h, i) in $store.system.health" :key="i">
783
- <div class="card flex items-center justify-between gap-3">
784
- <div class="flex items-center gap-3">
785
- <div class="w-2 h-2 rounded-full flex-shrink-0"
786
- :class="h.ok || h.status === 'pass' ? 'bg-green-400' : 'bg-red-400'"></div>
787
- <span class="text-sm text-gray-300 font-medium" x-text="h.name || h.check"></span>
788
- </div>
789
- <div class="flex items-center gap-2">
790
- <span class="text-xs text-gray-500" x-text="h.detail || h.message || ''"></span>
791
- <span class="badge"
792
- :class="h.ok || h.status === 'pass' ? 'badge-green' : 'badge-red'"
793
- x-text="h.ok || h.status === 'pass' ? 'PASS' : 'FAIL'"></span>
794
- </div>
795
- </div>
796
- </template>
797
- </div>
798
- </section>
799
-
800
- <!-- Systemd Timers -->
801
- <section>
802
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Systemd Timers</h3>
803
- <div x-show="$store.system.loading.timers" class="flex items-center gap-2 text-gray-500 text-sm">
804
- <div class="spinner"></div> Loading…
805
- </div>
806
- <div x-show="!$store.system.loading.timers && !$store.system.timers.length"
807
- class="text-gray-600 text-sm">No timer data</div>
808
- <div x-show="!$store.system.loading.timers && $store.system.timers.length > 0" class="table-wrapper">
809
- <table class="ops-table">
810
- <thead>
811
- <tr>
812
- <th>Timer</th>
813
- <th>Last Run</th>
814
- <th>Next Run</th>
815
- <th>Status</th>
816
- </tr>
817
- </thead>
818
- <tbody>
819
- <template x-for="(t, i) in $store.system.timers" :key="i">
820
- <tr>
821
- <td class="font-medium text-white" x-text="t.name || t.timer"></td>
822
- <td class="text-gray-400 text-xs mono" x-text="t.last || t.last_trigger || '—'"></td>
823
- <td class="text-gray-400 text-xs mono" x-text="t.next || t.next_elapse || '—'"></td>
824
- <td>
825
- <span class="badge"
826
- :class="t.active || t.status === 'active' ? 'badge-green' : 'badge-gray'"
827
- x-text="t.status || (t.active ? 'active' : 'inactive')"></span>
828
- </td>
829
- </tr>
830
- </template>
831
- </tbody>
832
- </table>
833
- </div>
834
- </section>
835
-
836
- </div>
837
-
838
- </main>
104
+ <!-- Page content -->
105
+ <div id="page-content"></div>
839106 </div>
840107 </div>
841108
109
+<!-- Toast Container -->
110
+<div id="toast-container" class="toast-container"></div>
111
+
112
+<!-- Log Modal -->
113
+<div id="log-modal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeLogModal()">
114
+ <div class="modal-box" style="max-width:800px;">
115
+ <div class="modal-header">
116
+ <span id="log-modal-title" style="font-weight:600;color:#f3f4f6;">Container Logs</span>
117
+ <button onclick="closeLogModal()" style="background:none;border:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;">&times;</button>
118
+ </div>
119
+ <div class="modal-body" style="padding:0;">
120
+ <div id="log-modal-content" class="terminal" style="max-height:60vh;border:none;border-radius:0;"></div>
121
+ </div>
122
+ <div class="modal-footer">
123
+ <button class="btn btn-ghost btn-sm" onclick="closeLogModal()">Close</button>
124
+ <button class="btn btn-primary btn-sm" id="log-refresh-btn" onclick="refreshLogs()">Refresh</button>
125
+ </div>
126
+ </div>
127
+</div>
128
+
129
+<script src="/static/js/app.js"></script>
842130 </body>
843131 </html>
static/js/app.js
....@@ -1,15 +1,30 @@
1
-/* ============================================================
2
- OPS Dashboard — Alpine.js Application Logic
3
- ============================================================ */
4
-
51 'use strict';
62
7
-// ----------------------------------------------------------------
8
-// Helpers
9
-// ----------------------------------------------------------------
3
+// ============================================================
4
+// OPS Dashboard — Vanilla JS Application
5
+// ============================================================
106
7
+// ---------------------------------------------------------------------------
8
+// State
9
+// ---------------------------------------------------------------------------
10
+let allServices = [];
11
+let currentPage = 'dashboard';
12
+let drillLevel = 0; // 0=projects, 1=environments, 2=services
13
+let drillProject = null;
14
+let drillEnv = null;
15
+let refreshTimer = null;
16
+const REFRESH_INTERVAL = 30000;
17
+
18
+// Log modal state
19
+let logModalProject = null;
20
+let logModalEnv = null;
21
+let logModalService = null;
22
+
23
+// ---------------------------------------------------------------------------
24
+// Helpers
25
+// ---------------------------------------------------------------------------
1126 function formatBytes(bytes) {
12
- if (bytes == null || bytes === '') return '—';
27
+ if (bytes == null || bytes === '') return '\u2014';
1328 const n = Number(bytes);
1429 if (isNaN(n) || n === 0) return '0 B';
1530 const k = 1024;
....@@ -19,755 +34,779 @@
1934 }
2035
2136 function timeAgo(dateInput) {
22
- if (!dateInput) return '—';
37
+ if (!dateInput) return '\u2014';
2338 const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
24
- if (isNaN(date)) return '—';
39
+ if (isNaN(date)) return '\u2014';
2540 const secs = Math.floor((Date.now() - date.getTime()) / 1000);
26
- if (secs < 60) return secs + 's ago';
27
- if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
28
- if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
41
+ if (secs < 60) return secs + 's ago';
42
+ if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
43
+ if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
2944 return Math.floor(secs / 86400) + 'd ago';
3045 }
3146
32
-function ageHours(dateInput) {
33
- if (!dateInput) return 0;
34
- const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
35
- if (isNaN(date)) return 0;
36
- return (Date.now() - date.getTime()) / 3600000;
37
-}
38
-
39
-function ageBadgeClass(dateInput) {
40
- const h = ageHours(dateInput);
41
- if (h >= 48) return 'badge-red';
42
- if (h >= 24) return 'badge-yellow';
43
- return 'badge-green';
44
-}
45
-
46
-function statusBadgeClass(status, health) {
47
- const s = (status || '').toLowerCase();
48
- const h = (health || '').toLowerCase();
49
- if (s === 'running' && (h === 'healthy' || h === '')) return 'badge-green';
50
- if (s === 'running' && h === 'unhealthy') return 'badge-red';
51
- if (s === 'running' && h === 'starting') return 'badge-yellow';
52
- if (s === 'restarting' || h === 'starting') return 'badge-yellow';
53
- if (s === 'exited' || s === 'dead' || s === 'removed') return 'badge-red';
54
- if (s === 'paused') return 'badge-yellow';
55
- return 'badge-gray';
47
+function escapeHtml(str) {
48
+ const div = document.createElement('div');
49
+ div.textContent = str;
50
+ return div.innerHTML;
5651 }
5752
5853 function statusDotClass(status, health) {
59
- const cls = statusBadgeClass(status, health);
60
- return cls.replace('badge-', 'status-dot-');
54
+ const s = (status || '').toLowerCase();
55
+ const h = (health || '').toLowerCase();
56
+ if (s === 'up' && (h === 'healthy' || h === '')) return 'status-dot-green';
57
+ if (s === 'up' && h === 'unhealthy') return 'status-dot-red';
58
+ if (s === 'up' && h === 'starting') return 'status-dot-yellow';
59
+ if (s === 'down' || s === 'exited') return 'status-dot-red';
60
+ return 'status-dot-gray';
6161 }
6262
63
-function diskBarClass(pct) {
64
- if (pct >= 90) return 'disk-danger';
65
- if (pct >= 75) return 'disk-warn';
63
+function badgeClass(status, health) {
64
+ const s = (status || '').toLowerCase();
65
+ const h = (health || '').toLowerCase();
66
+ if (s === 'up' && (h === 'healthy' || h === '')) return 'badge-green';
67
+ if (s === 'up' && h === 'unhealthy') return 'badge-red';
68
+ if (s === 'up' && h === 'starting') return 'badge-yellow';
69
+ if (s === 'down' || s === 'exited') return 'badge-red';
70
+ return 'badge-gray';
71
+}
72
+
73
+function diskColorClass(pct) {
74
+ const n = parseInt(pct);
75
+ if (isNaN(n)) return 'disk-ok';
76
+ if (n >= 90) return 'disk-danger';
77
+ if (n >= 75) return 'disk-warn';
6678 return 'disk-ok';
6779 }
6880
69
-// ----------------------------------------------------------------
70
-// Auth Store
71
-// ----------------------------------------------------------------
72
-
73
-function authStore() {
74
- return {
75
- token: localStorage.getItem('ops_token') || '',
76
- loginInput: '',
77
- loginError: '',
78
- loading: false,
79
-
80
- get isAuthenticated() {
81
- return !!this.token;
82
- },
83
-
84
- async login() {
85
- this.loginError = '';
86
- if (!this.loginInput.trim()) {
87
- this.loginError = 'Please enter your access token.';
88
- return;
89
- }
90
- this.loading = true;
91
- try {
92
- const res = await fetch('/api/status/', {
93
- headers: { 'Authorization': 'Bearer ' + this.loginInput.trim() }
94
- });
95
- if (res.ok || res.status === 200) {
96
- this.token = this.loginInput.trim();
97
- localStorage.setItem('ops_token', this.token);
98
- this.loginInput = '';
99
- // Trigger page load
100
- this.$dispatch('authenticated');
101
- } else if (res.status === 401) {
102
- this.loginError = 'Invalid token. Please try again.';
103
- } else {
104
- this.loginError = 'Server error (' + res.status + '). Please try again.';
105
- }
106
- } catch {
107
- this.loginError = 'Could not reach the server.';
108
- } finally {
109
- this.loading = false;
110
- }
111
- },
112
-
113
- logout() {
114
- this.token = '';
115
- localStorage.removeItem('ops_token');
116
- }
117
- };
81
+// ---------------------------------------------------------------------------
82
+// Auth
83
+// ---------------------------------------------------------------------------
84
+function getToken() {
85
+ return localStorage.getItem('ops_token');
11886 }
11987
120
-// ----------------------------------------------------------------
88
+function doLogin() {
89
+ const input = document.getElementById('login-token');
90
+ const errEl = document.getElementById('login-error');
91
+ const token = input.value.trim();
92
+ if (!token) {
93
+ errEl.textContent = 'Please enter a token';
94
+ errEl.style.display = 'block';
95
+ return;
96
+ }
97
+ errEl.style.display = 'none';
98
+
99
+ // Validate token by calling the API
100
+ fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
101
+ .then(r => {
102
+ if (!r.ok) throw new Error('Invalid token');
103
+ return r.json();
104
+ })
105
+ .then(data => {
106
+ localStorage.setItem('ops_token', token);
107
+ allServices = data;
108
+ document.getElementById('login-overlay').style.display = 'none';
109
+ document.getElementById('app').style.display = 'flex';
110
+ showPage('dashboard');
111
+ startAutoRefresh();
112
+ })
113
+ .catch(() => {
114
+ errEl.textContent = 'Invalid token. Try again.';
115
+ errEl.style.display = 'block';
116
+ });
117
+}
118
+
119
+function doLogout() {
120
+ localStorage.removeItem('ops_token');
121
+ stopAutoRefresh();
122
+ document.getElementById('app').style.display = 'none';
123
+ document.getElementById('login-overlay').style.display = 'flex';
124
+ document.getElementById('login-token').value = '';
125
+}
126
+
127
+// ---------------------------------------------------------------------------
121128 // API Helper
122
-// ----------------------------------------------------------------
123
-
124
-function api(path, options = {}) {
125
- const token = localStorage.getItem('ops_token') || '';
126
- const headers = Object.assign({ 'Authorization': 'Bearer ' + token }, options.headers || {});
127
- if (options.json) {
128
- headers['Content-Type'] = 'application/json';
129
- options.body = JSON.stringify(options.json);
130
- delete options.json;
129
+// ---------------------------------------------------------------------------
130
+async function api(path, opts = {}) {
131
+ const token = getToken();
132
+ const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token };
133
+ const resp = await fetch(path, { ...opts, headers });
134
+ if (resp.status === 401) {
135
+ doLogout();
136
+ throw new Error('Session expired');
131137 }
132
- return fetch(path, Object.assign({}, options, { headers })).then(res => {
133
- if (res.status === 401) {
134
- localStorage.removeItem('ops_token');
135
- window.dispatchEvent(new CustomEvent('unauthorized'));
136
- throw new Error('Unauthorized');
137
- }
138
- return res;
138
+ if (!resp.ok) {
139
+ const body = await resp.text();
140
+ throw new Error(body || 'HTTP ' + resp.status);
141
+ }
142
+ const ct = resp.headers.get('content-type') || '';
143
+ if (ct.includes('json')) return resp.json();
144
+ return resp.text();
145
+}
146
+
147
+async function fetchStatus() {
148
+ allServices = await api('/api/status/');
149
+}
150
+
151
+// ---------------------------------------------------------------------------
152
+// Toast Notifications
153
+// ---------------------------------------------------------------------------
154
+function toast(message, type = 'info') {
155
+ const container = document.getElementById('toast-container');
156
+ const el = document.createElement('div');
157
+ el.className = 'toast toast-' + type;
158
+ el.innerHTML = `<span>${escapeHtml(message)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">&times;</span>`;
159
+ container.appendChild(el);
160
+ setTimeout(() => {
161
+ el.classList.add('toast-out');
162
+ setTimeout(() => el.remove(), 200);
163
+ }, 4000);
164
+}
165
+
166
+// ---------------------------------------------------------------------------
167
+// Sidebar & Navigation
168
+// ---------------------------------------------------------------------------
169
+function toggleSidebar() {
170
+ document.getElementById('sidebar').classList.toggle('open');
171
+ document.getElementById('mobile-overlay').classList.toggle('open');
172
+}
173
+
174
+function showPage(page) {
175
+ currentPage = page;
176
+ drillLevel = 0;
177
+ drillProject = null;
178
+ drillEnv = null;
179
+
180
+ // Update sidebar active
181
+ document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => {
182
+ el.classList.toggle('active', el.dataset.page === page);
139183 });
184
+
185
+ // Close mobile sidebar
186
+ document.getElementById('sidebar').classList.remove('open');
187
+ document.getElementById('mobile-overlay').classList.remove('open');
188
+
189
+ renderPage();
140190 }
141191
142
-// ----------------------------------------------------------------
143
-// Toast Store
144
-// ----------------------------------------------------------------
192
+function renderPage() {
193
+ const content = document.getElementById('page-content');
194
+ content.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
145195
146
-function toastStore() {
147
- return {
148
- toasts: [],
149
- _counter: 0,
150
-
151
- add(msg, type = 'info', duration = 4000) {
152
- const id = ++this._counter;
153
- this.toasts.push({ id, msg, type });
154
- if (duration > 0) {
155
- setTimeout(() => this.remove(id), duration);
156
- }
157
- return id;
158
- },
159
-
160
- remove(id) {
161
- const idx = this.toasts.findIndex(t => t.id === id);
162
- if (idx !== -1) this.toasts.splice(idx, 1);
163
- },
164
-
165
- success(msg) { return this.add(msg, 'toast-success'); },
166
- error(msg) { return this.add(msg, 'toast-error', 6000); },
167
- warn(msg) { return this.add(msg, 'toast-warning'); },
168
- info(msg) { return this.add(msg, 'toast-info'); },
169
-
170
- iconFor(type) {
171
- const icons = {
172
- 'toast-success': '✓',
173
- 'toast-error': '✕',
174
- 'toast-warning': '⚠',
175
- 'toast-info': 'ℹ'
176
- };
177
- return icons[type] || 'ℹ';
178
- }
179
- };
180
-}
181
-
182
-// ----------------------------------------------------------------
183
-// App Root Store
184
-// ----------------------------------------------------------------
185
-
186
-function appStore() {
187
- return {
188
- page: 'dashboard',
189
- sidebarOpen: false,
190
- toast: null,
191
-
192
- init() {
193
- this.toast = Alpine.store('toast');
194
- window.addEventListener('unauthorized', () => {
195
- Alpine.store('auth').logout();
196
- });
197
- window.addEventListener('authenticated', () => {
198
- this.loadPage('dashboard');
199
- });
200
- },
201
-
202
- navigate(page) {
203
- this.page = page;
204
- this.sidebarOpen = false;
205
- this.loadPage(page);
206
- },
207
-
208
- loadPage(page) {
209
- const storeMap = {
210
- dashboard: 'dashboard',
211
- backups: 'backups',
212
- restore: 'restore',
213
- services: 'services',
214
- system: 'system'
215
- };
216
- const storeName = storeMap[page];
217
- if (storeName && Alpine.store(storeName) && Alpine.store(storeName).load) {
218
- Alpine.store(storeName).load();
219
- }
220
- }
221
- };
222
-}
223
-
224
-// ----------------------------------------------------------------
225
-// Dashboard Store
226
-// ----------------------------------------------------------------
227
-
228
-function dashboardStore() {
229
- return {
230
- projects: [],
231
- loading: false,
232
- error: null,
233
- lastRefresh: null,
234
- refreshInterval: null,
235
- autoRefreshEnabled: true,
236
- refreshing: false,
237
-
238
- load() {
239
- this.fetch();
240
- this.startAutoRefresh();
241
- },
242
-
243
- async fetch() {
244
- if (this.loading) return;
245
- this.loading = true;
246
- this.error = null;
247
- try {
248
- const res = await api('/api/status/');
249
- if (!res.ok) throw new Error('HTTP ' + res.status);
250
- const data = await res.json();
251
- this.projects = this.groupByProject(data);
252
- this.lastRefresh = new Date();
253
- } catch (e) {
254
- if (e.message !== 'Unauthorized') {
255
- this.error = e.message || 'Failed to load status';
256
- }
257
- } finally {
258
- this.loading = false;
259
- this.refreshing = false;
260
- }
261
- },
262
-
263
- groupByProject(data) {
264
- // data may be array of containers or object keyed by project
265
- let containers = Array.isArray(data) ? data : Object.values(data).flat();
266
- const map = {};
267
- for (const c of containers) {
268
- const proj = c.project || 'Other';
269
- if (!map[proj]) map[proj] = [];
270
- map[proj].push(c);
271
- }
272
- return Object.entries(map).map(([name, services]) => ({ name, services }))
273
- .sort((a, b) => a.name.localeCompare(b.name));
274
- },
275
-
276
- manualRefresh() {
277
- this.refreshing = true;
278
- this.fetch();
279
- },
280
-
281
- startAutoRefresh() {
282
- this.stopAutoRefresh();
283
- if (this.autoRefreshEnabled) {
284
- this.refreshInterval = setInterval(() => this.fetch(), 30000);
285
- }
286
- },
287
-
288
- stopAutoRefresh() {
289
- if (this.refreshInterval) {
290
- clearInterval(this.refreshInterval);
291
- this.refreshInterval = null;
292
- }
293
- },
294
-
295
- toggleAutoRefresh() {
296
- this.autoRefreshEnabled = !this.autoRefreshEnabled;
297
- if (this.autoRefreshEnabled) {
298
- this.startAutoRefresh();
299
- } else {
300
- this.stopAutoRefresh();
301
- }
302
- },
303
-
304
- destroy() {
305
- this.stopAutoRefresh();
306
- },
307
-
308
- badgeClass: statusBadgeClass,
309
- dotClass: statusDotClass,
310
- timeAgo
311
- };
312
-}
313
-
314
-// ----------------------------------------------------------------
315
-// Backups Store
316
-// ----------------------------------------------------------------
317
-
318
-function backupsStore() {
319
- return {
320
- local: [],
321
- offsite: [],
322
- loading: false,
323
- loadingOffsite: false,
324
- error: null,
325
- ops: {}, // track per-row operation state: key -> { loading, done, error }
326
-
327
- load() {
328
- this.fetchLocal();
329
- this.fetchOffsite();
330
- },
331
-
332
- async fetchLocal() {
333
- this.loading = true;
334
- this.error = null;
335
- try {
336
- const res = await api('/api/backups/');
337
- if (!res.ok) throw new Error('HTTP ' + res.status);
338
- const data = await res.json();
339
- this.local = Array.isArray(data) ? data : Object.values(data).flat();
340
- } catch (e) {
341
- if (e.message !== 'Unauthorized') this.error = e.message;
342
- } finally {
343
- this.loading = false;
344
- }
345
- },
346
-
347
- async fetchOffsite() {
348
- this.loadingOffsite = true;
349
- try {
350
- const res = await api('/api/backups/offsite');
351
- if (!res.ok) throw new Error('HTTP ' + res.status);
352
- const data = await res.json();
353
- this.offsite = Array.isArray(data) ? data : Object.values(data).flat();
354
- } catch (e) {
355
- if (e.message !== 'Unauthorized')
356
- Alpine.store('toast').error('Offsite: ' + (e.message || 'Failed'));
357
- } finally {
358
- this.loadingOffsite = false;
359
- }
360
- },
361
-
362
- opKey(project, env, action) {
363
- return `${action}::${project}::${env}`;
364
- },
365
-
366
- isRunning(project, env, action) {
367
- return !!(this.ops[this.opKey(project, env, action)]?.loading);
368
- },
369
-
370
- async backupNow(project, env) {
371
- const key = this.opKey(project, env, 'backup');
372
- this.ops = { ...this.ops, [key]: { loading: true } };
373
- try {
374
- const res = await api(`/api/backups/${project}/${env}`, { method: 'POST' });
375
- if (!res.ok) throw new Error('HTTP ' + res.status);
376
- Alpine.store('toast').success(`Backup started: ${project}/${env}`);
377
- setTimeout(() => this.fetchLocal(), 2000);
378
- } catch (e) {
379
- if (e.message !== 'Unauthorized')
380
- Alpine.store('toast').error(`Backup failed: ${e.message}`);
381
- } finally {
382
- this.ops = { ...this.ops, [key]: { loading: false } };
383
- }
384
- },
385
-
386
- async uploadOffsite(project, env) {
387
- const key = this.opKey(project, env, 'upload');
388
- this.ops = { ...this.ops, [key]: { loading: true } };
389
- try {
390
- const res = await api(`/api/backups/offsite/upload/${project}/${env}`, { method: 'POST' });
391
- if (!res.ok) throw new Error('HTTP ' + res.status);
392
- Alpine.store('toast').success(`Upload started: ${project}/${env}`);
393
- setTimeout(() => this.fetchOffsite(), 3000);
394
- } catch (e) {
395
- if (e.message !== 'Unauthorized')
396
- Alpine.store('toast').error(`Upload failed: ${e.message}`);
397
- } finally {
398
- this.ops = { ...this.ops, [key]: { loading: false } };
399
- }
400
- },
401
-
402
- retentionRunning: false,
403
-
404
- async applyRetention() {
405
- this.retentionRunning = true;
406
- try {
407
- const res = await api('/api/backups/offsite/retention', { method: 'POST' });
408
- if (!res.ok) throw new Error('HTTP ' + res.status);
409
- Alpine.store('toast').success('Retention policy applied');
410
- setTimeout(() => this.fetchOffsite(), 2000);
411
- } catch (e) {
412
- if (e.message !== 'Unauthorized')
413
- Alpine.store('toast').error(`Retention failed: ${e.message}`);
414
- } finally {
415
- this.retentionRunning = false;
416
- }
417
- },
418
-
419
- ageBadge: ageBadgeClass,
420
- timeAgo,
421
- formatBytes
422
- };
423
-}
424
-
425
-// ----------------------------------------------------------------
426
-// Restore Store
427
-// ----------------------------------------------------------------
428
-
429
-function restoreStore() {
430
- return {
431
- source: 'local',
432
- project: '',
433
- env: '',
434
- dryRun: false,
435
- confirming: false,
436
- running: false,
437
- output: [],
438
- sseSource: null,
439
- projects: [],
440
- envs: [],
441
- loadingProjects: false,
442
- error: null,
443
-
444
- load() {
445
- this.loadProjectList();
446
- },
447
-
448
- async loadProjectList() {
449
- this.loadingProjects = true;
450
- try {
451
- const endpoint = this.source === 'offsite' ? '/api/backups/offsite' : '/api/backups/';
452
- const res = await api(endpoint);
453
- if (!res.ok) throw new Error('HTTP ' + res.status);
454
- const data = await res.json();
455
- const items = Array.isArray(data) ? data : Object.values(data).flat();
456
- const projSet = new Set(items.map(i => i.project).filter(Boolean));
457
- this.projects = Array.from(projSet).sort();
458
- this.project = this.projects[0] || '';
459
- this.updateEnvs(items);
460
- } catch (e) {
461
- if (e.message !== 'Unauthorized') this.error = e.message;
462
- } finally {
463
- this.loadingProjects = false;
464
- }
465
- },
466
-
467
- updateEnvs(items) {
468
- if (!items) return;
469
- const envSet = new Set(
470
- items.filter(i => i.project === this.project).map(i => i.env).filter(Boolean)
471
- );
472
- this.envs = Array.from(envSet).sort();
473
- this.env = this.envs[0] || '';
474
- },
475
-
476
- onSourceChange() {
477
- this.project = '';
478
- this.env = '';
479
- this.envs = [];
480
- this.loadProjectList();
481
- },
482
-
483
- onProjectChange() {
484
- // Re-fetch envs for this project from the already loaded data
485
- this.loadProjectList();
486
- },
487
-
488
- confirm() {
489
- if (!this.project || !this.env) {
490
- Alpine.store('toast').warn('Select project and environment first');
491
- return;
492
- }
493
- this.confirming = true;
494
- },
495
-
496
- cancel() {
497
- this.confirming = false;
498
- },
499
-
500
- async execute() {
501
- this.confirming = false;
502
- this.running = true;
503
- this.output = [];
504
-
505
- const params = new URLSearchParams({
506
- source: this.source,
507
- dry_run: this.dryRun ? '1' : '0'
508
- });
509
- const url = `/api/restore/${this.project}/${this.env}?${params}`;
510
-
511
- try {
512
- this.sseSource = new EventSource(url + '&token=' + encodeURIComponent(localStorage.getItem('ops_token') || ''));
513
- this.sseSource.onmessage = (e) => {
514
- try {
515
- const msg = JSON.parse(e.data);
516
- if (msg.done) {
517
- this.sseSource.close();
518
- this.sseSource = null;
519
- this.running = false;
520
- if (msg.success) {
521
- Alpine.store('toast').success('Restore completed');
522
- } else {
523
- Alpine.store('toast').error('Restore finished with errors');
524
- }
525
- return;
526
- }
527
- const text = msg.line || e.data;
528
- this.output.push({ text, cls: this.classifyLine(text) });
529
- } catch {
530
- this.output.push({ text: e.data, cls: this.classifyLine(e.data) });
531
- }
532
- this.$nextTick(() => {
533
- const el = document.getElementById('restore-output');
534
- if (el) el.scrollTop = el.scrollHeight;
535
- });
536
- };
537
- this.sseSource.onerror = () => {
538
- if (this.running) {
539
- this.running = false;
540
- if (this.sseSource) this.sseSource.close();
541
- this.sseSource = null;
542
- }
543
- };
544
- } catch (e) {
545
- this.running = false;
546
- Alpine.store('toast').error('Restore failed: ' + (e.message || 'Unknown error'));
547
- }
548
- },
549
-
550
- classifyLine(text) {
551
- const t = text.toLowerCase();
552
- if (t.includes('error') || t.includes('fail') || t.includes('critical')) return 'line-error';
553
- if (t.includes('warn')) return 'line-warn';
554
- if (t.includes('ok') || t.includes('success') || t.includes('done')) return 'line-ok';
555
- if (t.startsWith('$') || t.startsWith('#') || t.startsWith('>')) return 'line-cmd';
556
- return '';
557
- },
558
-
559
- abort() {
560
- if (this.sseSource) {
561
- this.sseSource.close();
562
- this.sseSource = null;
563
- }
564
- this.running = false;
565
- this.output.push({ text: '--- aborted by user ---', cls: 'line-warn' });
566
- }
567
- };
568
-}
569
-
570
-// ----------------------------------------------------------------
571
-// Services Store
572
-// ----------------------------------------------------------------
573
-
574
-function servicesStore() {
575
- return {
576
- projects: [],
577
- loading: false,
578
- error: null,
579
- logModal: { open: false, title: '', lines: [], loading: false },
580
- confirmRestart: { open: false, project: '', env: '', service: '', running: false },
581
-
582
- load() {
583
- this.fetch();
584
- },
585
-
586
- async fetch() {
587
- this.loading = true;
588
- this.error = null;
589
- try {
590
- const res = await api('/api/status/');
591
- if (!res.ok) throw new Error('HTTP ' + res.status);
592
- const data = await res.json();
593
- this.projects = this.groupByProject(data);
594
- } catch (e) {
595
- if (e.message !== 'Unauthorized') this.error = e.message;
596
- } finally {
597
- this.loading = false;
598
- }
599
- },
600
-
601
- groupByProject(data) {
602
- let containers = Array.isArray(data) ? data : Object.values(data).flat();
603
- const map = {};
604
- for (const c of containers) {
605
- const proj = c.project || 'Other';
606
- if (!map[proj]) map[proj] = [];
607
- map[proj].push(c);
608
- }
609
- return Object.entries(map).map(([name, services]) => ({ name, services }))
610
- .sort((a, b) => a.name.localeCompare(b.name));
611
- },
612
-
613
- async viewLogs(project, env, service) {
614
- this.logModal = {
615
- open: true,
616
- title: `${service} — logs`,
617
- lines: [],
618
- loading: true
619
- };
620
- try {
621
- const res = await api(`/api/services/logs/${project}/${env}/${service}?lines=150`);
622
- if (!res.ok) throw new Error('HTTP ' + res.status);
623
- const data = await res.json();
624
- this.logModal.lines = (data.logs || '').split('\n');
625
- } catch (e) {
626
- if (e.message !== 'Unauthorized')
627
- this.logModal.lines = ['Error: ' + e.message];
628
- } finally {
629
- this.logModal.loading = false;
630
- this.$nextTick(() => {
631
- const el = document.getElementById('log-output');
632
- if (el) el.scrollTop = el.scrollHeight;
633
- });
634
- }
635
- },
636
-
637
- closeLogs() {
638
- this.logModal.open = false;
639
- },
640
-
641
- askRestart(project, env, service) {
642
- this.confirmRestart = { open: true, project, env, service, running: false };
643
- },
644
-
645
- cancelRestart() {
646
- this.confirmRestart.open = false;
647
- },
648
-
649
- async doRestart() {
650
- const { project, env, service } = this.confirmRestart;
651
- this.confirmRestart.running = true;
652
- try {
653
- const res = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
654
- if (!res.ok) throw new Error('HTTP ' + res.status);
655
- Alpine.store('toast').success(`${service} restarted`);
656
- this.confirmRestart.open = false;
657
- setTimeout(() => this.fetch(), 2000);
658
- } catch (e) {
659
- if (e.message !== 'Unauthorized')
660
- Alpine.store('toast').error(`Restart failed: ${e.message}`);
661
- } finally {
662
- this.confirmRestart.running = false;
663
- }
664
- },
665
-
666
- badgeClass: statusBadgeClass,
667
- dotClass: statusDotClass
668
- };
669
-}
670
-
671
-// ----------------------------------------------------------------
672
-// System Store
673
-// ----------------------------------------------------------------
674
-
675
-function systemStore() {
676
- return {
677
- disk: [],
678
- health: [],
679
- timers: [],
680
- info: {},
681
- loading: { disk: false, health: false, timers: false, info: false },
682
- error: null,
683
-
684
- load() {
685
- this.fetchDisk();
686
- this.fetchHealth();
687
- this.fetchTimers();
688
- this.fetchInfo();
689
- },
690
-
691
- async fetchDisk() {
692
- this.loading.disk = true;
693
- try {
694
- const res = await api('/api/system//disk');
695
- if (!res.ok) throw new Error('HTTP ' + res.status);
696
- const data = await res.json();
697
- this.disk = Array.isArray(data) ? data : (data.filesystems || []);
698
- } catch (e) {
699
- if (e.message !== 'Unauthorized') this.error = e.message;
700
- } finally {
701
- this.loading.disk = false;
702
- }
703
- },
704
-
705
- async fetchHealth() {
706
- this.loading.health = true;
707
- try {
708
- const res = await api('/api/system//health');
709
- if (!res.ok) throw new Error('HTTP ' + res.status);
710
- const data = await res.json();
711
- this.health = Array.isArray(data) ? data : (data.checks || []);
712
- } catch (e) {
713
- if (e.message !== 'Unauthorized') {}
714
- } finally {
715
- this.loading.health = false;
716
- }
717
- },
718
-
719
- async fetchTimers() {
720
- this.loading.timers = true;
721
- try {
722
- const res = await api('/api/system//timers');
723
- if (!res.ok) throw new Error('HTTP ' + res.status);
724
- const data = await res.json();
725
- this.timers = Array.isArray(data) ? data : (data.timers || []);
726
- } catch (e) {
727
- if (e.message !== 'Unauthorized') {}
728
- } finally {
729
- this.loading.timers = false;
730
- }
731
- },
732
-
733
- async fetchInfo() {
734
- this.loading.info = true;
735
- try {
736
- const res = await api('/api/system//info');
737
- if (!res.ok) throw new Error('HTTP ' + res.status);
738
- this.info = await res.json();
739
- } catch (e) {
740
- if (e.message !== 'Unauthorized') {}
741
- } finally {
742
- this.loading.info = false;
743
- }
744
- },
745
-
746
- diskBarClass,
747
- formatBytes,
748
- timeAgo
749
- };
750
-}
751
-
752
-// ----------------------------------------------------------------
753
-// Alpine initialization
754
-// ----------------------------------------------------------------
755
-
756
-document.addEventListener('alpine:init', () => {
757
- Alpine.store('auth', authStore());
758
- Alpine.store('toast', toastStore());
759
- Alpine.store('app', appStore());
760
- Alpine.store('dashboard', dashboardStore());
761
- Alpine.store('backups', backupsStore());
762
- Alpine.store('restore', restoreStore());
763
- Alpine.store('services', servicesStore());
764
- Alpine.store('system', systemStore());
765
-
766
- // Init app store
767
- Alpine.store('app').init();
768
-
769
- // Load the dashboard if already authenticated
770
- if (Alpine.store('auth').isAuthenticated) {
771
- Alpine.store('dashboard').load();
196
+ switch (currentPage) {
197
+ case 'dashboard': renderDashboard(); break;
198
+ case 'services': renderServicesFlat(); break;
199
+ case 'backups': renderBackups(); break;
200
+ case 'system': renderSystem(); break;
201
+ case 'restore': renderRestore(); break;
202
+ default: renderDashboard();
772203 }
773
-});
204
+}
205
+
206
+function refreshCurrentPage() {
207
+ showRefreshSpinner();
208
+ fetchStatus()
209
+ .then(() => renderPage())
210
+ .catch(e => toast('Refresh failed: ' + e.message, 'error'))
211
+ .finally(() => hideRefreshSpinner());
212
+}
213
+
214
+// ---------------------------------------------------------------------------
215
+// Auto-refresh
216
+// ---------------------------------------------------------------------------
217
+function startAutoRefresh() {
218
+ stopAutoRefresh();
219
+ refreshTimer = setInterval(() => {
220
+ fetchStatus()
221
+ .then(() => {
222
+ if (currentPage === 'dashboard' || currentPage === 'services') renderPage();
223
+ })
224
+ .catch(() => {});
225
+ }, REFRESH_INTERVAL);
226
+}
227
+
228
+function stopAutoRefresh() {
229
+ if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
230
+}
231
+
232
+function showRefreshSpinner() {
233
+ document.getElementById('refresh-indicator').classList.remove('paused');
234
+}
235
+function hideRefreshSpinner() {
236
+ document.getElementById('refresh-indicator').classList.add('paused');
237
+}
238
+
239
+// ---------------------------------------------------------------------------
240
+// Breadcrumbs
241
+// ---------------------------------------------------------------------------
242
+function updateBreadcrumbs() {
243
+ const bc = document.getElementById('breadcrumbs');
244
+ let html = '';
245
+
246
+ if (currentPage === 'dashboard') {
247
+ if (drillLevel === 0) {
248
+ html = '<span class="current">Dashboard</span>';
249
+ } else if (drillLevel === 1) {
250
+ html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + escapeHtml(drillProject) + '</span>';
251
+ } else if (drillLevel === 2) {
252
+ 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>';
253
+ }
254
+ } else {
255
+ const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' };
256
+ html = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
257
+ }
258
+
259
+ bc.innerHTML = html;
260
+}
261
+
262
+function drillBack(level) {
263
+ if (level === 0) {
264
+ drillLevel = 0;
265
+ drillProject = null;
266
+ drillEnv = null;
267
+ } else if (level === 1) {
268
+ drillLevel = 1;
269
+ drillEnv = null;
270
+ }
271
+ renderDashboard();
272
+}
273
+
274
+// ---------------------------------------------------------------------------
275
+// Dashboard — 3-level Drill
276
+// ---------------------------------------------------------------------------
277
+function renderDashboard() {
278
+ currentPage = 'dashboard';
279
+ if (drillLevel === 0) renderProjects();
280
+ else if (drillLevel === 1) renderEnvironments();
281
+ else if (drillLevel === 2) renderServices();
282
+ updateBreadcrumbs();
283
+}
284
+
285
+function renderProjects() {
286
+ const content = document.getElementById('page-content');
287
+ const projects = groupByProject(allServices);
288
+
289
+ // Summary stats
290
+ const totalUp = allServices.filter(s => s.status === 'Up').length;
291
+ const totalDown = allServices.length - totalUp;
292
+
293
+ let html = '<div class="page-enter" style="padding:0;">';
294
+
295
+ // Summary bar
296
+ html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
297
+ html += statCard('Projects', Object.keys(projects).length, '#3b82f6');
298
+ html += statCard('Services', allServices.length, '#8b5cf6');
299
+ html += statCard('Healthy', totalUp, '#10b981');
300
+ html += statCard('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280');
301
+ html += '</div>';
302
+
303
+ // Project cards
304
+ html += '<div class="project-grid">';
305
+ for (const [name, proj] of Object.entries(projects)) {
306
+ const upCount = proj.services.filter(s => s.status === 'Up').length;
307
+ const total = proj.services.length;
308
+ const allUp = upCount === total;
309
+ const envNames = [...new Set(proj.services.map(s => s.env))];
310
+
311
+ html += `<div class="card card-clickable" onclick="drillToProject('${escapeHtml(name)}')">
312
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
313
+ <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
314
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(name)}</span>
315
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
316
+ </div>
317
+ <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
318
+ ${envNames.map(e => `<span class="badge badge-blue">${escapeHtml(e)}</span>`).join('')}
319
+ </div>
320
+ <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
321
+ </div>`;
322
+ }
323
+ html += '</div></div>';
324
+ content.innerHTML = html;
325
+}
326
+
327
+function renderEnvironments() {
328
+ const content = document.getElementById('page-content');
329
+ const projServices = allServices.filter(s => s.project === drillProject);
330
+ const envs = groupByEnv(projServices);
331
+
332
+ let html = '<div class="page-enter" style="padding:0;">';
333
+ html += '<div class="env-grid">';
334
+
335
+ for (const [envName, services] of Object.entries(envs)) {
336
+ const upCount = services.filter(s => s.status === 'Up').length;
337
+ const total = services.length;
338
+ const allUp = upCount === total;
339
+
340
+ html += `<div class="card card-clickable" onclick="drillToEnv('${escapeHtml(envName)}')">
341
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
342
+ <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
343
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(envName).toUpperCase()}</span>
344
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
345
+ </div>
346
+ <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
347
+ ${services.map(s => `<span class="badge ${badgeClass(s.status, s.health)}">${escapeHtml(s.service)}</span>`).join('')}
348
+ </div>
349
+ <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
350
+ </div>`;
351
+ }
352
+
353
+ html += '</div></div>';
354
+ content.innerHTML = html;
355
+}
356
+
357
+function renderServices() {
358
+ const content = document.getElementById('page-content');
359
+ const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
360
+
361
+ let html = '<div class="page-enter" style="padding:0;">';
362
+ html += '<div class="service-grid">';
363
+
364
+ for (const svc of services) {
365
+ html += serviceCard(svc);
366
+ }
367
+
368
+ html += '</div></div>';
369
+ content.innerHTML = html;
370
+}
371
+
372
+function drillToProject(name) {
373
+ drillProject = name;
374
+ drillLevel = 1;
375
+ renderDashboard();
376
+}
377
+
378
+function drillToEnv(name) {
379
+ drillEnv = name;
380
+ drillLevel = 2;
381
+ renderDashboard();
382
+}
383
+
384
+// ---------------------------------------------------------------------------
385
+// Service Card (shared component)
386
+// ---------------------------------------------------------------------------
387
+function serviceCard(svc) {
388
+ const proj = escapeHtml(svc.project);
389
+ const env = escapeHtml(svc.env);
390
+ const service = escapeHtml(svc.service);
391
+ const bc = badgeClass(svc.status, svc.health);
392
+ const dc = statusDotClass(svc.status, svc.health);
393
+
394
+ return `<div class="card">
395
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
396
+ <span class="status-dot ${dc}"></span>
397
+ <span style="font-weight:600;color:#f3f4f6;">${service}</span>
398
+ <span class="badge ${bc}" style="margin-left:auto;">${escapeHtml(svc.status)}</span>
399
+ </div>
400
+ <div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;">
401
+ Health: ${escapeHtml(svc.health || 'n/a')} &middot; Uptime: ${escapeHtml(svc.uptime || 'n/a')}
402
+ </div>
403
+ <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
404
+ <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
405
+ <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
406
+ </div>
407
+ </div>`;
408
+}
409
+
410
+function statCard(label, value, color) {
411
+ return `<div class="card" style="text-align:center;">
412
+ <div style="font-size:1.75rem;font-weight:700;color:${color};">${value}</div>
413
+ <div style="font-size:0.8125rem;color:#9ca3af;">${label}</div>
414
+ </div>`;
415
+}
416
+
417
+// ---------------------------------------------------------------------------
418
+// Services (flat list page)
419
+// ---------------------------------------------------------------------------
420
+function renderServicesFlat() {
421
+ updateBreadcrumbs();
422
+ const content = document.getElementById('page-content');
423
+
424
+ if (allServices.length === 0) {
425
+ content.innerHTML = '<div style="text-align:center;padding:3rem;color:#6b7280;">No services found.</div>';
426
+ return;
427
+ }
428
+
429
+ let html = '<div class="page-enter" style="padding:0;">';
430
+ html += '<div class="table-wrapper"><table class="ops-table">';
431
+ 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>';
432
+ html += '<tbody>';
433
+
434
+ for (const svc of allServices) {
435
+ const bc = badgeClass(svc.status, svc.health);
436
+ const proj = escapeHtml(svc.project);
437
+ const env = escapeHtml(svc.env);
438
+ const service = escapeHtml(svc.service);
439
+
440
+ html += `<tr>
441
+ <td style="font-weight:500;">${proj}</td>
442
+ <td><span class="badge badge-blue">${env}</span></td>
443
+ <td class="mono">${service}</td>
444
+ <td><span class="badge ${bc}">${escapeHtml(svc.status)}</span></td>
445
+ <td>${escapeHtml(svc.health || 'n/a')}</td>
446
+ <td>${escapeHtml(svc.uptime || 'n/a')}</td>
447
+ <td style="white-space:nowrap;">
448
+ <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
449
+ <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
450
+ </td>
451
+ </tr>`;
452
+ }
453
+
454
+ html += '</tbody></table></div></div>';
455
+ content.innerHTML = html;
456
+}
457
+
458
+// ---------------------------------------------------------------------------
459
+// Backups Page
460
+// ---------------------------------------------------------------------------
461
+async function renderBackups() {
462
+ updateBreadcrumbs();
463
+ const content = document.getElementById('page-content');
464
+
465
+ try {
466
+ const [local, offsite] = await Promise.all([
467
+ api('/api/backups/'),
468
+ api('/api/backups/offsite').catch(() => []),
469
+ ]);
470
+
471
+ let html = '<div class="page-enter" style="padding:0;">';
472
+
473
+ // Quick backup buttons
474
+ html += '<div style="margin-bottom:1.5rem;">';
475
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
476
+ html += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
477
+ for (const proj of ['mdf', 'seriousletter']) {
478
+ for (const env of ['dev', 'int', 'prod']) {
479
+ html += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${proj}','${env}')">${proj}/${env}</button>`;
480
+ }
481
+ }
482
+ html += '</div></div>';
483
+
484
+ // Local backups
485
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
486
+ if (local.length === 0) {
487
+ html += '<div class="card" style="color:#6b7280;">No local backups found.</div>';
488
+ } else {
489
+ html += '<div class="table-wrapper"><table class="ops-table">';
490
+ html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th><th>Files</th></tr></thead><tbody>';
491
+ for (const b of local) {
492
+ html += `<tr>
493
+ <td>${escapeHtml(b.project || '')}</td>
494
+ <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
495
+ <td>${escapeHtml(b.date || b.timestamp || '')}</td>
496
+ <td>${escapeHtml(b.size || '')}</td>
497
+ <td class="mono" style="font-size:0.75rem;">${escapeHtml(b.file || b.files || '')}</td>
498
+ </tr>`;
499
+ }
500
+ html += '</tbody></table></div>';
501
+ }
502
+
503
+ // Offsite backups
504
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';
505
+ if (offsite.length === 0) {
506
+ html += '<div class="card" style="color:#6b7280;">No offsite backups found.</div>';
507
+ } else {
508
+ html += '<div class="table-wrapper"><table class="ops-table">';
509
+ html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th></tr></thead><tbody>';
510
+ for (const b of offsite) {
511
+ html += `<tr>
512
+ <td>${escapeHtml(b.project || '')}</td>
513
+ <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
514
+ <td>${escapeHtml(b.date || b.timestamp || '')}</td>
515
+ <td>${escapeHtml(b.size || '')}</td>
516
+ </tr>`;
517
+ }
518
+ html += '</tbody></table></div>';
519
+ }
520
+
521
+ html += '</div>';
522
+ content.innerHTML = html;
523
+ } catch (e) {
524
+ content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + escapeHtml(e.message) + '</div>';
525
+ }
526
+}
527
+
528
+// ---------------------------------------------------------------------------
529
+// System Page
530
+// ---------------------------------------------------------------------------
531
+async function renderSystem() {
532
+ updateBreadcrumbs();
533
+ const content = document.getElementById('page-content');
534
+
535
+ try {
536
+ const [disk, health, timers, info] = await Promise.all([
537
+ api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })),
538
+ api('/api/system/health').catch(e => ({ checks: [], raw: e.message })),
539
+ api('/api/system/timers').catch(e => ({ timers: [], raw: e.message })),
540
+ api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })),
541
+ ]);
542
+
543
+ let html = '<div class="page-enter" style="padding:0;">';
544
+
545
+ // System info bar
546
+ html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
547
+ html += statCard('Uptime', info.uptime || 'n/a', '#3b82f6');
548
+ html += statCard('Load', info.load || 'n/a', '#8b5cf6');
549
+ html += '</div>';
550
+
551
+ // Disk usage
552
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>';
553
+ if (disk.filesystems && disk.filesystems.length > 0) {
554
+ html += '<div style="display:grid;gap:0.75rem;margin-bottom:1.5rem;">';
555
+ for (const fs of disk.filesystems) {
556
+ const pct = parseInt(fs.use_percent) || 0;
557
+ html += `<div class="card">
558
+ <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
559
+ <span class="mono" style="font-size:0.8125rem;">${escapeHtml(fs.mount || fs.filesystem)}</span>
560
+ <span style="font-size:0.8125rem;color:#9ca3af;">${escapeHtml(fs.used)} / ${escapeHtml(fs.size)} (${escapeHtml(fs.use_percent)})</span>
561
+ </div>
562
+ <div class="progress-bar-track">
563
+ <div class="progress-bar-fill ${diskColorClass(fs.use_percent)}" style="width:${pct}%;"></div>
564
+ </div>
565
+ </div>`;
566
+ }
567
+ html += '</div>';
568
+ } else {
569
+ html += '<div class="card" style="color:#6b7280;">No disk data available.</div>';
570
+ }
571
+
572
+ // Health checks
573
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>';
574
+ if (health.checks && health.checks.length > 0) {
575
+ html += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">';
576
+ for (const c of health.checks) {
577
+ const st = (c.status || '').toUpperCase();
578
+ const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray';
579
+ html += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;">
580
+ <span class="badge ${cls}">${escapeHtml(st)}</span>
581
+ <span style="font-size:0.875rem;">${escapeHtml(c.check)}</span>
582
+ </div>`;
583
+ }
584
+ html += '</div>';
585
+ } else {
586
+ html += '<div class="card" style="color:#6b7280;">No health check data.</div>';
587
+ }
588
+
589
+ // Timers
590
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Systemd Timers</h2>';
591
+ if (timers.timers && timers.timers.length > 0) {
592
+ html += '<div class="table-wrapper"><table class="ops-table">';
593
+ html += '<thead><tr><th>Unit</th><th>Next</th><th>Left</th><th>Last</th><th>Passed</th></tr></thead><tbody>';
594
+ for (const t of timers.timers) {
595
+ html += `<tr>
596
+ <td class="mono">${escapeHtml(t.unit)}</td>
597
+ <td>${escapeHtml(t.next)}</td>
598
+ <td>${escapeHtml(t.left)}</td>
599
+ <td>${escapeHtml(t.last)}</td>
600
+ <td>${escapeHtml(t.passed)}</td>
601
+ </tr>`;
602
+ }
603
+ html += '</tbody></table></div>';
604
+ } else {
605
+ html += '<div class="card" style="color:#6b7280;">No timers found.</div>';
606
+ }
607
+
608
+ html += '</div>';
609
+ content.innerHTML = html;
610
+ } catch (e) {
611
+ content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + escapeHtml(e.message) + '</div>';
612
+ }
613
+}
614
+
615
+// ---------------------------------------------------------------------------
616
+// Restore Page
617
+// ---------------------------------------------------------------------------
618
+function renderRestore() {
619
+ updateBreadcrumbs();
620
+ const content = document.getElementById('page-content');
621
+
622
+ let html = '<div class="page-enter" style="padding:0;">';
623
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';
624
+ html += '<div class="card" style="max-width:480px;">';
625
+
626
+ html += '<div style="margin-bottom:1rem;">';
627
+ html += '<label class="form-label">Project</label>';
628
+ html += '<select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select>';
629
+ html += '</div>';
630
+
631
+ html += '<div style="margin-bottom:1rem;">';
632
+ html += '<label class="form-label">Environment</label>';
633
+ html += '<select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select>';
634
+ html += '</div>';
635
+
636
+ html += '<div style="margin-bottom:1rem;">';
637
+ html += '<label class="form-label">Source</label>';
638
+ html += '<select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select>';
639
+ html += '</div>';
640
+
641
+ html += '<div style="margin-bottom:1rem;">';
642
+ html += '<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;">';
643
+ html += '<input type="checkbox" id="restore-dry" checked> Dry run (preview only)';
644
+ html += '</label>';
645
+ html += '</div>';
646
+
647
+ html += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';
648
+ html += '</div>';
649
+
650
+ html += '<div id="restore-output" style="display:none;margin-top:1rem;">';
651
+ html += '<h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3>';
652
+ html += '<div id="restore-terminal" class="terminal" style="max-height:400px;"></div>';
653
+ html += '</div>';
654
+
655
+ html += '</div>';
656
+ content.innerHTML = html;
657
+}
658
+
659
+async function startRestore() {
660
+ const project = document.getElementById('restore-project').value;
661
+ const env = document.getElementById('restore-env').value;
662
+ const source = document.getElementById('restore-source').value;
663
+ const dryRun = document.getElementById('restore-dry').checked;
664
+
665
+ if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}? This may overwrite data.`)) return;
666
+
667
+ const outputDiv = document.getElementById('restore-output');
668
+ const terminal = document.getElementById('restore-terminal');
669
+ outputDiv.style.display = 'block';
670
+ terminal.textContent = 'Starting restore...\n';
671
+
672
+ const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`;
673
+ const evtSource = new EventSource(url);
674
+
675
+ evtSource.onmessage = function(e) {
676
+ const data = JSON.parse(e.data);
677
+ if (data.done) {
678
+ evtSource.close();
679
+ terminal.textContent += data.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
680
+ toast(data.success ? 'Restore completed' : 'Restore failed', data.success ? 'success' : 'error');
681
+ return;
682
+ }
683
+ if (data.line) {
684
+ terminal.textContent += data.line + '\n';
685
+ terminal.scrollTop = terminal.scrollHeight;
686
+ }
687
+ };
688
+
689
+ evtSource.onerror = function() {
690
+ evtSource.close();
691
+ terminal.textContent += '\n--- Connection lost ---\n';
692
+ toast('Restore connection lost', 'error');
693
+ };
694
+}
695
+
696
+// ---------------------------------------------------------------------------
697
+// Service Actions
698
+// ---------------------------------------------------------------------------
699
+async function restartService(project, env, service) {
700
+ if (!confirm(`Restart ${service} in ${project}/${env}?`)) return;
701
+
702
+ toast('Restarting ' + service + '...', 'info');
703
+ try {
704
+ const result = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
705
+ toast(result.message || 'Restarted successfully', 'success');
706
+ setTimeout(() => refreshCurrentPage(), 3000);
707
+ } catch (e) {
708
+ toast('Restart failed: ' + e.message, 'error');
709
+ }
710
+}
711
+
712
+async function viewLogs(project, env, service) {
713
+ logModalProject = project;
714
+ logModalEnv = env;
715
+ logModalService = service;
716
+
717
+ document.getElementById('log-modal-title').textContent = `Logs: ${project}/${env}/${service}`;
718
+ document.getElementById('log-modal-content').textContent = 'Loading...';
719
+ document.getElementById('log-modal').style.display = 'flex';
720
+
721
+ await refreshLogs();
722
+}
723
+
724
+async function refreshLogs() {
725
+ if (!logModalProject) return;
726
+ try {
727
+ const data = await api(`/api/services/logs/${logModalProject}/${logModalEnv}/${logModalService}?lines=200`);
728
+ const terminal = document.getElementById('log-modal-content');
729
+ terminal.textContent = data.logs || 'No logs available.';
730
+ terminal.scrollTop = terminal.scrollHeight;
731
+ } catch (e) {
732
+ document.getElementById('log-modal-content').textContent = 'Error loading logs: ' + e.message;
733
+ }
734
+}
735
+
736
+function closeLogModal() {
737
+ document.getElementById('log-modal').style.display = 'none';
738
+ logModalProject = null;
739
+ logModalEnv = null;
740
+ logModalService = null;
741
+}
742
+
743
+// ---------------------------------------------------------------------------
744
+// Backup Actions
745
+// ---------------------------------------------------------------------------
746
+async function createBackup(project, env) {
747
+ if (!confirm(`Create backup for ${project}/${env}?`)) return;
748
+ toast('Creating backup for ' + project + '/' + env + '...', 'info');
749
+ try {
750
+ await api(`/api/backups/${project}/${env}`, { method: 'POST' });
751
+ toast('Backup created for ' + project + '/' + env, 'success');
752
+ if (currentPage === 'backups') renderBackups();
753
+ } catch (e) {
754
+ toast('Backup failed: ' + e.message, 'error');
755
+ }
756
+}
757
+
758
+// ---------------------------------------------------------------------------
759
+// Data Grouping
760
+// ---------------------------------------------------------------------------
761
+function groupByProject(services) {
762
+ const map = {};
763
+ for (const s of services) {
764
+ const key = s.project || 'other';
765
+ if (!map[key]) map[key] = { name: key, services: [] };
766
+ map[key].services.push(s);
767
+ }
768
+ return map;
769
+}
770
+
771
+function groupByEnv(services) {
772
+ const map = {};
773
+ for (const s of services) {
774
+ const key = s.env || 'default';
775
+ if (!map[key]) map[key] = [];
776
+ map[key].push(s);
777
+ }
778
+ return map;
779
+}
780
+
781
+// ---------------------------------------------------------------------------
782
+// Init
783
+// ---------------------------------------------------------------------------
784
+(function init() {
785
+ const token = getToken();
786
+ if (token) {
787
+ // Validate and load
788
+ fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
789
+ .then(r => {
790
+ if (!r.ok) throw new Error('Invalid token');
791
+ return r.json();
792
+ })
793
+ .then(data => {
794
+ allServices = data;
795
+ document.getElementById('login-overlay').style.display = 'none';
796
+ document.getElementById('app').style.display = 'flex';
797
+ showPage('dashboard');
798
+ startAutoRefresh();
799
+ })
800
+ .catch(() => {
801
+ localStorage.removeItem('ops_token');
802
+ document.getElementById('login-overlay').style.display = 'flex';
803
+ });
804
+ }
805
+
806
+ // ESC to close modals
807
+ document.addEventListener('keydown', e => {
808
+ if (e.key === 'Escape') {
809
+ closeLogModal();
810
+ }
811
+ });
812
+})();