Spaces:
Running
Running
Download static/admin.html from smodusermc/InfinityChat: direct link, hf CLI and curl.
- Browser
- Download file 14.2 kB
-
https://huggingface.co/spaces/smodusermc/InfinityChat/resolve/main/static/admin.html
- Command line
-
hf download hf://spaces/smodusermc/InfinityChat/static/admin.html
-
curl -L -o admin.html https://huggingface.co/spaces/smodusermc/InfinityChat/resolve/main/static/admin.html
14.2 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>InfinityChat Admin</title> | |
| <style> | |
| :root { | |
| --primary:#6366F1; --primary-hover:#4F46E5; --primary-soft:#312E81; | |
| --bg:#0B0B14; --bg2:#171725; --bg3:#262639; | |
| --text:#F5F3FF; --text2:#C4B5FD; --text3:#8A8AA0; | |
| --border:#373349; --border-light:#23202F; | |
| --success:#34D399; --warning:#FBBF24; --danger:#FB7185; --info:#60A5FA; | |
| } | |
| * { box-sizing:border-box; margin:0; padding:0; } | |
| body { | |
| font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; | |
| background:var(--bg); color:var(--text); min-height:100vh; | |
| } | |
| a { color:var(--info); text-decoration:none; } | |
| a:hover { text-decoration:underline; } | |
| .topbar { | |
| display:flex; align-items:center; gap:.75rem; | |
| padding:.9rem 1.25rem; background:var(--bg2); | |
| border-bottom:1px solid var(--border-light); | |
| position:sticky; top:0; z-index:10; | |
| } | |
| .topbar h1 { font-size:1.05rem; font-weight:700; display:flex; gap:.5rem; align-items:center; } | |
| .topbar .spacer { flex:1; } | |
| .btn { | |
| border:none; border-radius:8px; padding:.55rem .9rem; cursor:pointer; | |
| font-weight:600; font-size:.85rem; color:#fff; background:var(--primary); | |
| transition:background .15s ease, transform .05s ease; | |
| } | |
| .btn:hover { background:var(--primary-hover); } | |
| .btn:disabled { opacity:.5; cursor:not-allowed; } | |
| .btn.secondary { background:var(--bg3); color:var(--text); border:1px solid var(--border); } | |
| .btn.secondary:hover { background:#31314A; } | |
| .btn.danger { background:transparent; color:var(--danger); border:1px solid rgba(251,113,133,.35); } | |
| .btn.danger:hover { background:rgba(251,113,133,.12); } | |
| .container { max-width:1000px; margin:0 auto; padding:1.5rem 1.25rem 4rem; } | |
| .card { | |
| background:var(--bg2); border:1px solid var(--border); | |
| border-radius:14px; padding:1.1rem 1.2rem; margin-bottom:1rem; | |
| } | |
| .card h2 { font-size:.95rem; margin-bottom:.9rem; display:flex; align-items:center; gap:.45rem; } | |
| .muted { color:var(--text3); font-size:.82rem; } | |
| .hidden { display:none ; } | |
| .login-wrap { max-width:380px; margin:12vh auto 0; } | |
| .login-wrap .card { padding:1.5rem; } | |
| .login-wrap h2 { font-size:1.15rem; margin-bottom:.25rem; } | |
| .login-wrap .muted { display:block; margin-bottom:1rem; } | |
| label { display:block; font-size:.82rem; color:var(--text2); margin:.8rem 0 .3rem; } | |
| input { | |
| width:100%; background:var(--bg3); color:var(--text); | |
| border:1px solid var(--border); border-radius:8px; padding:.65rem .75rem; | |
| font-size:.9rem; | |
| } | |
| input:focus { outline:none; border-color:var(--primary); } | |
| .login-err { color:var(--danger); font-size:.82rem; margin-top:.7rem; min-height:1em; } | |
| .status-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:.8rem; } | |
| .stat { | |
| background:var(--bg3); border:1px solid var(--border-light); | |
| border-radius:10px; padding:.8rem .9rem; | |
| } | |
| .stat .label { font-size:.72rem; color:var(--text3); text-transform:uppercase; letter-spacing:.04em; } | |
| .stat .value { font-size:.9rem; font-weight:600; margin-top:.25rem; word-break:break-all; } | |
| .toolbar { display:flex; gap:.5rem; flex-wrap:wrap; align-items:center; } | |
| .badge { display:inline-block; padding:.12rem .5rem; border-radius:999px; font-size:.72rem; font-weight:700; } | |
| .badge.local { background:rgba(96,165,250,.15); color:var(--info); } | |
| .badge.bucket { background:rgba(52,211,153,.15); color:var(--success); } | |
| .backup-list { display:flex; flex-direction:column; gap:.55rem; } | |
| .backup-row { | |
| display:flex; align-items:center; gap:1rem; flex-wrap:wrap; | |
| padding:.75rem .9rem; background:var(--bg3); | |
| border:1px solid var(--border-light); border-radius:10px; | |
| } | |
| .backup-row .info { flex:1; min-width:180px; } | |
| .backup-row .info .name { font-weight:600; font-size:.88rem; } | |
| .backup-row .info .meta { font-size:.76rem; color:var(--text3); margin-top:.15rem; } | |
| .backup-row .actions { display:flex; gap:.4rem; } | |
| .empty { color:var(--text3); padding:1rem; text-align:center; } | |
| .toast { | |
| position:fixed; bottom:1rem; right:1rem; z-index:1000; | |
| background:var(--bg3); border:1px solid var(--border); | |
| border-radius:10px; padding:.7rem .9rem; max-width:320px; | |
| box-shadow:0 8px 20px rgba(0,0,0,.4); font-size:.85rem; | |
| } | |
| .toast.error { border-color:rgba(251,113,133,.5); color:var(--danger); } | |
| .toast.success { border-color:rgba(52,211,153,.5); color:var(--success); } | |
| @media (max-width:600px) { | |
| .status-grid { grid-template-columns:1fr; } | |
| .topbar { flex-wrap:wrap; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="topbar"> | |
| <h1>🛠 InfinityChat Admin</h1> | |
| <div class="spacer"></div> | |
| <a href="/">← Main app</a> | |
| <button id="logoutBtn" class="btn secondary hidden">Log out</button> | |
| </div> | |
| <div class="container"> | |
| <div id="loginView" class="login-wrap hidden"> | |
| <div class="card"> | |
| <h2>Admin sign in</h2> | |
| <span class="muted">Use the admin username/password configured in the host's environment secrets.</span> | |
| <label for="username">Username</label> | |
| <input id="username" autocomplete="username" autofocus> | |
| <label for="password">Password</label> | |
| <input id="password" type="password" autocomplete="current-password"> | |
| <div id="loginErr" class="login-err"></div> | |
| <div style="margin-top:1rem"><button id="loginBtn" class="btn" style="width:100%">Sign in</button></div> | |
| </div> | |
| </div> | |
| <div id="dashboardView" class="hidden"> | |
| <div class="card"> | |
| <h2>System status</h2> | |
| <div id="statusGrid" class="status-grid"></div> | |
| </div> | |
| <div class="card"> | |
| <h2>Backups</h2> | |
| <div class="toolbar"> | |
| <button id="refreshBtn" class="btn secondary">Refresh</button> | |
| <button id="createBtn" class="btn">Create backup now</button> | |
| <span class="muted">A full point-in-time backup runs automatically every hour.</span> | |
| </div> | |
| <div id="backupList" class="backup-list" style="margin-top:.9rem"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (() => { | |
| const API = location.origin; | |
| const TOKEN_KEY = 'infinitychat-admin-token'; | |
| const loginView = document.getElementById('loginView'); | |
| const dashboardView = document.getElementById('dashboardView'); | |
| const logoutBtn = document.getElementById('logoutBtn'); | |
| const loginBtn = document.getElementById('loginBtn'); | |
| const loginErr = document.getElementById('loginErr'); | |
| const usernameEl = document.getElementById('username'); | |
| const passwordEl = document.getElementById('password'); | |
| const statusGrid = document.getElementById('statusGrid'); | |
| const backupList = document.getElementById('backupList'); | |
| const refreshBtn = document.getElementById('refreshBtn'); | |
| const createBtn = document.getElementById('createBtn'); | |
| function esc(s) { | |
| return String(s == null ? '' : s) | |
| .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') | |
| .replace(/"/g,'"').replace(/'/g,'''); | |
| } | |
| function fmt(n) { | |
| if (n == null) return '0 B'; | |
| const v = Number(n); | |
| if (!Number.isFinite(v)) return '0 B'; | |
| if (v >= 1024*1024*1024) return (v/1024/1024/1024).toFixed(1)+' GB'; | |
| if (v >= 1024*1024) return (v/1024/1024).toFixed(1)+' MB'; | |
| if (v >= 1024) return (v/1024).toFixed(1)+' KB'; | |
| return Math.round(v)+' B'; | |
| } | |
| function toast(msg, ok) { | |
| const el = document.createElement('div'); | |
| el.className = 'toast ' + (ok ? 'success' : 'error'); | |
| el.textContent = msg; | |
| document.body.appendChild(el); | |
| setTimeout(() => el.remove(), 4200); | |
| } | |
| function token() { return sessionStorage.getItem(TOKEN_KEY) || ''; } | |
| function setToken(v) { sessionStorage.setItem(TOKEN_KEY, v || ''); } | |
| function showDashboard() { loginView.classList.add('hidden'); dashboardView.classList.remove('hidden'); logoutBtn.classList.remove('hidden'); } | |
| function showLogin() { loginView.classList.remove('hidden'); dashboardView.classList.add('hidden'); logoutBtn.classList.add('hidden'); } | |
| async function api(path, opts) { | |
| const headers = Object.assign({}, opts && opts.headers); | |
| const t = token(); | |
| if (t) headers['X-Admin-Token'] = t; | |
| if (opts && opts.body) headers['Content-Type'] = 'application/json'; | |
| const res = await fetch(API + path, Object.assign({}, opts, { headers })); | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) throw new Error(data.detail || ('Request failed (' + res.status + ')')); | |
| return data; | |
| } | |
| async function refreshStatus() { | |
| try { | |
| const s = await api('/api/admin/status'); | |
| const mode = s.storage_mode === 'local' ? 'local' : 'bucket'; | |
| const rows = [ | |
| ['App version', s.app_version], | |
| ['Storage mode', mode], | |
| ['Backup count', s.backup_count], | |
| ['Backup interval', 'every ' + Math.round((s.backup_interval_seconds||3600)/3600) + 'h'], | |
| ['Database', s.db_path], | |
| ['Temp uploads', s.temp_dir], | |
| ['Local storage', s.local_storage_dir], | |
| ]; | |
| statusGrid.innerHTML = rows.map(([label, value]) => | |
| `<div class="stat"><div class="label">${esc(label)}</div><div class="value">${esc(value || '—')}</div></div>` | |
| ).join('') + | |
| `<div class="stat"><div class="label">Storage</div><div class="value"><span class="badge ${mode==='local'?'local':'bucket'}">${esc(mode)}</span></div></div>`; | |
| } catch (e) { | |
| if (e.message && /401|expired|authentication/i.test(e.message)) { | |
| setToken(''); showLogin(); loginErr.textContent = 'Session expired. Sign in again.'; | |
| } else { | |
| toast(e.message, false); | |
| } | |
| } | |
| } | |
| async function refreshBackups() { | |
| refreshBtn.disabled = true; | |
| refreshBtn.textContent = 'Refreshing…'; | |
| try { | |
| const data = await api('/api/admin/backups'); | |
| const list = data.backups || []; | |
| if (!list.length) { | |
| backupList.innerHTML = '<div class="empty">No backups yet. The first hourly backup will appear soon.</div>'; | |
| return; | |
| } | |
| backupList.innerHTML = list.map(b => ` | |
| <div class="backup-row"> | |
| <div class="info"> | |
| <div class="name">${esc(b.category)} · ${esc(b.timestamp)}</div> | |
| <div class="meta">${b.file_count||0} files · ${fmt(b.size||0)}${b.has_database ? ' · with database' : ''}</div> | |
| </div> | |
| <div class="actions"> | |
| <button class="btn secondary restore-btn" data-path="${esc(b.path)}">Restore</button> | |
| <button class="btn danger delete-btn" data-path="${esc(b.path)}">Delete</button> | |
| </div> | |
| </div>`).join(''); | |
| } catch (e) { | |
| backupList.innerHTML = `<div class="empty">${esc(e.message)}</div>`; | |
| } finally { | |
| refreshBtn.disabled = false; | |
| refreshBtn.textContent = 'Refresh'; | |
| } | |
| } | |
| async function login(e) { | |
| e.preventDefault(); | |
| const username = usernameEl.value.trim(); | |
| const password = passwordEl.value; | |
| loginBtn.disabled = true; loginErr.textContent = ''; | |
| try { | |
| const data = await api('/api/admin/login', { | |
| method:'POST', | |
| body: JSON.stringify({ username, password }) | |
| }); | |
| setToken(data.token); | |
| passwordEl.value = ''; | |
| showDashboard(); | |
| toast('Signed in as ' + data.username, true); | |
| await Promise.all([refreshStatus(), refreshBackups()]); | |
| } catch (err) { | |
| loginErr.textContent = err.message; | |
| } finally { | |
| loginBtn.disabled = false; | |
| } | |
| } | |
| async function logout() { | |
| try { await api('/api/admin/logout', { method:'POST' }); } catch (e) {} | |
| setToken(''); | |
| showLogin(); | |
| } | |
| async function createBackup() { | |
| createBtn.disabled = true; | |
| createBtn.textContent = 'Creating…'; | |
| try { | |
| const data = await api('/api/admin/backups/create', { method:'POST' }); | |
| toast('Backup created at ' + data.path, true); | |
| await refreshBackups(); | |
| } catch (e) { toast(e.message, false); } | |
| finally { | |
| createBtn.disabled = false; | |
| createBtn.textContent = 'Create backup now'; | |
| } | |
| } | |
| function backupPath(btn) { return btn.dataset.path; } | |
| async function restore(e) { | |
| const btn = e.target.closest('.restore-btn'); | |
| if (!btn) return; | |
| if (!confirm('Restore this backup? This overwrites the live database and all restored files. Restart the server after restoring.')) return; | |
| btn.disabled = true; | |
| const old = btn.textContent; | |
| btn.textContent = 'Restoring…'; | |
| try { | |
| const path = backupPath(btn); | |
| const data = await api('/api/admin/backups/' + encodeURIComponent(path).replace(/%2F/g,'/') + '/restore', { method:'POST' }); | |
| toast('Restored ' + (data.files||0) + ' files. Restart the server to apply.', true); | |
| } catch (e) { toast(e.message, false); } | |
| finally { btn.disabled = false; btn.textContent = old; } | |
| } | |
| async function deleteBackup(e) { | |
| const btn = e.target.closest('.delete-btn'); | |
| if (!btn) return; | |
| if (!confirm('Delete this backup permanently? This cannot be undone.')) return; | |
| btn.disabled = true; | |
| try { | |
| const path = backupPath(btn); | |
| await api('/api/admin/backups/' + encodeURIComponent(path).replace(/%2F/g,'/'), { method:'DELETE' }); | |
| toast('Backup deleted', true); | |
| await refreshBackups(); | |
| } catch (e) { toast(e.message, false); } | |
| finally { btn.disabled = false; } | |
| } | |
| loginBtn.addEventListener('click', login); | |
| [usernameEl, passwordEl].forEach(el => el.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(e); })); | |
| logoutBtn.addEventListener('click', logout); | |
| refreshBtn.addEventListener('click', refreshBackups); | |
| createBtn.addEventListener('click', createBackup); | |
| backupList.addEventListener('click', (e) => { | |
| if (e.target.closest('.restore-btn')) restore(e); | |
| else if (e.target.closest('.delete-btn')) deleteBackup(e); | |
| }); | |
| (async function init() { | |
| if (!token()) { showLogin(); return; } | |
| try { | |
| await refreshStatus(); | |
| await refreshBackups(); | |
| showDashboard(); | |
| } catch (e) { | |
| if (e.message && /401|expired|authentication/i.test(e.message)) { | |
| setToken(''); showLogin(); loginErr.textContent = 'Session expired. Sign in again.'; | |
| } else { | |
| showLogin(); loginErr.textContent = e.message; | |
| } | |
| } | |
| })(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |