diff --git a/app/admin_routes.py b/app/admin_routes.py
index 00d667f..aeb9416 100644
--- a/app/admin_routes.py
+++ b/app/admin_routes.py
@@ -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")
diff --git a/site/admin.html b/site/admin.html
index 8a7136a..372dc18 100644
--- a/site/admin.html
+++ b/site/admin.html
@@ -77,6 +77,7 @@
System
π§βπΌ Staff
π Connections
+ π₯οΈ System
π§ Virtual store β
β Home
@@ -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&&i0?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 = 'System
Read-only Postgres health Β· maintenance is handled by infra (autovacuum + scheduled backups)
';
+ 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=>`| ${escapeH(t.name)} | ${fmtBytes(t.bytes)} | ${Number(t.rows).toLocaleString()} |
`).join('');
+ $('#sys').innerHTML = ''+tiles+'
'
+ + 'Data freshness
'
+ + `| Last sale | ${ago(f.last_sale)} | ${f.last_sale?String(f.last_sale).slice(0,16).replace('T',' '):'β'} |
`
+ + `| Inventory last updated | ${ago(f.inventory_updated)} | ${f.inventory_updated?String(f.inventory_updated).slice(0,16).replace('T',' '):'β'} |
`
+ + '
'
+ + 'Largest tables
| Table | Size | Rows (est.) |
'+(rows||'| β |
')+'
';
+}
+
// ---------- Connections ----------
async function vConn(){
const m = $('#content');