Files
Project-SDE-WP-Suite/html/admin.html
n.siegfried 928ab8c900 Archive projects, auto-add default members, rebuild the admin console
Three things asked for together, plus the migration they share (a7c31f9e5b02 —
additive, with database defaults for existing rows, so unlike the users.role
rewrite it is safe under a code-only rollback).

ARCHIVE A PROJECT. A finished job leaves every picker, switcher and search, and
freezes read-only, without losing anything. Hiding is free: GET /api/projects
defaults to archived=exclude, so the home picker and the app-bar switcher drop it
without either of them changing. Freezing is require_project_writable(), which
every write that lands on a project now goes through — SOP and WP upserts (both
ends, so a package can be moved neither into nor out of an archived job), deletes,
issue, status, WP archive, and comments on its WPs/SOPs. It answers 409, not 403:
nobody lacks a permission, the project's state is the objection, and the browser
outbox in project-data.js retires 4xx ops instead of retrying them against a job
that will never accept them. Unarchive and delete stay allowed on purpose —
unarchive is the one write an archived project must take, and archive-then-delete
is a normal sequence.

DEFAULT MEMBERS ON NEW PROJECTS. users.auto_add_projects / auto_add_role flag the
people who belong on every job, so an admin says it once instead of remembering it
at each project creation. It runs on the is_new branch of upsert_project, which is
the single road into project creation, so the home page, the sample project and the
demo seeder are all covered and an update never re-runs it. Note the interaction
with the existing creator-grant: that row commits first and add_default_members
never overwrites an existing membership, so the creator grant now carries the
creator's own auto_add_role — otherwise someone flagged "Project Admin on every
job" would land as a plain member on the one job they started themselves.

ADMIN CONSOLE. The user table had outgrown .wrap{max-width:860px}: nine columns in
an 860px card meant every cell wrapped, so one user occupied a ~100px band, the
action buttons stacked, and the table spilled outside its own white card. Now
1240px, with wide tables scrolling inside .tscroll so the page itself never scrolls
sideways, and one spacing/control scale across all twelve cards. Truncation hangs
off a span inside the cell rather than max-width on the td, which table-layout:auto
treats as advisory — the usual reason cell ellipsis works in the stylesheet and not
on the page.

Found in review and fixed here rather than later:

- Stored XSS in the new Projects card, reachable by any signed-in user, landing in
  an admin's session. The uesc(v).replace(/'/g,"\'") idiom this file already used
  in eight places escapes in the wrong order — uesc leaves backslashes alone, so a
  stored name containing \' closes the JS string literal and the rest executes.
  jsq() does backslash, then quote, then HTML, and all thirteen handler bindings go
  through it. The same bug, unescaped entirely, was in the SOP builder's custom
  constraint names (escHandlerArg there). Three of seven test payloads escaped the
  literal under the old idiom — one of them a plain name ending in a backslash, so
  it was breaking buttons for innocent input too.
- _save_comment resolved wp_id and sop_id with if/elif but stored both, so a
  payload naming a WP you may touch and a SOP you may not was authorised on the WP
  alone and still wrote into the other project's thread. Both are checked now.
- Promoting an account to admin left its default-member flag set but invisible,
  ready to take effect again on demotion — cleared, as set_user_auto_add already
  does for the role.

smoketest.py and the console's own smoke test both assert the archive round trip:
out of the default list, present with archived=all, writes refused with 409, and
all of it undone by unarchiving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:48:43 -07:00

