InfinityChat / static /admin.html
smodusermc's picture
Create admin.html
f5d05ea verified
Raw History Blame Contribute Delete
14.2 kB
<!DOCTYPE html>
<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 !important; }
.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
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>