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:
parent
6a95efb9bb
commit
a57b74700c
@ -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")
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user