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)
loading…
'; + 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

'+(rows||'')+'
TableSizeRows (est.)
β€”
'; +} + // ---------- Connections ---------- async function vConn(){ const m = $('#content');