384 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Console — Work Package Suite</title>
<script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style>
/* ══ TOKENS ══════════════════════════════════════════════════════════════
The console is the only page in the suite that is mostly dense tables, so
it carries its own sheet. The palette, the square corners and the type are
Carbon's — the same ones theme-light.css sets — so it still reads as one
product with the rest of the suite. Two scales do all the spacing and all
the control sizing; nothing in here should invent its own. */
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --accent-hover:#0353e9; --accent-soft:#edf5ff;
--green:#198038; --green-bg:#defbe6;
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd;
--head-bg:#f4f4f4; --zebra:#fafafa; --row-hover:#eef0f2;
--mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s6:28px;
--ctl:32px; /* every button / input / select that sits in a form row */
--ctl-sm:26px; } /* every control that sits inside a table cell */
*{ box-sizing:border-box; }
body{ margin:0; font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
/* ══ PAGE ════════════════════════════════════════════════════════════════
1240px, not 860: the user table is nine columns wide and at 860 it spilled
straight out of its own white card. Wide enough for that table, still a
readable measure for the prose, which is capped separately. */
.wrap{ max-width:1240px; margin:0 auto; padding:var(--s6) var(--s5) 80px; }
h1{ font-size:20px; line-height:1.2; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; line-height:1.5; margin:0 0 var(--s3); max-width:96ch; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; white-space:nowrap; }
a.home:hover{ text-decoration:underline; }
/* ══ CARDS ═══════════════════════════════════════════════════════════════ */
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0;
padding:var(--s4) var(--s5) var(--s5); margin-bottom:var(--s4); }
/* One card header everywhere: small uppercase accent label on a hairline.
admin.js also emits h2 for sub-sections inside a card (Localization
defaults, Step views, Actions) with an inline margin-top — the same
treatment reads correctly as a divider there, so both get it. */
.card h2{ font-size:12px; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
color:var(--accent); margin:0 0 var(--s3); padding-bottom:var(--s2); border-bottom:1px solid var(--border); }
.wrap code{ font-family:var(--mono); font-size:.92em; background:var(--bg); padding:1px 4px; }
/* ══ CONTROLS ════════════════════════════════════════════════════════════
Every button, input and select in a form row is exactly --ctl tall, so a
toolbar is one clean band instead of a ragged one. */
button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-space:nowrap;
height:var(--ctl); padding:0 var(--s3); border-radius:0; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; }
button:disabled, button:disabled:hover{ color:var(--dim); border-color:var(--border); background:#fff; cursor:default; }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
button.primary:hover{ background:var(--accent-hover); border-color:var(--accent-hover); color:#fff; }
button.danger{ border-color:var(--red); color:var(--red); }
button.danger:hover{ background:var(--red-bg); border-color:var(--red); color:var(--red); }
.row{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
/* The filter / search / button strip at the top of a card. */
.toolbar{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; margin:0 0 var(--s3); }
.toolbar + .banner{ margin-top:0; }
.urow{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
/* Checkboxes are excluded: they are drawn by the platform and want none of a
text field's height, padding or border. */
.toolbar input:not([type=checkbox]), .toolbar select,
.urow input:not([type=checkbox]), .urow select{
height:var(--ctl); padding:0 var(--s2); font:inherit; font-size:13px; line-height:normal;
border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); }
.toolbar select, .urow select{ cursor:pointer; padding-right:var(--s1); }
.toolbar input:focus-visible, .toolbar select:focus-visible,
.urow input:focus-visible, .urow select:focus-visible{ outline:2px solid var(--accent); outline-offset:-2px; }
.toolbar > input{ flex:1 1 240px; min-width:150px; }
.urow input:not([type=checkbox]){ flex:1 1 140px; min-width:0; }
/* Inline checkbox + label, sized to sit on the same line as the buttons. */
.chk{ display:inline-flex; align-items:center; gap:var(--s2); height:var(--ctl); padding:0 var(--s1);
font-size:13px; color:var(--muted); white-space:nowrap; cursor:pointer; }
.chk input{ width:16px; height:16px; margin:0; accent-color:var(--accent); cursor:pointer; }
/* ══ FEEDBACK: banners, notes, console output, key/value ═════════════════ */
.banner{ margin:var(--s3) 0 0; padding:9px var(--s3); border-radius:0; font-size:13px; font-weight:600;
line-height:1.4; border:1px solid var(--border); border-left:3px solid var(--border-strong);
background:var(--surface); color:var(--text); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:#a7f0ba; border-left-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:#ffd7d9; border-left-color:var(--red); }
/* These three start life as empty divs that admin.js fills on demand, so they
only earn their gap once they are actually saying something. */
#users-banner:not(:empty), #projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
/* --muted, not --dim: #8d8d8d on white is 3.3:1, under the 4.5:1 floor at 12px,
and #features-box / #settings-box are themselves .note — their primary toggle
labels inherit this colour. */
.note{ font-size:12px; line-height:1.55; color:var(--muted); margin-top:var(--s2); }
.note strong, .note em{ color:var(--text); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:var(--s3) var(--s4); font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:var(--s3) 0 0; }
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
table.kv{ border-collapse:collapse; font-size:13px; margin-top:var(--s2); }
table.kv th{ text-align:left; padding:var(--s1) var(--s5) var(--s1) 0; color:var(--muted); font-weight:600; white-space:nowrap; }
table.kv td{ padding:var(--s1) 0; font-variant-numeric:tabular-nums; font-weight:700; color:var(--text); }
/* ══ DATA TABLES ═════════════════════════════════════════════════════════
table.users is the name admin.js already emits; table.grid is the same
object under the shared name. One rule set serves both, so existing markup
picks up the dense styling without being rewritten. border-collapse is
separate rather than collapse because a collapsed border does not travel
with a sticky header. */
table.grid, table.users{ width:100%; border-collapse:separate; border-spacing:0;
font-size:13px; color:var(--text); background:var(--surface); }
table.grid th, table.users th{ position:sticky; top:0; z-index:2; background:var(--head-bg);
text-align:left; padding:var(--s2) var(--s3); white-space:nowrap;
font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted);
box-shadow:inset 0 -1px 0 var(--border); }
/* Cells never wrap: a wrapped cell turns one user into a 100px tall band and
the table stops reading as rows. Anything genuinely long truncates (.ell)
or is exempted by name further down. */
table.grid td, table.users td{ padding:var(--s1) var(--s3); border-bottom:1px solid var(--border);
vertical-align:middle; white-space:nowrap; }
table.grid tbody tr:last-child td, table.users tbody tr:last-child td{ border-bottom:none; }
table.grid tbody tr:nth-child(even) td, table.users tbody tr:nth-child(even) td{ background:var(--zebra); }
/* A neutral hover, not --accent-soft: that is .tag.admin's fill, and an "all
projects" pill sitting on its own colour disappears the moment you hover it. */
table.grid tbody tr:hover td, table.users tbody tr:hover td{ background:var(--row-hover); }
/* Truncation has to hang off a block INSIDE the cell. max-width on a <td> is
advisory under table-layout:auto — the cell just grows to fit and the ellipsis
never appears, which is the usual reason this trick looks like it works in the
stylesheet and doesn't on the page. admin.js emits <td class="ell"><span>. */
.ell{ max-width:240px; }
.ell > span{ display:block; max-width:240px; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; }
/* Every action cell admin.js renders is a .cellactions, and it must not wrap:
unwrapped, the three buttons stack and the row grows fourfold — which is what
the console looked like before this pass. */
.cellactions{ display:flex; flex-wrap:nowrap; align-items:center; gap:var(--s1); white-space:nowrap; }
/* Controls that live in a cell are one step smaller, which is what keeps a
row at ~34px instead of ~100px. .chk is form-row sized by default, so it needs
saying again here or the default-members rows stand 6px taller than the rest. */
button.mini{ height:var(--ctl-sm); padding:0 var(--s2); font-size:12px; }
table.grid td .chk, table.users td .chk{ height:var(--ctl-sm); }
select.role-select{ height:var(--ctl-sm); max-width:170px; padding:0 var(--s1) 0 var(--s2);
font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0;
background:#fff; color:var(--text); cursor:pointer; }
select.role-select:hover{ border-color:var(--accent); }
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:600; }
.tag{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600;
line-height:1.55; white-space:nowrap; vertical-align:middle; }
.tag.admin{ background:var(--accent-soft); color:var(--accent); }
.tag.user{ background:#e8e8e8; color:var(--muted); }
.tag.on{ background:var(--green-bg); color:var(--green); }
.tag.off{ background:var(--red-bg); color:var(--red); }
.tag.archived{ background:var(--amber-bg); color:var(--amber); }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; white-space:nowrap; }
/* A wide table scrolls inside its own box so the page never scrolls sideways,
and the capped height is what gives the sticky header something to do. Every
container admin.js paints a table into is one, so a sticky header always has
a scrollport of its own rather than sliding up behind the app bar. */
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
#projects-table, #defmem-table{
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside
so one table never ends up with two scrollbars. */
#users-table:has(.tscroll), #comments-admin:has(.tscroll), #audit-admin:has(.tscroll),
#notif-box:has(.tscroll), #usage-admin:has(.tscroll), #projects-table:has(.tscroll),
#defmem-table:has(.tscroll){
overflow:visible; max-height:none; }
/* Column exceptions, addressed by card because admin.js emits these tables
without per-cell classes. Email is the one user cell long enough to stretch
a row, so it truncates; comment text and audit detail are the two columns
you are actually here to read, so they wrap inside a sane width instead. */
#users-table table.users td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
#comments-admin table.users td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
#audit-admin table.users td:nth-child(6){ white-space:normal; max-width:420px; }
/* ══ GATES & WARNINGS ════════════════════════════════════════════════════ */
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:var(--s5); z-index:9999; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:var(--s6); max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 var(--s1); padding:0; border:0; font-size:17px; text-transform:none; letter-spacing:0; color:var(--text); }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 var(--s4); }
.gate-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:var(--s2); }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:var(--s4); }
/* The denial notice is a sentence, not a table — don't stretch it to 1240px. */
#admin-denied .card{ max-width:560px; }
/* ══ NARROW SCREENS ══════════════════════════════════════════════════════
The page itself must never scroll sideways; the wide tables scroll inside
their own box instead, and there they get the full page height to do it. */
@media (max-width:900px){
.wrap{ padding:var(--s4) var(--s3) 60px; }
.card{ padding:var(--s3) var(--s4) var(--s4); }
.toolbar > input{ flex:1 1 100%; }
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
#projects-table, #defmem-table{ max-height:none; }
}
@media (max-width:620px){
.urow input, .urow select, .urow button{ flex:1 1 100%; }
}
</style>
</head>
<body>
<!-- SHARED DARK APP BAR -->
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Back to site">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| Admin Console</span></span>
</a>
</header>
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
<div class="wrap" id="admin-denied" style="display:none">
<div class="card">
<h2>Admins only</h2>
<p class="sub">Your account doesnt have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
</div>
</div>
<!-- CONSOLE -->
<div class="wrap" id="admin-main" style="display:none">
<div class="row" style="justify-content:space-between; margin-bottom:var(--s5)">
<div><h1>Admin Console</h1><div class="sub" style="margin:0">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a></div>
</div>
<!-- CONNECTIVITY -->
<div class="card">
<h2>API connectivity</h2>
<div class="toolbar"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
<div class="banner" id="health-banner"></div>
</div>
<!-- USER ADMINISTRATION -->
<div class="card">
<h2>User administration</h2>
<div class="sub">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
<div class="toolbar"><button onclick="loadUsers()">Refresh users</button></div>
<div id="users-banner"></div>
<div id="users-table"></div>
<h2 style="margin-top:var(--s6)">Add a user</h2>
<div class="urow">
<input id="nu-username" placeholder="Username *" autocomplete="off">
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
<input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role" title="Permissions — what this account may do">
<option value="project_user">Project User</option>
<option value="project_admin">Project Admin</option>
<option value="admin">Administrator</option>
</select>
<select id="nu-project-role" title="Job function on the project"></select>
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
<button class="primary" onclick="createUser()">Create user</button>
</div>
<div id="users-create-msg" class="note"></div>
</div>
<!-- PROJECTS (ARCHIVE / UNARCHIVE) -->
<div class="card">
<h2>Projects</h2>
<div class="sub">Archiving a project hides it from every picker, switcher and search, and freezes it
read-only — nothing is deleted and every work package, SOP and comment is kept exactly as it is.
Unarchive here to bring it back; the project returns unchanged.</div>
<div class="toolbar">
<button onclick="loadProjects()">Refresh projects</button>
<label class="chk"><input type="checkbox" id="proj-show-archived" onchange="renderProjects()"> Show archived</label>
<input id="proj-search" placeholder="Search name / number / client…" oninput="renderProjects()">
</div>
<div id="projects-banner"></div>
<div id="projects-table"><div class="note">Click “Refresh projects” to load.</div></div>
</div>
<!-- DEFAULT MEMBERS ON NEW PROJECTS -->
<div class="card">
<h2>Default members on new projects</h2>
<div class="sub">Everyone flagged here is added automatically to every project created from now on,
with the role chosen here. It does not touch projects that already exist — for those, use
<strong>Project access</strong> in the user table above. Administrators are listed with nothing
to set: they already reach every project.</div>
<div class="toolbar"><button onclick="loadDefaultMembers()">Refresh</button></div>
<div id="defmem-banner"></div>
<div id="defmem-table"><div class="note">Click “Refresh” to load.</div></div>
</div>
<!-- FEATURE FLAGS -->
<div class="card">
<h2>Features</h2>
<div class="sub">Switches that change what the suite offers on every project.</div>
<div id="features-box" class="note">Loading…</div>
</div>
<!-- NOTIFICATIONS / EMAIL -->
<div class="card">
<h2>Notifications &amp; email</h2>
<div class="sub">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div id="settings-box" class="note">Loading…</div>
<div id="notif-box" class="note" style="margin-top:14px"></div>
</div>
<!-- ALL FEEDBACK / COMMENTS -->
<div class="card">
<h2>All feedback &amp; comments</h2>
<div class="sub">Every comment submitted across the suite — who wrote it, what they said, and where they were (page &amp; step) when they commented.</div>
<div class="toolbar">
<button onclick="loadComments()">Refresh comments</button>
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()">
</div>
<div id="comments-admin" class="note">Click refresh to load.</div>
</div>
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
<div class="card">
<h2>Activity log</h2>
<div class="sub">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
<div class="toolbar">
<button onclick="loadAudit()">Refresh</button>
<select id="audit-type" onchange="renderAudit()">
<option value="">All types</option>
<option value="wp">Work packages</option>
<option value="sop">SOPs</option>
<option value="project">Projects</option>
<option value="user">User accounts</option>
</select>
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()">
</div>
<div id="audit-admin" class="note">Click refresh to load.</div>
</div>
<!-- USAGE LOGS -->
<div class="card">
<h2>Usage logs</h2>
<div class="sub">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
<div class="toolbar">
<button onclick="loadUsage()">Refresh</button>
<button onclick="downloadUsage()">Download JSON</button>
</div>
<div id="usage-admin" class="note">Click refresh to load.</div>
</div>
<!-- DB SNAPSHOT -->
<div class="card">
<h2>Database snapshot</h2>
<div class="toolbar"><button onclick="snapshot()">Refresh counts</button></div>
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
</div>
<!-- SMOKE TEST -->
<div class="card">
<h2>End-to-end smoke test</h2>
<div class="sub">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
<div class="toolbar"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
<pre class="out" id="smoke-out">Ready.</pre>
</div>
<!-- DEMO DATA -->
<div class="card">
<h2>Demo data</h2>
<div class="sub">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
<div class="toolbar">
<button class="primary" onclick="seedDemo()">Seed demo project</button>
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
</div>
<pre class="out" id="demo-out">Ready.</pre>
<div class="note">Note: the seeded <strong>project</strong> appears in the home picker; its SOP/WPs live in SQL but won't render in the Creator/Dashboard until the front end is wired to the API (Phase 2).</div>
</div>
</div>
<script src="admin.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>