smodusermc commited on
Commit
f5d05ea
·
verified ·
1 Parent(s): 7ef9510

Create admin.html

Browse files
Files changed (1) hide show
  1. static/admin.html +348 -0
static/admin.html ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>InfinityChat Admin</title>
7
+ <style>
8
+ :root {
9
+ --primary:#6366F1; --primary-hover:#4F46E5; --primary-soft:#312E81;
10
+ --bg:#0B0B14; --bg2:#171725; --bg3:#262639;
11
+ --text:#F5F3FF; --text2:#C4B5FD; --text3:#8A8AA0;
12
+ --border:#373349; --border-light:#23202F;
13
+ --success:#34D399; --warning:#FBBF24; --danger:#FB7185; --info:#60A5FA;
14
+ }
15
+ * { box-sizing:border-box; margin:0; padding:0; }
16
+ body {
17
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
18
+ background:var(--bg); color:var(--text); min-height:100vh;
19
+ }
20
+ a { color:var(--info); text-decoration:none; }
21
+ a:hover { text-decoration:underline; }
22
+ .topbar {
23
+ display:flex; align-items:center; gap:.75rem;
24
+ padding:.9rem 1.25rem; background:var(--bg2);
25
+ border-bottom:1px solid var(--border-light);
26
+ position:sticky; top:0; z-index:10;
27
+ }
28
+ .topbar h1 { font-size:1.05rem; font-weight:700; display:flex; gap:.5rem; align-items:center; }
29
+ .topbar .spacer { flex:1; }
30
+ .btn {
31
+ border:none; border-radius:8px; padding:.55rem .9rem; cursor:pointer;
32
+ font-weight:600; font-size:.85rem; color:#fff; background:var(--primary);
33
+ transition:background .15s ease, transform .05s ease;
34
+ }
35
+ .btn:hover { background:var(--primary-hover); }
36
+ .btn:disabled { opacity:.5; cursor:not-allowed; }
37
+ .btn.secondary { background:var(--bg3); color:var(--text); border:1px solid var(--border); }
38
+ .btn.secondary:hover { background:#31314A; }
39
+ .btn.danger { background:transparent; color:var(--danger); border:1px solid rgba(251,113,133,.35); }
40
+ .btn.danger:hover { background:rgba(251,113,133,.12); }
41
+ .container { max-width:1000px; margin:0 auto; padding:1.5rem 1.25rem 4rem; }
42
+ .card {
43
+ background:var(--bg2); border:1px solid var(--border);
44
+ border-radius:14px; padding:1.1rem 1.2rem; margin-bottom:1rem;
45
+ }
46
+ .card h2 { font-size:.95rem; margin-bottom:.9rem; display:flex; align-items:center; gap:.45rem; }
47
+ .muted { color:var(--text3); font-size:.82rem; }
48
+ .hidden { display:none !important; }
49
+
50
+ .login-wrap { max-width:380px; margin:12vh auto 0; }
51
+ .login-wrap .card { padding:1.5rem; }
52
+ .login-wrap h2 { font-size:1.15rem; margin-bottom:.25rem; }
53
+ .login-wrap .muted { display:block; margin-bottom:1rem; }
54
+ label { display:block; font-size:.82rem; color:var(--text2); margin:.8rem 0 .3rem; }
55
+ input {
56
+ width:100%; background:var(--bg3); color:var(--text);
57
+ border:1px solid var(--border); border-radius:8px; padding:.65rem .75rem;
58
+ font-size:.9rem;
59
+ }
60
+ input:focus { outline:none; border-color:var(--primary); }
61
+ .login-err { color:var(--danger); font-size:.82rem; margin-top:.7rem; min-height:1em; }
62
+
63
+ .status-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:.8rem; }
64
+ .stat {
65
+ background:var(--bg3); border:1px solid var(--border-light);
66
+ border-radius:10px; padding:.8rem .9rem;
67
+ }
68
+ .stat .label { font-size:.72rem; color:var(--text3); text-transform:uppercase; letter-spacing:.04em; }
69
+ .stat .value { font-size:.9rem; font-weight:600; margin-top:.25rem; word-break:break-all; }
70
+ .toolbar { display:flex; gap:.5rem; flex-wrap:wrap; align-items:center; }
71
+ .badge { display:inline-block; padding:.12rem .5rem; border-radius:999px; font-size:.72rem; font-weight:700; }
72
+ .badge.local { background:rgba(96,165,250,.15); color:var(--info); }
73
+ .badge.bucket { background:rgba(52,211,153,.15); color:var(--success); }
74
+
75
+ .backup-list { display:flex; flex-direction:column; gap:.55rem; }
76
+ .backup-row {
77
+ display:flex; align-items:center; gap:1rem; flex-wrap:wrap;
78
+ padding:.75rem .9rem; background:var(--bg3);
79
+ border:1px solid var(--border-light); border-radius:10px;
80
+ }
81
+ .backup-row .info { flex:1; min-width:180px; }
82
+ .backup-row .info .name { font-weight:600; font-size:.88rem; }
83
+ .backup-row .info .meta { font-size:.76rem; color:var(--text3); margin-top:.15rem; }
84
+ .backup-row .actions { display:flex; gap:.4rem; }
85
+ .empty { color:var(--text3); padding:1rem; text-align:center; }
86
+ .toast {
87
+ position:fixed; bottom:1rem; right:1rem; z-index:1000;
88
+ background:var(--bg3); border:1px solid var(--border);
89
+ border-radius:10px; padding:.7rem .9rem; max-width:320px;
90
+ box-shadow:0 8px 20px rgba(0,0,0,.4); font-size:.85rem;
91
+ }
92
+ .toast.error { border-color:rgba(251,113,133,.5); color:var(--danger); }
93
+ .toast.success { border-color:rgba(52,211,153,.5); color:var(--success); }
94
+ @media (max-width:600px) {
95
+ .status-grid { grid-template-columns:1fr; }
96
+ .topbar { flex-wrap:wrap; }
97
+ }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <div class="topbar">
102
+ <h1>🛠 InfinityChat Admin</h1>
103
+ <div class="spacer"></div>
104
+ <a href="/">← Main app</a>
105
+ <button id="logoutBtn" class="btn secondary hidden">Log out</button>
106
+ </div>
107
+
108
+ <div class="container">
109
+ <div id="loginView" class="login-wrap hidden">
110
+ <div class="card">
111
+ <h2>Admin sign in</h2>
112
+ <span class="muted">Use the admin username/password configured in the host's environment secrets.</span>
113
+ <label for="username">Username</label>
114
+ <input id="username" autocomplete="username" autofocus>
115
+ <label for="password">Password</label>
116
+ <input id="password" type="password" autocomplete="current-password">
117
+ <div id="loginErr" class="login-err"></div>
118
+ <div style="margin-top:1rem"><button id="loginBtn" class="btn" style="width:100%">Sign in</button></div>
119
+ </div>
120
+ </div>
121
+
122
+ <div id="dashboardView" class="hidden">
123
+ <div class="card">
124
+ <h2>System status</h2>
125
+ <div id="statusGrid" class="status-grid"></div>
126
+ </div>
127
+
128
+ <div class="card">
129
+ <h2>Backups</h2>
130
+ <div class="toolbar">
131
+ <button id="refreshBtn" class="btn secondary">Refresh</button>
132
+ <button id="createBtn" class="btn">Create backup now</button>
133
+ <span class="muted">A full point-in-time backup runs automatically every hour.</span>
134
+ </div>
135
+ <div id="backupList" class="backup-list" style="margin-top:.9rem"></div>
136
+ </div>
137
+ </div>
138
+ </div>
139
+
140
+ <script>
141
+ (() => {
142
+ const API = location.origin;
143
+ const TOKEN_KEY = 'infinitychat-admin-token';
144
+ const loginView = document.getElementById('loginView');
145
+ const dashboardView = document.getElementById('dashboardView');
146
+ const logoutBtn = document.getElementById('logoutBtn');
147
+ const loginBtn = document.getElementById('loginBtn');
148
+ const loginErr = document.getElementById('loginErr');
149
+ const usernameEl = document.getElementById('username');
150
+ const passwordEl = document.getElementById('password');
151
+ const statusGrid = document.getElementById('statusGrid');
152
+ const backupList = document.getElementById('backupList');
153
+ const refreshBtn = document.getElementById('refreshBtn');
154
+ const createBtn = document.getElementById('createBtn');
155
+
156
+ function esc(s) {
157
+ return String(s == null ? '' : s)
158
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
159
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
160
+ }
161
+ function fmt(n) {
162
+ if (n == null) return '0 B';
163
+ const v = Number(n);
164
+ if (!Number.isFinite(v)) return '0 B';
165
+ if (v >= 1024*1024*1024) return (v/1024/1024/1024).toFixed(1)+' GB';
166
+ if (v >= 1024*1024) return (v/1024/1024).toFixed(1)+' MB';
167
+ if (v >= 1024) return (v/1024).toFixed(1)+' KB';
168
+ return Math.round(v)+' B';
169
+ }
170
+ function toast(msg, ok) {
171
+ const el = document.createElement('div');
172
+ el.className = 'toast ' + (ok ? 'success' : 'error');
173
+ el.textContent = msg;
174
+ document.body.appendChild(el);
175
+ setTimeout(() => el.remove(), 4200);
176
+ }
177
+ function token() { return sessionStorage.getItem(TOKEN_KEY) || ''; }
178
+ function setToken(v) { sessionStorage.setItem(TOKEN_KEY, v || ''); }
179
+ function showDashboard() { loginView.classList.add('hidden'); dashboardView.classList.remove('hidden'); logoutBtn.classList.remove('hidden'); }
180
+ function showLogin() { loginView.classList.remove('hidden'); dashboardView.classList.add('hidden'); logoutBtn.classList.add('hidden'); }
181
+
182
+ async function api(path, opts) {
183
+ const headers = Object.assign({}, opts && opts.headers);
184
+ const t = token();
185
+ if (t) headers['X-Admin-Token'] = t;
186
+ if (opts && opts.body) headers['Content-Type'] = 'application/json';
187
+ const res = await fetch(API + path, Object.assign({}, opts, { headers }));
188
+ const data = await res.json().catch(() => ({}));
189
+ if (!res.ok) throw new Error(data.detail || ('Request failed (' + res.status + ')'));
190
+ return data;
191
+ }
192
+
193
+ async function refreshStatus() {
194
+ try {
195
+ const s = await api('/api/admin/status');
196
+ const mode = s.storage_mode === 'local' ? 'local' : 'bucket';
197
+ const rows = [
198
+ ['App version', s.app_version],
199
+ ['Storage mode', mode],
200
+ ['Backup count', s.backup_count],
201
+ ['Backup interval', 'every ' + Math.round((s.backup_interval_seconds||3600)/3600) + 'h'],
202
+ ['Database', s.db_path],
203
+ ['Temp uploads', s.temp_dir],
204
+ ['Local storage', s.local_storage_dir],
205
+ ];
206
+ statusGrid.innerHTML = rows.map(([label, value]) =>
207
+ `<div class="stat"><div class="label">${esc(label)}</div><div class="value">${esc(value || '—')}</div></div>`
208
+ ).join('') +
209
+ `<div class="stat"><div class="label">Storage</div><div class="value"><span class="badge ${mode==='local'?'local':'bucket'}">${esc(mode)}</span></div></div>`;
210
+ } catch (e) {
211
+ if (e.message && /401|expired|authentication/i.test(e.message)) {
212
+ setToken(''); showLogin(); loginErr.textContent = 'Session expired. Sign in again.';
213
+ } else {
214
+ toast(e.message, false);
215
+ }
216
+ }
217
+ }
218
+
219
+ async function refreshBackups() {
220
+ refreshBtn.disabled = true;
221
+ refreshBtn.textContent = 'Refreshing…';
222
+ try {
223
+ const data = await api('/api/admin/backups');
224
+ const list = data.backups || [];
225
+ if (!list.length) {
226
+ backupList.innerHTML = '<div class="empty">No backups yet. The first hourly backup will appear soon.</div>';
227
+ return;
228
+ }
229
+ backupList.innerHTML = list.map(b => `
230
+ <div class="backup-row">
231
+ <div class="info">
232
+ <div class="name">${esc(b.category)} · ${esc(b.timestamp)}</div>
233
+ <div class="meta">${b.file_count||0} files · ${fmt(b.size||0)}${b.has_database ? ' · with database' : ''}</div>
234
+ </div>
235
+ <div class="actions">
236
+ <button class="btn secondary restore-btn" data-path="${esc(b.path)}">Restore</button>
237
+ <button class="btn danger delete-btn" data-path="${esc(b.path)}">Delete</button>
238
+ </div>
239
+ </div>`).join('');
240
+ } catch (e) {
241
+ backupList.innerHTML = `<div class="empty">${esc(e.message)}</div>`;
242
+ } finally {
243
+ refreshBtn.disabled = false;
244
+ refreshBtn.textContent = 'Refresh';
245
+ }
246
+ }
247
+
248
+ async function login(e) {
249
+ e.preventDefault();
250
+ const username = usernameEl.value.trim();
251
+ const password = passwordEl.value;
252
+ loginBtn.disabled = true; loginErr.textContent = '';
253
+ try {
254
+ const data = await api('/api/admin/login', {
255
+ method:'POST',
256
+ body: JSON.stringify({ username, password })
257
+ });
258
+ setToken(data.token);
259
+ passwordEl.value = '';
260
+ showDashboard();
261
+ toast('Signed in as ' + data.username, true);
262
+ await Promise.all([refreshStatus(), refreshBackups()]);
263
+ } catch (err) {
264
+ loginErr.textContent = err.message;
265
+ } finally {
266
+ loginBtn.disabled = false;
267
+ }
268
+ }
269
+
270
+ async function logout() {
271
+ try { await api('/api/admin/logout', { method:'POST' }); } catch (e) {}
272
+ setToken('');
273
+ showLogin();
274
+ }
275
+
276
+ async function createBackup() {
277
+ createBtn.disabled = true;
278
+ createBtn.textContent = 'Creating…';
279
+ try {
280
+ const data = await api('/api/admin/backups/create', { method:'POST' });
281
+ toast('Backup created at ' + data.path, true);
282
+ await refreshBackups();
283
+ } catch (e) { toast(e.message, false); }
284
+ finally {
285
+ createBtn.disabled = false;
286
+ createBtn.textContent = 'Create backup now';
287
+ }
288
+ }
289
+
290
+ function backupPath(btn) { return btn.dataset.path; }
291
+
292
+ async function restore(e) {
293
+ const btn = e.target.closest('.restore-btn');
294
+ if (!btn) return;
295
+ if (!confirm('Restore this backup? This overwrites the live database and all restored files. Restart the server after restoring.')) return;
296
+ btn.disabled = true;
297
+ const old = btn.textContent;
298
+ btn.textContent = 'Restoring…';
299
+ try {
300
+ const path = backupPath(btn);
301
+ const data = await api('/api/admin/backups/' + encodeURIComponent(path).replace(/%2F/g,'/') + '/restore', { method:'POST' });
302
+ toast('Restored ' + (data.files||0) + ' files. Restart the server to apply.', true);
303
+ } catch (e) { toast(e.message, false); }
304
+ finally { btn.disabled = false; btn.textContent = old; }
305
+ }
306
+
307
+ async function deleteBackup(e) {
308
+ const btn = e.target.closest('.delete-btn');
309
+ if (!btn) return;
310
+ if (!confirm('Delete this backup permanently? This cannot be undone.')) return;
311
+ btn.disabled = true;
312
+ try {
313
+ const path = backupPath(btn);
314
+ await api('/api/admin/backups/' + encodeURIComponent(path).replace(/%2F/g,'/'), { method:'DELETE' });
315
+ toast('Backup deleted', true);
316
+ await refreshBackups();
317
+ } catch (e) { toast(e.message, false); }
318
+ finally { btn.disabled = false; }
319
+ }
320
+
321
+ loginBtn.addEventListener('click', login);
322
+ [usernameEl, passwordEl].forEach(el => el.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(e); }));
323
+ logoutBtn.addEventListener('click', logout);
324
+ refreshBtn.addEventListener('click', refreshBackups);
325
+ createBtn.addEventListener('click', createBackup);
326
+ backupList.addEventListener('click', (e) => {
327
+ if (e.target.closest('.restore-btn')) restore(e);
328
+ else if (e.target.closest('.delete-btn')) deleteBackup(e);
329
+ });
330
+
331
+ (async function init() {
332
+ if (!token()) { showLogin(); return; }
333
+ try {
334
+ await refreshStatus();
335
+ await refreshBackups();
336
+ showDashboard();
337
+ } catch (e) {
338
+ if (e.message && /401|expired|authentication/i.test(e.message)) {
339
+ setToken(''); showLogin(); loginErr.textContent = 'Session expired. Sign in again.';
340
+ } else {
341
+ showLogin(); loginErr.textContent = e.message;
342
+ }
343
+ }
344
+ })();
345
+ })();
346
+ </script>
347
+ </body>
348
+ </html>