Matthias Nott
2026-02-21 485476a297c111e37fec9913535a63a2383ca06e
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>