feat(admin): System tab — read-only Postgres health (db size/version/uptime/conns/cache-hit, largest tables, data freshness)

Admin-only /admin/system; no maintenance buttons (PG autovacuums, infra owns backups).
The useful 10% of WowPlatter's System page; the WP cache/transients/WP-CLI/debug rest dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-23 18:44:44 +10:00
parent 6a95efb9bb
commit a57b74700c
2 changed files with 48 additions and 2 deletions

View File

@ -606,6 +606,31 @@ async def report_stock(dim: str, ident=Depends(require_token), db=Depends(get_db
return {"dim": dim, "rows": [dict(r) for r in (await db.execute(text(_STOCK[dim]))).mappings()]}
# ── System — read-only Postgres health (admin). No maintenance buttons: PG autovacuums, infra owns backups.
@router.get("/system")
async def system(ident=Depends(require_admin), db=Depends(get_db)):
info = dict((await db.execute(text("""
SELECT pg_database_size(current_database()) AS db_bytes,
split_part(version(), ' ', 2) AS pg_version,
extract(epoch FROM (now() - pg_postmaster_start_time()))::bigint AS uptime_s,
(SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()) AS conns,
(SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND state = 'active') AS active,
(SELECT round(sum(blks_hit) * 100.0 / nullif(sum(blks_hit + blks_read), 0), 1)
FROM pg_stat_database WHERE datname = current_database()) AS cache_hit
"""))).mappings().first())
tables = [dict(r) for r in (await db.execute(text("""
SELECT relname AS name, pg_total_relation_size(relid) AS bytes, n_live_tup AS rows
FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 15
"""))).mappings()]
fresh = dict((await db.execute(text("""
SELECT (SELECT max(updated_at) FROM inventory) AS inventory_updated,
(SELECT max(sale_date) FROM sales) AS last_sale,
(SELECT count(*) FROM inventory) AS inventory_rows,
(SELECT count(*) FROM disc_release) AS releases
"""))).mappings().first())
return {"db": info, "tables": tables, "fresh": fresh}
@router.get("/orders")
async def orders(ident=Depends(require_token), db=Depends(get_db)):
base = await vault.get_secret(db, "woo_base_url")

View File

@ -77,6 +77,7 @@
<div class="sec" data-admin>System</div>
<a data-v="staff" data-admin>🧑‍💼 Staff</a>
<a data-v="connections" data-admin>🔌 Connections</a>
<a data-v="system" data-admin>🖥️ System</a>
<a href="/store/">🎧 Virtual store ↗</a>
<a href="/">← Home</a>
</nav>
@ -105,9 +106,9 @@ async function signin(){
document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.dataset.v));
function go(v){
if(ROLE!=='admin' && (v==='connections'||v==='staff')) v='dashboard'; // staff can't reach admin views
if(ROLE!=='admin' && (v==='connections'||v==='staff'||v==='system')) v='dashboard'; // staff can't reach admin views
document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v));
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn, staff:vStaff}[v])();
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn, staff:vStaff, system:vSystem}[v])();
}
// ---------- Dashboard (fast tiles first, charts lazy) ----------
@ -473,6 +474,26 @@ async function staffActive(id,active){ await fetch('/admin/staff/'+id,{method:'P
async function staffReset(id){ if(!confirm('Reset this token? Their current one stops working immediately.')) return;
const d=await fetch('/admin/staff/'+id+'/token',{method:'POST',headers:hdr()}).then(r=>r.json()); $('#stNew').innerHTML=staffToken('Reset',d.token); }
// ---------- System (read-only Postgres health) ----------
function fmtBytes(b){ b=+b||0; const u=['B','KB','MB','GB','TB']; let i=0; while(b>=1024&&i<u.length-1){b/=1024;i++;} return b.toFixed(b<10&&i>0?1:0)+' '+u[i]; }
function fmtUptime(s){ s=+s||0; const d=Math.floor(s/86400), h=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d?`${d}d ${h}h`:(h?`${h}h ${m}m`:`${m}m`); }
function ago(t){ if(!t) return '—'; const s=(Date.now()-new Date(t).getTime())/1000; if(s<60)return 'just now'; if(s<3600)return Math.floor(s/60)+'m ago'; if(s<86400)return Math.floor(s/3600)+'h ago'; return Math.floor(s/86400)+'d ago'; }
async function vSystem(){
$('#content').innerHTML = '<h2>System</h2><div class="muted">Read-only Postgres health · maintenance is handled by infra (autovacuum + scheduled backups)</div><div id="sys" style="margin-top:10px"><div class="muted">loading…</div></div>';
const d = await api('/system'), db = d.db, f = d.fresh;
const tiles = kpi(fmtBytes(db.db_bytes),'database size',true) + kpi('PG '+escapeH(db.pg_version||'?'),'postgres')
+ kpi(fmtUptime(db.uptime_s),'uptime') + kpi(db.active+' / '+db.conns,'active / conns')
+ kpi(db.cache_hit==null?'—':db.cache_hit+'%','cache hit',true)
+ kpi(Number(f.inventory_rows).toLocaleString(),'inventory rows') + kpi(Number(f.releases).toLocaleString(),'releases');
const rows = (d.tables||[]).map(t=>`<tr><td>${escapeH(t.name)}</td><td class="price">${fmtBytes(t.bytes)}</td><td>${Number(t.rows).toLocaleString()}</td></tr>`).join('');
$('#sys').innerHTML = '<div class="kpis">'+tiles+'</div>'
+ '<div class="card"><h3>Data freshness</h3><table><tbody>'
+ `<tr><td>Last sale</td><td class="price">${ago(f.last_sale)}</td><td class="muted">${f.last_sale?String(f.last_sale).slice(0,16).replace('T',' '):'—'}</td></tr>`
+ `<tr><td>Inventory last updated</td><td class="price">${ago(f.inventory_updated)}</td><td class="muted">${f.inventory_updated?String(f.inventory_updated).slice(0,16).replace('T',' '):'—'}</td></tr>`
+ '</tbody></table></div>'
+ '<div class="card"><h3>Largest tables</h3><table><thead><tr><th>Table</th><th>Size</th><th>Rows (est.)</th></tr></thead><tbody>'+(rows||'<tr><td colspan=3 class=muted></td></tr>')+'</tbody></table></div>';
}
// ---------- Connections ----------
async function vConn(){
const m = $('#content');