Matthias Nott
2026-02-21 485476a297c111e37fec9913535a63a2383ca06e
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
+})();