@reddoorla/maintenance 0.60.0 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -737,6 +737,7 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
737
737
  .rf-status { margin-top:0.6rem; font-size:0.85rem; }
738
738
  .rf-row { padding:0.1rem 0; }
739
739
  .rf-row a { margin-left:0.3rem; }
740
+ .rf-sub { color:#999; font-size:0.8rem; margin-left:1.1rem; }
740
741
  .rf-spin { display:inline-block; width:0.8em; height:0.8em; border:2px solid #999; border-top-color:transparent; border-radius:50%; animation:rf-spin 0.8s linear infinite; vertical-align:-0.1em; }
741
742
  @keyframes rf-spin { to { transform:rotate(360deg); } }
742
743
  details.tier { margin:0.75rem 0; }
@@ -940,16 +941,45 @@ var FILTER_SCRIPT = `<script>
940
941
  var RF_MAX_MS = 90 * 60 * 1000; // safety ceiling; a full fleet Lighthouse run was ~48 min (2026-06-24)
941
942
  function rfPanel(){ return document.getElementById('rf-status'); }
942
943
  function rfStop(){ try { localStorage.removeItem(RF_KEY); } catch(e){} }
943
- // Safe to build raw HTML: workflow/state are server-fixed enums and url is GitHub's
944
- // own html_url for our central repo \u2014 none are user-supplied. Don't interpolate
945
- // untrusted fields here without escaping.
946
- function rfRender(status){
944
+ // Per-workflow ETA hint (the lighthouse fleet sweep runs ~48 min; security ~1-2 min).
945
+ function rfEta(label){ return label === 'lighthouse' ? '~48m' : label === 'security' ? '~2m' : ''; }
946
+ // Map a raw GitHub step name to a coarse human phase. Order matters (the audit step
947
+ // name also contains 'browser'; 'playwright install' also contains 'install').
948
+ function rfPhase(step){
949
+ if (!step) return '';
950
+ var s = step.toLowerCase();
951
+ if (s.indexOf('audit') !== -1 || s.indexOf('lighthouse') !== -1) return 'auditing the fleet\u2026';
952
+ if (s.indexOf('build') !== -1) return 'building\u2026';
953
+ if (s.indexOf('playwright') !== -1 || s.indexOf('browser') !== -1) return 'installing browsers\u2026';
954
+ if (s.indexOf('install') !== -1 || s.indexOf('depend') !== -1) return 'installing dependencies\u2026';
955
+ if (s.indexOf('set up') !== -1 || s.indexOf('checkout') !== -1) return 'setting up\u2026';
956
+ return step;
957
+ }
958
+ // Safe to build raw HTML: workflow/state/step are server-fixed (enums + GitHub step
959
+ // names) and url is GitHub's own html_url for our central repo \u2014 none are
960
+ // user-supplied. Don't interpolate untrusted fields here without escaping.
961
+ function rfRender(status, startedAt){
947
962
  var failed = function(s){ return s === 'failure' || s === 'cancelled' || s === 'timed_out'; };
963
+ var mins = Math.floor((Date.now() - startedAt) / 60000);
964
+ var elapsed = mins < 1 ? '<1m' : mins + 'm';
948
965
  return status.perWorkflow.map(function(w){
949
966
  var label = w.workflow.replace('.yml','').replace('fleet-','');
950
- var icon = w.state === 'success' ? '\u2713' : failed(w.state) ? '\u2717' : '<span class="rf-spin"></span>';
951
- var link = (failed(w.state) && w.url) ? ' <a href="'+w.url+'" target="_blank" rel="noopener">run</a>' : '';
952
- return '<div class="rf-row">'+icon+' '+label+' \u2014 '+w.state.replace('_',' ')+link+'</div>';
967
+ var done = w.state === 'success';
968
+ var isFailed = failed(w.state);
969
+ var icon = done ? '\u2713' : isFailed ? '\u2717' : '<span class="rf-spin"></span>';
970
+ var line = icon + ' ' + label + ' \u2014 ' + w.state.replace('_',' ');
971
+ if (!done && !isFailed){
972
+ var phase = rfPhase(w.step);
973
+ var eta = rfEta(label);
974
+ var detail = [];
975
+ if (phase) detail.push(phase);
976
+ detail.push(elapsed + (eta ? ' / ' + eta : ''));
977
+ var link = w.url ? ' \xB7 <a href="'+w.url+'" target="_blank" rel="noopener">view run \u2197</a>' : '';
978
+ line += '<div class="rf-sub">' + detail.join(' \xB7 ') + link + '</div>';
979
+ } else if (isFailed && w.url){
980
+ line += ' <a href="'+w.url+'" target="_blank" rel="noopener">run</a>';
981
+ }
982
+ return '<div class="rf-row">' + line + '</div>';
953
983
  }).join('');
954
984
  }
955
985
  function rfPoll(since, startedAt){
@@ -964,7 +994,7 @@ var FILTER_SCRIPT = `<script>
964
994
  rfStop(); return;
965
995
  }
966
996
  if (data && data.status){
967
- if (p) p.innerHTML = rfRender(data.status);
997
+ if (p) p.innerHTML = rfRender(data.status, startedAt);
968
998
  if (data.status.allDone){
969
999
  if (!data.status.anyFailure){
970
1000
  if (p) p.innerHTML += '<div class="rf-row">\u2713 Done \u2014 reloading\u2026</div>';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/recipes/index.ts","../src/dashboard/relative-time.ts","../src/dashboard/favicon.ts","../src/dashboard/onboarding.ts","../src/dashboard/submission-view.ts","../src/dashboard/site-details.ts","../src/dashboard/render.ts","../src/dashboard/fleet-render.ts","../src/dashboard/basic-auth.ts"],"sourcesContent":["import type { RecipeName } from \"../types.js\";\nimport { syncConfigs, type SyncConfigsOptions } from \"./sync-configs.js\";\nimport { bumpDeps, type BumpDepsOptions } from \"./bump-deps.js\";\nimport { upgradeSvelte4to5, type UpgradeSvelte4to5Options } from \"./svelte-5/index.js\";\nimport { svelteCodemods } from \"./svelte-codemods.js\";\nimport { convertToPnpm, type ConvertToPnpmOptions } from \"./convert-to-pnpm.js\";\nimport { onboard, type OnboardOptions, type OnboardAudit } from \"./onboard.js\";\nimport { a11yFixturesPage } from \"./a11y-fixtures-page/index.js\";\nimport {\n init,\n DEFAULT_INIT_STEPS,\n type InitOptions,\n type InitResult,\n type InitStep,\n type InitStepResult,\n} from \"./init.js\";\n\nexport {\n syncConfigs,\n bumpDeps,\n upgradeSvelte4to5,\n svelteCodemods,\n convertToPnpm,\n onboard,\n a11yFixturesPage,\n init,\n DEFAULT_INIT_STEPS,\n};\nexport type {\n SyncConfigsOptions,\n BumpDepsOptions,\n UpgradeSvelte4to5Options,\n ConvertToPnpmOptions,\n OnboardOptions,\n OnboardAudit,\n InitOptions,\n InitResult,\n InitStep,\n InitStepResult,\n};\n\nexport const ALL_RECIPE_NAMES: RecipeName[] = [\n \"sync-configs\",\n \"bump-deps\",\n \"svelte-4-to-5\",\n \"svelte-codemods\",\n \"convert-to-pnpm\",\n \"onboard\",\n \"a11y-fixtures-page\",\n \"self-updating\",\n \"init\",\n];\n\nexport function isRecipeName(value: string): value is RecipeName {\n return (ALL_RECIPE_NAMES as string[]).includes(value);\n}\n","/** Render an absolute timestamp as a coarse \"Xd ago\" relative string for the\n * fleet card. Takes an explicit `now` for testability; defaults to wall clock\n * for callers (the Netlify function). Returns \"—\" for null / unparseable. */\nexport function relativeTimeFromNow(iso: string | null, now: Date = new Date()): string {\n if (!iso) return \"—\";\n const t = Date.parse(iso);\n if (Number.isNaN(t)) return \"—\";\n\n const seconds = Math.max(0, Math.floor((now.getTime() - t) / 1000));\n if (seconds < 60) return \"just now\";\n\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes}m ago`;\n\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return `${hours}h ago`;\n\n const days = Math.floor(hours / 24);\n if (days < 7) return `${days}d ago`;\n\n const weeks = Math.floor(days / 7);\n if (weeks < 4) return `${weeks}w ago`;\n\n const months = Math.floor(days / 30);\n return `${months}mo ago`;\n}\n","// The reddoor mark (32×32 PNG, ~554 B) inlined as a data-URI favicon. The\n// dashboard pages are rendered by Netlify functions with no static-asset\n// pipeline, so embedding the icon in the <head> brands every page without a\n// second request or a hosted file. Source: reddoor-website/static/favicon.png.\nconst REDDOOR_FAVICON_PNG_BASE64 =\n \"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAACXBIWXMAAAAnAAAAJwEqCZFPAAAAk1BMVEVHcEzkGTfjGDbjGTfjGTfjGTfjGTfjGDbjGDbjGTfjGTfjGTfkGTfjGTfjGDbjGDbkGTfjGDbjGDbjGTfjGTfjGDbjGTfjGDbjGTfjGDbjGTfjGTfjGDbjGDbjGDbjGDbjGTfjGTbjGDbjGDbjGDbjGDbjGTfjGTfjGTfjGDbjGDbjGTfjGDbjGDbjGTfjGTbkGTfxxbwzAAAALXRSTlMA4DABMOD8cOBwDltwi+bFLMlk54/BDVPk/I61/u2cFaBw9nqO1izBIQE+Becdo6eEAAABBElEQVQ4y91SCVYCMQwt4kxbcEHFfUFFcMGf9P6nM0kXEPEAmtdJs+cnU+f+He2fIIEAYuVgkmvDfTG9Rh+6LoRBUBqE7gi09l9eAeeTrZJoFd5uQWfj4VbPvRqwmj9zfzP6CYpyi48F6HW5A3WuMHsEPsfvO8cSkMPTe+pfRr/MTckdg+4enqL3PkYf/YFIqiiP8VAw6EZkH6QEO0xJLUhmdbY5+TjHkCVohprlcpzlnJw9GlNkrZC16uCmWIaAZItNrT4xV0T2yxrIWoNKy4rdVXOq0MycWp4r43FOMQWciibMYaOHCXKSPhapqTupGFAnwHoVuUUZq7jaSkqDb0/uD9MXqvJMDtU7lL0AAAAASUVORK5CYII=\";\n\n/** A ready-to-interpolate `<link rel=\"icon\">` carrying the reddoor mark. */\nexport const FAVICON_LINK = `<link rel=\"icon\" type=\"image/png\" href=\"data:image/png;base64,${REDDOOR_FAVICON_PNG_BASE64}\" />`;\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\n\nexport type OnboardingStatus = {\n score: number;\n total: 4;\n checks: {\n firstAudit: boolean;\n recipients: boolean;\n schedule: boolean;\n poc: boolean;\n };\n};\n\nfunction isNonEmpty(s: string | null | undefined): boolean {\n return typeof s === \"string\" && s.trim().length > 0;\n}\n\n/** Four-point onboarding signal for the fleet card. A site is \"fully onboarded\"\n * when it has been audited at least once, has a To-recipient for monthly\n * reports, has a maintenance schedule that isn't \"None\", and has a named POC. */\nexport function onboardingStatus(row: WebsiteRow): OnboardingStatus {\n const checks = {\n firstAudit: isNonEmpty(row.lastLighthouseAuditAt),\n recipients: isNonEmpty(row.reportRecipientsTo),\n schedule: row.maintenanceFreq !== \"None\",\n poc: isNonEmpty(row.pointOfContact),\n };\n const score = Object.values(checks).filter(Boolean).length;\n return { score, total: 4, checks };\n}\n\n/** Human label for each onboarding check, in canonical check order. Used by the\n * dashboards to spell out which signals a partially-onboarded site is missing\n * (cockpit setup-chip tooltip + per-site setup line). */\nexport const ONBOARDING_LABELS: Record<keyof OnboardingStatus[\"checks\"], string> = {\n firstAudit: \"First audit\",\n recipients: \"Report recipients\",\n schedule: \"Maintenance schedule\",\n poc: \"Point of contact\",\n};\n\n/** The labels of the onboarding checks this site has NOT satisfied, in check\n * order. Empty array → fully onboarded. */\nexport function missingOnboarding(row: WebsiteRow): string[] {\n const { checks } = onboardingStatus(row);\n return (Object.keys(ONBOARDING_LABELS) as Array<keyof typeof ONBOARDING_LABELS>)\n .filter((key) => !checks[key])\n .map((key) => ONBOARDING_LABELS[key]);\n}\n","import type { SubmissionRow } from \"../reports/submission-row.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\n\n/** Render a submission's `extraFields` JSON as a key/value list; on parse failure\n * show the raw string (escaped) rather than dropping it. Returns \"\" when blank. */\nfunction extraFieldsList(raw: string | null): string {\n if (!raw || raw.trim() === \"\") return \"\";\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return `<div class=\"subm-kv\"><span class=\"k\">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return `<div class=\"subm-kv\"><span class=\"k\">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;\n }\n const rows = Object.entries(parsed as Record<string, unknown>)\n .map(\n ([k, v]) =>\n `<div class=\"subm-kv\"><span class=\"k\">${escapeHtml(k)}</span> ${escapeHtml(String(v))}</div>`,\n )\n .join(\"\");\n return rows;\n}\n\nexport function renderSubmissionRow(s: SubmissionRow): string {\n const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : \"—\";\n const type = escapeHtml(s.formType);\n const who = escapeHtml(s.name || \"(no name)\");\n const email = escapeHtml(s.email || \"\");\n const status = escapeHtml(s.status);\n const id = escapeHtml(s.id);\n const url = `/api/submissions/${encodeURIComponent(s.id)}/status`;\n const btn = (label: string, action: string) =>\n `<button class=\"subm-status\" data-id=\"${id}\" data-status=\"${action}\" data-url=\"${url}\">${label}</button>`;\n\n // One detail row per present field; absent fields are omitted (no blank rows).\n const kv = (label: string, value: string | number | null) =>\n value === null || value === \"\"\n ? \"\"\n : `<div class=\"subm-kv\"><span class=\"k\">${label}</span> ${escapeHtml(String(value))}</div>`;\n const sourceLink = s.sourceUrl\n ? `<div class=\"subm-kv\"><span class=\"k\">Source</span> <a href=\"${escapeHtml(safeUrl(s.sourceUrl))}\" rel=\"noopener noreferrer\">${escapeHtml(s.sourceUrl)}</a></div>`\n : \"\";\n const messageBlock = s.message\n ? `<div class=\"subm-kv\"><span class=\"k\">Message</span></div><div class=\"subm-msg\">${escapeHtml(s.message)}</div>`\n : \"\";\n const details = [\n kv(\"Phone\", s.phone),\n messageBlock,\n sourceLink,\n kv(\"UTM\", s.utm),\n extraFieldsList(s.extraFields),\n kv(\"Notify\", s.notifyStatus),\n kv(\"Resend ID\", s.resendMessageId),\n kv(\"Submission #\", s.submissionId),\n ].join(\"\");\n\n return `<li class=\"subm-item\">\n <details>\n <summary class=\"subm-head\"><strong>${type}</strong> · ${who} <span class=\"muted\">${email}</span> <span class=\"pill subm-${status}\">${status}</span> <span class=\"muted\">${when}</span></summary>\n <div class=\"subm-detail\">${details}</div>\n </details>\n <div class=\"subm-actions\">${btn(\"Read\", \"read\")}${btn(\"Archive\", \"archived\")}${btn(\"Spam\", \"spam\")}</div>\n </li>`;\n}\n\n/** CSS rules for the submission list UI. Append to the page's <style> block. */\nexport const SUBMISSION_STYLES = `.subm-list { list-style: none; padding: 0; margin: 0; }\n.subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { .subm-item { border-color: #2a2a2a; } }\n.subm-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }\n.subm-msg { margin: 0.35rem 0; white-space: pre-wrap; }\n.subm-detail { padding: 0.35rem 0 0.2rem; }\n.subm-kv { font-size: 0.9rem; margin: 0.15rem 0; }\n.subm-kv .k { color: #888; margin-right: 0.4rem; }\nsummary.subm-head { cursor: pointer; }\n.subm-actions { display: flex; gap: 0.4rem; }\nbutton.subm-status { font: inherit; padding: 0.25rem 0.7rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }\nbutton.subm-status:disabled { opacity: 0.6; cursor: default; }\n.spam-screen .spam-kv { font-size: 0.95rem; margin: 0.2rem 0; }\n.spam-screen .spam-kv .k { color: #888; display: inline-block; min-width: 11rem; }\n.pill.subm-new { background: #e8f0fe; color: #1a56db; }\n.pill.subm-read { background: #f0f0f0; color: #555; }\n.pill.subm-archived { background: #eee; color: #888; }\n.pill.subm-spam { background: #fdecea; color: #b00; }\n.subm-viewall { font-size: 0.8rem; font-weight: normal; margin-left: 0.4rem; white-space: nowrap; }`;\n\n/** Client-side JS for the submission status triage buttons. Insert bare (no <script> wrapper). */\nexport const SUBMISSION_STATUS_SCRIPT = `document.querySelectorAll(\"button.subm-status\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n try {\n const res = await fetch(b.dataset.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ status: b.dataset.status }),\n });\n b.textContent = res.ok ? \"✓\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });`;\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\n\n/** Status options the editor offers (the code Status union; rare Airtable values\n * like \"legacy\" are set directly in Airtable, not from the dashboard). */\nexport const SITE_STATUS_OPTIONS = [\n \"in development\",\n \"launch period\",\n \"maintenance\",\n \"hosting\",\n \"probably not our problem\",\n \"deprecated\",\n] as const;\nexport const FREQ_OPTIONS = [\"None\", \"Monthly\", \"Quarterly\", \"Yearly\"] as const;\n\ntype FieldKind = \"text\" | \"email\" | \"emails\" | \"enum\" | \"gitrepo\";\nexport type EditableField = {\n column: string;\n kind: FieldKind;\n options?: readonly string[];\n maxLen?: number;\n};\n\n/**\n * The ONLY columns the dashboard editor may write. `column` is the EXACT Airtable\n * field name (note the lowercase / em-dash / misspelled ones), kept in lockstep\n * with `mapRow` in src/reports/airtable/websites.ts.\n */\nexport const EDITABLE_SITE_FIELDS: Record<string, EditableField> = {\n pointOfContact: { column: \"point of contact\", kind: \"email\" },\n reportRecipientsTo: { column: \"Report recipients (To)\", kind: \"emails\" },\n reportRecipientsCc: { column: \"Report recipients (CC)\", kind: \"emails\" },\n copyIntro: { column: \"Copy — Intro\", kind: \"text\", maxLen: 2000 },\n copyContact: { column: \"Copy — Contact\", kind: \"text\", maxLen: 2000 },\n copyFooter: { column: \"Copy — Footer\", kind: \"text\", maxLen: 2000 },\n searchQuery: { column: \"Search query\", kind: \"text\", maxLen: 500 },\n ga4PropertyId: { column: \"GA4 property ID\", kind: \"text\", maxLen: 500 },\n gitRepo: { column: \"Git repo\", kind: \"gitrepo\" },\n status: { column: \"Status\", kind: \"enum\", options: SITE_STATUS_OPTIONS },\n maintenanceFreq: { column: \"maintenence freq\", kind: \"enum\", options: FREQ_OPTIONS },\n testingFreq: { column: \"testing freq\", kind: \"enum\", options: FREQ_OPTIONS },\n};\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst REPO_RE = /^[\\w.-]+\\/[\\w.-]+$/;\n\n/**\n * Validate/normalize a raw value for a field kind. Returns the string to write, or\n * `null` when invalid. Empty (after trim) is allowed — it clears the cell — for\n * every kind EXCEPT `enum`, which must be one of its options.\n */\nexport function normalizeFieldValue(f: EditableField, raw: string): string | null {\n const v = raw.trim();\n // Hard upper bound across every kind (text additionally enforces its own\n // tighter maxLen below) — a single absurdly long value can't reach Airtable.\n if (v.length > 2000) return null;\n switch (f.kind) {\n case \"enum\":\n return f.options!.includes(v) ? v : null;\n case \"email\":\n return v === \"\" ? \"\" : EMAIL_RE.test(v) ? v : null;\n case \"emails\": {\n if (v === \"\") return \"\";\n const parts = v\n .split(/[,\\n]/)\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n return parts.every((p) => EMAIL_RE.test(p)) ? parts.join(\", \") : null;\n }\n case \"gitrepo\":\n return v === \"\" ? \"\" : REPO_RE.test(v) ? v : null;\n case \"text\":\n return v.length <= (f.maxLen ?? 500) ? v : null;\n }\n}\n\n/** Injected IO — the `.mts` binds these to a live Airtable base; tests bind fakes. */\nexport type SiteDetailDeps = {\n getSite: (slug: string) => Promise<WebsiteRow | null>;\n updateField: (recordId: string, column: string, value: string) => Promise<void>;\n};\n\nexport type SiteDetailResult =\n | { status: \"updated\"; slug: string; field: string }\n | { status: \"bad-field\"; slug: string; field: string }\n | { status: \"invalid\"; slug: string; field: string }\n | { status: \"not-found\"; slug: string };\n\n/**\n * Write one allowlisted site-detail field from the dashboard editor.\n *\n * SAFETY: an unknown `field` is rejected BEFORE any read (a hand-crafted authed\n * POST can never write an arbitrary Airtable column), and the value is\n * validated/normalized per kind before the write — invalid input never reaches\n * Airtable.\n */\nexport async function setSiteDetail(\n deps: SiteDetailDeps,\n slug: string,\n field: string,\n rawValue: string,\n): Promise<SiteDetailResult> {\n const f = EDITABLE_SITE_FIELDS[field];\n if (!f) return { status: \"bad-field\", slug, field };\n const value = normalizeFieldValue(f, rawValue);\n if (value === null) return { status: \"invalid\", slug, field };\n const site = await deps.getSite(slug);\n if (!site) return { status: \"not-found\", slug };\n await deps.updateField(site.id, f.column, value);\n return { status: \"updated\", slug, field };\n}\n","import type { WebsiteRow, SecurityAdvisory } from \"../reports/airtable/websites.js\";\nimport { SEVERITY_RANK, siteSlug } from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { isPendingApproval } from \"../reports/airtable/reports.js\";\nimport type { SubmissionRow } from \"../reports/submission-row.js\";\nimport type { ScreenOutTotals } from \"../db/screenouts.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { FAVICON_LINK } from \"./favicon.js\";\nimport { onboardingStatus, missingOnboarding } from \"./onboarding.js\";\nimport { checklistFor, isChecklistComplete } from \"../reports/checklist.js\";\nimport {\n renderSubmissionRow,\n SUBMISSION_STYLES,\n SUBMISSION_STATUS_SCRIPT,\n} from \"./submission-view.js\";\nimport { SITE_STATUS_OPTIONS, FREQ_OPTIONS } from \"./site-details.js\";\n\nconst DASH = \"—\";\n\nfunction scoreTile(label: string, value: number | null): string {\n const display = value === null ? \"—\" : String(value);\n return `<div class=\"tile\"><div class=\"tile-value\">${escapeHtml(display)}</div><div class=\"tile-label\">${escapeHtml(label)}</div></div>`;\n}\n\nfunction healthTile(label: string, value: number | null, sub: string | null): string {\n const display = value === null ? \"—\" : String(value);\n const subLine = sub ? `<div class=\"tile-sub\">${escapeHtml(sub)}</div>` : \"\";\n return `<div class=\"tile\"><div class=\"tile-value\">${escapeHtml(display)}</div><div class=\"tile-label\">${escapeHtml(label)}</div>${subLine}</div>`;\n}\n\nfunction depsSub(majorBehind: number | null): string | null {\n if (majorBehind === null || majorBehind === 0) return null;\n return `${majorBehind} major behind`;\n}\n\nfunction securityTotal(site: WebsiteRow): number | null {\n const parts = [\n site.securityVulnsCritical,\n site.securityVulnsHigh,\n site.securityVulnsModerate,\n site.securityVulnsLow,\n ];\n if (parts.every((p) => p === null)) return null;\n return parts.reduce<number>((sum, p) => sum + (p ?? 0), 0);\n}\n\nfunction securitySub(site: WebsiteRow): string | null {\n const total = securityTotal(site);\n if (total === null || total === 0) return null;\n const c = site.securityVulnsCritical ?? 0;\n const h = site.securityVulnsHigh ?? 0;\n const m = site.securityVulnsModerate ?? 0;\n const l = site.securityVulnsLow ?? 0;\n return `${c}C / ${h}H / ${m}M / ${l}L`;\n}\n\n/** One advisory line: a severity pill, the vulnerable module, the advisory title, any CVEs,\n * and a link to the advisory when present. All Airtable-sourced text is escaped. */\nfunction advisoryRow(a: SecurityAdvisory): string {\n const sev = escapeHtml(a.severity);\n const module = escapeHtml(a.module);\n const title = a.title ? ` — ${escapeHtml(a.title)}` : \"\";\n const cves =\n a.cves.length > 0 ? ` <span class=\"muted\">(${escapeHtml(a.cves.join(\", \"))})</span>` : \"\";\n const link = a.url\n ? ` <a href=\"${escapeHtml(safeUrl(a.url))}\" rel=\"noopener noreferrer\">advisory ▸</a>`\n : \"\";\n return `<li class=\"vuln-item\">\n <span class=\"pill sev-${sev}\">${sev}</span>\n <strong>${module}</strong>${title}${cves}${link}\n </li>`;\n}\n\n/** The per-site vulnerability list — which packages are vulnerable, severity-sorted, not just the\n * totals tile. Omitted entirely when the site was never audited (`null`) or is clean (empty). */\nfunction securitySection(site: WebsiteRow): string {\n const advisories = site.securityAdvisories;\n if (!advisories || advisories.length === 0) return \"\";\n const sorted = [...advisories].sort(\n (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],\n );\n return `<div class=\"section vulns\">\n <h2>Vulnerabilities (${sorted.length})</h2>\n <ul class=\"vuln-list\">${sorted.map(advisoryRow).join(\"\")}</ul>\n </div>`;\n}\n\n/** The interactive operator-checklist for one pending report: one checkbox per\n * `checklistFor(reportType)` item, current state from `report.checklist`, each\n * carrying the report record id + the Airtable field name so the client can POST\n * to /api/reports/:id/checklist and re-gate the Approve button. Launch/Announcement\n * reports (empty checklist) render NOTHING — they are never gated. */\nfunction checklistBlock(r: ReportRow): string {\n const items = checklistFor(r.reportType);\n if (items.length === 0) return \"\";\n const rid = escapeHtml(r.id);\n const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;\n const boxes = items\n .map((item) => {\n const checked = r.checklist[item.field] === true ? \" checked\" : \"\";\n const ev = r.autoEvidence?.[item.field];\n // Auto-tick provenance beside the box: green when the signal proved it (box also `checked`),\n // amber when a signal ran but isn't green (box left unticked, reason shown). No evidence →\n // a plain manual checkbox, exactly as before.\n const badge = ev\n ? ev.result === \"pass\"\n ? ` <span class=\"auto-badge auto-pass\" title=\"${escapeHtml(ev.note)}\">auto ✓</span>`\n : ` <span class=\"auto-badge auto-amber\" title=\"${escapeHtml(ev.note)}\">auto: ${escapeHtml(ev.note)}</span>`\n : \"\";\n return `<label class=\"check-item\"><input type=\"checkbox\" class=\"checklist-checkbox\" data-checklist-report-id=\"${rid}\" data-field=\"${escapeHtml(item.field)}\" data-checklist-url=\"${escapeHtml(url)}\"${checked} /> ${escapeHtml(item.label)}${badge}</label>`;\n })\n .join(\"\");\n return `<div class=\"checklist\" data-checklist-for=\"${rid}\">${boxes}</div>`;\n}\n\n/** The Approve button for a pending report. Server-renders `disabled` when the\n * report's checklist is incomplete (the convenience gate — approve.ts + orchestrate.ts\n * are the hard backstops). Launch/Announcement have an empty checklist → never gated. */\nfunction approveButton(r: ReportRow): string {\n const disabled = isChecklistComplete(r) ? \"\" : \" disabled\";\n return `<button class=\"approve\" data-report-id=\"${escapeHtml(r.id)}\" data-approve-url=\"${escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve`)}\"${disabled}>Approve</button>`;\n}\n\nfunction pendingRow(r: ReportRow): string {\n const type = escapeHtml(r.reportType);\n const period = r.period ? escapeHtml(r.period) : \"—\";\n return `<li><div class=\"pending-head\"><strong>${type}</strong> <span class=\"muted\">${period}</span> ${approveButton(r)}</div>${checklistBlock(r)}</li>`;\n}\n\nfunction pendingSection(reports: ReportRow[]): string {\n const pending = reports.filter(isPendingApproval);\n if (pending.length === 0) return \"\";\n return `<div class=\"section pending\">\n <h2>Pending your yes (${pending.length})</h2>\n <ul class=\"pending-list\">${pending.map(pendingRow).join(\"\")}</ul>\n </div>`;\n}\n\n/** The GA \"Users\" cell for a report row: current count plus the signed delta vs\n * the previous period when both are known. Renders \"—\" when there's no current\n * count (GA not configured / fetch failed → blank in Airtable). */\nfunction gaUsersCell(r: ReportRow): string {\n if (r.gaUsersCurrent === null) return DASH;\n const current = String(r.gaUsersCurrent);\n if (r.gaUsersPrevious === null) return escapeHtml(current);\n const delta = r.gaUsersCurrent - r.gaUsersPrevious;\n const sign = delta > 0 ? \"+\" : \"\"; // negatives carry their own \"-\"; zero shows \"0\"\n return `${escapeHtml(current)} <span class=\"muted\">(${escapeHtml(`${sign}${delta}`)})</span>`;\n}\n\n/** The search-presence cell: the page-1 position when the site was found on\n * page 1, otherwise \"—\" (not-found OR the check didn't run). */\nfunction searchCell(r: ReportRow): string {\n if (r.searchFoundPage1 && r.searchPosition !== null) {\n return escapeHtml(`#${r.searchPosition}`);\n }\n return DASH;\n}\n\nfunction reportRow(r: ReportRow): string {\n const date = r.completedOn ? escapeHtml(r.completedOn) : DASH;\n const type = escapeHtml(r.reportType);\n const id = escapeHtml(r.reportId);\n const ga = gaUsersCell(r);\n const search = searchCell(r);\n const link = r.renderedHtmlAttachment\n ? `<a href=\"${escapeHtml(safeUrl(r.renderedHtmlAttachment.url))}\">view</a>`\n : `<span class=\"muted\">no attachment</span>`;\n const action = isPendingApproval(r) ? approveButton(r) : \"\";\n return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${ga}</td><td>${search}</td><td>${link}</td><td>${action}</td></tr>`;\n}\n\nconst SUBMISSIONS_PER_SITE_CAP = 25;\n\nfunction submissionsSection(submissions: SubmissionRow[], site: WebsiteRow): string {\n if (submissions.length === 0) return \"\";\n const recent = [...submissions]\n .sort((a, b) => (b.submittedAt ?? \"\").localeCompare(a.submittedAt ?? \"\"))\n .slice(0, SUBMISSIONS_PER_SITE_CAP);\n // The heading shows the true total; when we only list a slice, say so rather\n // than implying every one of the N is on the page.\n const note =\n submissions.length > recent.length\n ? `<span class=\"muted\"> — showing ${recent.length} of ${submissions.length}</span>`\n : \"\";\n const viewAll = `<a class=\"subm-viewall\" href=\"/submissions?site=${escapeHtml(siteSlug(site.name))}\">View all for this site →</a>`;\n return `<div class=\"section submissions\">\n <h2>Form submissions (${submissions.length})${note} ${viewAll}</h2>\n <ul class=\"subm-list\">${recent.map(renderSubmissionRow).join(\"\")}</ul>\n </div>`;\n}\n\nconst SPAM_WINDOW_DAYS = 30;\n\n/** The per-site spam panel: caught (honeypot/too-fast) + marked-spam from the screen-out\n * buckets, and delivered counted from the submissions loaded for this page within the\n * window. Omitted when there's nothing to show. `delivered` undercounts only if the site\n * exceeds the 200-row submissions fetch within the window (rare at fleet scale). */\nfunction spamScreenSection(\n totals: ScreenOutTotals | null,\n submissions: SubmissionRow[],\n now: Date,\n): string {\n const sinceMs = now.getTime() - SPAM_WINDOW_DAYS * 24 * 60 * 60 * 1000;\n const delivered = submissions.filter(\n (s) => s.submittedAt !== null && Date.parse(s.submittedAt) >= sinceMs,\n ).length;\n const t = totals ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };\n if (delivered === 0 && t.honeypot === 0 && t.tooFast === 0 && t.markedSpam === 0) return \"\";\n const row = (label: string, n: number) =>\n `<div class=\"spam-kv\"><span class=\"k\">${label}</span> ${escapeHtml(String(n))}</div>`;\n return `<div class=\"section spam-screen\">\n <h2>Spam screen (30d)</h2>\n ${row(\"Caught — honeypot\", t.honeypot)}\n ${row(\"Caught — too-fast\", t.tooFast)}\n ${row(\"Delivered\", delivered)}\n ${row(\"Marked spam\", t.markedSpam)}\n </div>`;\n}\n\n/** Setup (N/4) status near the page header. Lists the missing onboarding items\n * visibly (the cockpit chip only hovers them) so the operator sees what's left\n * to wire up without leaving the page. */\nfunction setupSection(site: WebsiteRow): string {\n const { score, total } = onboardingStatus(site);\n const missing = missingOnboarding(site);\n const detail =\n missing.length === 0\n ? `<span class=\"setup-ok\">complete</span>`\n : `<span class=\"setup-missing\">Missing: ${escapeHtml(missing.join(\", \"))}</span>`;\n return `<div class=\"setup-line\">Setup ${score}/${total} — ${detail}</div>`;\n}\n\n/** One read-only \"Site details\" row: a label and a value that degrades to \"—\". */\nfunction detailRow(label: string, value: string | null | undefined): string {\n const display =\n typeof value === \"string\" && value.trim().length > 0 ? escapeHtml(value.trim()) : DASH;\n return `<div class=\"detail\"><dt>${escapeHtml(label)}</dt><dd>${display}</dd></div>`;\n}\n\n/** A per-field \"saved\" indicator the page script flips to ✓ / ✗ after a POST. */\nfunction savedSpan(field: string): string {\n return `<span class=\"detail-saved\" data-for=\"${field}\"></span>`;\n}\n\n/** Editable `<select>` row for an enum field (Status / cadence). */\nfunction selectRow(\n label: string,\n field: string,\n options: readonly string[],\n current: string | null,\n url: string,\n): string {\n const inList = current !== null && options.includes(current);\n const opts = options\n .map(\n (o) =>\n `<option value=\"${escapeHtml(o)}\"${o === current ? \" selected\" : \"\"}>${escapeHtml(o)}</option>`,\n )\n .join(\"\");\n // When the stored value isn't one of the offered options (e.g. a null cadence,\n // or an Airtable-only \"legacy\" status), show a disabled placeholder selected\n // first so the operator must actively pick — never silently overwrites.\n const placeholder = inList ? \"\" : `<option value=\"\" disabled selected hidden>— select —</option>`;\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><select id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${placeholder}${opts}</select>${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable single-line `<input>` row for a text/email/repo field. */\nfunction inputRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"text\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\" value=\"${escapeHtml(value ?? \"\")}\" />${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable multi-line `<textarea>` row for the copy override fields. */\nfunction textareaRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail wide\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><textarea id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${escapeHtml(value ?? \"\")}</textarea>${savedSpan(field)}</dd></div>`;\n}\n\n/** \"Site details\" section — inline-editable for the safe-text + operational fields\n * (writes via the authed /api/sites/:slug/details endpoint). `Last commit` stays\n * read-only (machine-derived). The Trigger Renovate button rides the heading. */\nfunction siteDetailsSection(site: WebsiteRow): string {\n const url = `/api/sites/${escapeHtml(siteSlug(site.name))}/details`;\n const lastCommit = site.lastCommitAt ? `${relativeTimeFromNow(site.lastCommitAt)}` : null;\n const rows = [\n selectRow(\"Status\", \"status\", SITE_STATUS_OPTIONS, site.status, url),\n selectRow(\"Maintenance cadence\", \"maintenanceFreq\", FREQ_OPTIONS, site.maintenanceFreq, url),\n selectRow(\"Testing cadence\", \"testingFreq\", FREQ_OPTIONS, site.testingFreq, url),\n inputRow(\"Report recipients (To)\", \"reportRecipientsTo\", site.reportRecipientsTo, url),\n inputRow(\"Report recipients (CC)\", \"reportRecipientsCc\", site.reportRecipientsCc, url),\n inputRow(\"Point of contact\", \"pointOfContact\", site.pointOfContact, url),\n inputRow(\"GA4 property\", \"ga4PropertyId\", site.ga4PropertyId, url),\n inputRow(\"Search query\", \"searchQuery\", site.searchQuery, url),\n inputRow(\"Git repo\", \"gitRepo\", site.gitRepo, url),\n textareaRow(\"Copy — Intro\", \"copyIntro\", site.copyIntro, url),\n textareaRow(\"Copy — Contact\", \"copyContact\", site.copyContact, url),\n textareaRow(\"Copy — Footer\", \"copyFooter\", site.copyFooter, url),\n detailRow(\"Last commit\", lastCommit),\n ].join(\"\");\n const triggerBtn = site.gitRepo?.trim()\n ? `<button class=\"trigger-renovate\" data-trigger-url=\"/api/sites/${escapeHtml(siteSlug(site.name))}/trigger-renovate\">Trigger Renovate</button>`\n : \"\";\n return `<div class=\"section site-details\">\n <h2>Site details ${triggerBtn}</h2>\n <dl class=\"details\">${rows}</dl>\n </div>`;\n}\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.75rem; }\n.meta { color: #666; margin-bottom: 2rem; }\n.meta a { color: inherit; }\n.audited { color: #999; font-size: 0.85rem; margin-bottom: 1.5rem; }\n.section { margin: 2rem 0; }\n.section h2 { font-size: 1.1rem; margin: 0 0 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #666; }\n.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; }\n.tile { padding: 1rem; border: 1px solid #ddd; border-radius: 6px; text-align: center; }\n@media (prefers-color-scheme: dark) { .tile { border-color: #333; } }\n.tile-value { font-size: 2rem; font-weight: 600; }\n.tile-label { font-size: 0.85rem; color: #666; margin-top: 0.25rem; }\n.tile-sub { font-size: 0.75rem; color: #999; margin-top: 0.15rem; }\ntable { width: 100%; border-collapse: collapse; }\nth, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { th, td { border-color: #2a2a2a; } }\n.muted { color: #999; }\n.empty { color: #999; padding: 1rem; border: 1px dashed #ccc; border-radius: 6px; text-align: center; }\nbutton.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; }\nbutton.approve:disabled { opacity: 0.6; cursor: default; }\n.pending-list { list-style: none; padding: 0; margin: 0; }\n.pending-list li { padding: 0.5rem; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { .pending-list li { border-color: #2a2a2a; } }\n.pending-head { display: flex; align-items: center; gap: 0.5rem; }\n.checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }\n.check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }\n.check-item input { margin: 0; }\n.auto-badge { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; }\n.auto-pass { background: #e6f4ea; color: #137333; }\n.auto-amber { background: #fef7e0; color: #b06000; }\n.pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }\n.vuln-list { list-style: none; padding: 0; margin: 0; }\n.vuln-item { padding: 0.45rem 0; border-bottom: 1px solid #eee; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; }\n@media (prefers-color-scheme: dark) { .vuln-item { border-color: #2a2a2a; } }\n.pill.sev-critical { background: #fdecea; color: #b00; }\n.pill.sev-high { background: #fff0e6; color: #c4500a; }\n.pill.sev-moderate { background: #fff8e1; color: #8a6d00; }\n.pill.sev-low { background: #f0f0f0; color: #555; }\n.home { display: inline-block; font-size: 0.9rem; margin-bottom: 0.75rem; text-decoration: none; }\n.setup-line { font-size: 0.9rem; color: #666; margin-bottom: 1rem; }\n.setup-ok { color: #1b7a2f; font-weight: 600; }\n.setup-missing { color: #a65a00; }\n.details { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.5rem 1.5rem; margin: 0; }\n.detail { display: flex; flex-direction: column; }\n.detail.wide { grid-column: 1 / -1; }\n.detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }\n.detail dd { margin: 0; }\n.detail dd input, .detail dd select, .detail dd textarea { width: 100%; box-sizing: border-box; font: inherit; padding: 0.25rem 0.4rem; border: 1px solid #ccc; border-radius: 4px; background: transparent; color: inherit; }\n.detail dd textarea { min-height: 3.5rem; resize: vertical; }\n.detail-saved { font-size: 0.8rem; color: #2a8; }\nbutton.trigger-renovate { font: inherit; font-size: 0.8rem; padding: 0.15rem 0.6rem; margin-left: 0.5rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }\n`;\n\n/**\n * Render the per-site dashboard as a single HTML document. Pure function:\n * no Airtable access, no env reads, no I/O. The Netlify function handler\n * fetches data, then hands it here. Easier to unit-test, easier to render\n * a static preview from CLI later.\n */\nexport function renderSiteDashboardHtml(\n site: WebsiteRow,\n reports: ReportRow[],\n submissions: SubmissionRow[] = [],\n spamTotals: ScreenOutTotals | null = null,\n now: Date = new Date(),\n): string {\n const name = escapeHtml(site.name);\n const urlSafe = safeUrl(site.url);\n const allScoresNull =\n site.pScore === null && site.rScore === null && site.bpScore === null && site.seoScore === null;\n\n const scoresSection = allScoresNull\n ? `<div class=\"empty\">No lighthouse data yet — run <code>reddoor-maint audit --write-airtable</code> from the site checkout.</div>`\n : `<div class=\"tiles\">\n ${scoreTile(\"Performance\", site.pScore)}\n ${scoreTile(\"Accessibility\", site.rScore)}\n ${scoreTile(\"Best Practices\", site.bpScore)}\n ${scoreTile(\"SEO\", site.seoScore)}\n </div>`;\n\n const secTotal = securityTotal(site);\n const allHealthNull =\n site.a11yViolations === null && site.depsDrifted === null && secTotal === null;\n const healthSection = allHealthNull\n ? `<div class=\"empty\">No health data yet — run <code>reddoor-maint audit --write-airtable</code> from the site checkout.</div>`\n : `<div class=\"tiles\">\n ${healthTile(\"Accessibility issues\", site.a11yViolations, null)}\n ${healthTile(\"Dependency updates\", site.depsDrifted, depsSub(site.depsMajorBehind))}\n ${healthTile(\"Security alerts\", secTotal, securitySub(site))}\n </div>`;\n\n const auditedLine = site.lastLighthouseAuditAt\n ? `<div class=\"audited\">Last audited ${escapeHtml(relativeTimeFromNow(site.lastLighthouseAuditAt))}</div>`\n : \"\";\n\n // The report-history TABLE is the only place the \"recent 6\" slice belongs:\n // long enough to show a quarter of monthly reports plus the latest testing\n // report, short enough to keep the page a single scroll. The pending list +\n // approve buttons above intentionally see the FULL `reports` set — an OLD\n // pending report that falls outside this slice must still be approvable\n // (and must not disagree with the fleet banner, which counts ALL reports).\n const recentReports = [...reports]\n .sort((a, b) => (b.completedOn ?? \"\").localeCompare(a.completedOn ?? \"\"))\n .slice(0, 6);\n const reportsSection =\n recentReports.length === 0\n ? `<div class=\"empty\">No reports yet.</div>`\n : `<table>\n <thead><tr><th>Completed</th><th>Type</th><th>ID</th><th>GA users</th><th>Search</th><th>Report</th><th></th></tr></thead>\n <tbody>${recentReports.map(reportRow).join(\"\")}</tbody>\n </table>`;\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n ${FAVICON_LINK}\n <title>${name} — Reddoor maintenance</title>\n <style>${STYLES}${SUBMISSION_STYLES}</style>\n</head>\n<body>\n <a class=\"home\" href=\"/\">← Fleet home</a>\n <h1>${name}</h1>\n <div class=\"meta\"><a href=\"${escapeHtml(urlSafe)}\">${escapeHtml(site.url)}</a></div>\n ${auditedLine}\n ${setupSection(site)}\n ${pendingSection(reports)}\n\n <div class=\"section\">\n <h2>Lighthouse</h2>\n ${scoresSection}\n </div>\n\n <div class=\"section\">\n <h2>Site Health</h2>\n ${healthSection}\n </div>\n\n ${securitySection(site)}\n\n <div class=\"section\">\n <h2>Reports</h2>\n ${reportsSection}\n </div>\n\n ${siteDetailsSection(site)}\n ${spamScreenSection(spamTotals, submissions, now)}\n ${submissionsSection(submissions, site)}\n <script>\n document.querySelectorAll(\"button.approve\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n try {\n const res = await fetch(b.dataset.approveUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Approved\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n // Network rejection (offline, DNS, abort): mirror the !res.ok path so\n // the button doesn't sit permanently disabled reading \"Approve\".\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Trigger-renovate button: async on-demand dispatch (mirrors the cockpit).\n document.querySelectorAll(\"button.trigger-renovate\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n b.textContent = \"Dispatching…\";\n try {\n const res = await fetch(b.dataset.triggerUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Dispatched ✓\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Site-details editor: save on change (selects) / blur (inputs+textareas, only\n // when the value actually changed). The per-field span shows ✓ / ✗.\n function saveDetail(el) {\n const span = document.querySelector('.detail-saved[data-for=\"' + el.dataset.detailField + '\"]');\n fetch(el.dataset.detailsUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ field: el.dataset.detailField, value: el.value }),\n })\n .then((r) => {\n if (span) span.textContent = r.ok ? \" ✓\" : \" ✗\";\n })\n .catch(() => {\n if (span) span.textContent = \" ✗\";\n });\n }\n document.querySelectorAll(\"select[data-detail-field]\").forEach((s) => {\n s.addEventListener(\"change\", () => saveDetail(s));\n });\n document.querySelectorAll(\"input[data-detail-field], textarea[data-detail-field]\").forEach((i) => {\n i.addEventListener(\"blur\", () => {\n if (i.value !== i.defaultValue) saveDetail(i);\n });\n });\n ${SUBMISSION_STATUS_SCRIPT}\n // Checklist gate: ticking a box POSTs the one field; the response { complete }\n // decides whether THIS report's Approve button is enabled. Scoped per report by\n // matching the checkbox's report id to the Approve button's id, so multiple\n // pending reports on one page never cross-toggle. On failure the checkbox reverts.\n document.querySelectorAll(\"input.checklist-checkbox\").forEach((cb) => {\n cb.addEventListener(\"change\", async () => {\n const reportId = cb.dataset.checklistReportId;\n const approveBtn = document.querySelector(\n 'button.approve[data-report-id=\"' + (window.CSS && CSS.escape ? CSS.escape(reportId) : reportId) + '\"]',\n );\n cb.disabled = true;\n try {\n const res = await fetch(cb.dataset.checklistUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ reportId, field: cb.dataset.field, value: cb.checked }),\n });\n if (!res.ok) throw new Error(\"bad status\");\n const data = await res.json();\n if (approveBtn) approveBtn.disabled = !data.complete;\n } catch {\n // Revert the optimistic flip so the box reflects the (unchanged) server state.\n cb.checked = !cb.checked;\n } finally {\n cb.disabled = false;\n }\n });\n });\n </script>\n</body>\n</html>`;\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { siteSlug } from \"../reports/airtable/websites.js\";\nimport type { CockpitModel, SiteCard, Tier, SubmissionEntry } from \"./fleet-cockpit.js\";\nimport { onboardingStatus, missingOnboarding } from \"./onboarding.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { FAVICON_LINK } from \"./favicon.js\";\n\nconst DASH = \"—\";\n\nfunction scoreSpan(category: \"perf\" | \"a11y-lh\" | \"bp\" | \"seo\", value: number | null): string {\n const display = value === null ? DASH : String(value);\n return `<span class=\"score ${category}\">${escapeHtml(display)}</span>`;\n}\n\nfunction a11ySpan(value: number | null): string {\n const display = value === null ? DASH : String(value);\n return `<span class=\"metric a11y\">${escapeHtml(display)}</span>`;\n}\n\nfunction depsSpan(\n drifted: number | null,\n majorBehind: number | null,\n outdated: number | null,\n): string {\n if (drifted === null || majorBehind === null) {\n return `<span class=\"metric deps\">${DASH}</span>`;\n }\n // Declared-range drift vs baseline, plus the real outdated-install count when\n // it was determined (null = not checked this run → omit, don't imply clean).\n const driftPart = drifted === 0 ? \"0\" : `${drifted} drifted (${majorBehind} major)`;\n const display = outdated === null ? driftPart : `${driftPart} · ${outdated} outdated`;\n return `<span class=\"metric deps\">${escapeHtml(display)}</span>`;\n}\n\nfunction securitySpan(\n critical: number | null,\n high: number | null,\n moderate: number | null,\n low: number | null,\n): string {\n if (critical === null || high === null || moderate === null || low === null) {\n return `<span class=\"metric sec\">${DASH}</span>`;\n }\n const total = critical + high + moderate + low;\n const display = total === 0 ? \"0\" : `${critical}C/${high}H/${moderate}M/${low}L`;\n return `<span class=\"metric sec\">${escapeHtml(display)}</span>`;\n}\n\nfunction card(site: WebsiteRow): string {\n const name = escapeHtml(site.name);\n // The per-site dashboard at /s/<slug> is operator-only, gated by the shared\n // dashboard password (no per-site token). Cockpit visibility is Status-based;\n // the caller filters the fleet view.\n const href = `/s/${escapeHtml(siteSlug(site.name))}`;\n const onboarding = onboardingStatus(site);\n const missing = missingOnboarding(site);\n const setupTitle = escapeHtml(\n missing.length === 0 ? \"Setup complete\" : `Missing: ${missing.join(\", \")}`,\n );\n const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);\n const safeSiteUrl = escapeHtml(safeUrl(site.url));\n const visibleUrl = escapeHtml(site.url);\n\n return `<article class=\"card\">\n <header class=\"card-head\">\n <a class=\"site\" href=\"${href}\">${name}</a>\n <a class=\"url\" href=\"${safeSiteUrl}\" target=\"_blank\" rel=\"noopener\">${visibleUrl}</a>\n <span class=\"setup\" title=\"${setupTitle}\">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>\n <span class=\"audited\">Audited: <strong>${escapeHtml(audited)}</strong></span>\n </header>\n <div class=\"card-metrics\">\n <span class=\"cluster lighthouse\">\n <span class=\"metric-label\">Perf</span> ${scoreSpan(\"perf\", site.pScore)}\n <span class=\"metric-label\">Access</span> ${scoreSpan(\"a11y-lh\", site.rScore)}\n <span class=\"metric-label\">BP</span> ${scoreSpan(\"bp\", site.bpScore)}\n <span class=\"metric-label\">SEO</span> ${scoreSpan(\"seo\", site.seoScore)}\n </span>\n <span class=\"cluster health\">\n <span class=\"metric-label\">a11y</span> ${a11ySpan(site.a11yViolations)}\n <span class=\"metric-label\">deps</span> ${depsSpan(site.depsDrifted, site.depsMajorBehind, site.depsOutdated)}\n <span class=\"metric-label\">sec</span> ${securitySpan(\n site.securityVulnsCritical,\n site.securityVulnsHigh,\n site.securityVulnsModerate,\n site.securityVulnsLow,\n )}\n </span>\n </div>\n </article>`;\n}\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.75rem; }\n.meta { color: #666; margin-bottom: 1.5rem; }\n.empty { color: #999; padding: 2rem; text-align: center; border: 1px dashed #ccc; border-radius: 6px; }\n.cards { display: flex; flex-direction: column; gap: 0.75rem; }\n.card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 0.9rem 1.1rem; }\n@media (prefers-color-scheme: dark) { .card { border-color: #2a2a2a; background: #181818; } }\n.card-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: baseline; }\n.card-head .site { font-weight: 600; font-size: 1.05rem; }\n.card-head .url { color: #666; font-size: 0.85rem; }\n.card-head .setup, .card-head .audited { color: #666; font-size: 0.85rem; }\n.card-head .setup { margin-left: auto; }\n.card-metrics { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin-top: 0.5rem; font-variant-numeric: tabular-nums; }\n.cluster { display: inline-flex; gap: 0.5rem; align-items: baseline; }\n.cluster.lighthouse .score { display: inline-block; min-width: 2.25rem; text-align: right; }\n.metric-label { color: #999; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }\n.metric { font-feature-settings: \"tnum\"; }\n.summary { display:flex; flex-wrap:wrap; gap:0.5rem 1.25rem; align-items:baseline; margin-bottom:0.5rem; }\n.summary .tier { font-weight:700; }\n.summary .heads { color:#666; font-size:0.9rem; }\n.spam-rollup { font-size:0.9rem; margin-bottom:1rem; }\n.muted { color:#999; }\n.subm-viewall { font-size:0.8rem; font-weight:normal; margin-left:0.4rem; white-space:nowrap; }\n.filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }\n.filters button { font:inherit; font-size:0.85rem; padding:0.25rem 0.7rem; border:1px solid #ccc; border-radius:999px; background:transparent; color:inherit; cursor:pointer; }\n.filters button[aria-pressed=\"true\"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }\n@media (prefers-color-scheme: dark) { .filters button[aria-pressed=\"true\"] { background:#e8e8e8; color:#111; } }\n.fleet-actions { margin-bottom:1.25rem; }\n.refresh-fleet { font:inherit; font-size:0.85rem; padding:0.3rem 0.8rem; border:1px solid #1a1a1a; border-radius:999px; background:#1a1a1a; color:#fff; cursor:pointer; }\n.refresh-fleet:disabled { opacity:0.6; cursor:default; }\n@media (prefers-color-scheme: dark) { .refresh-fleet { background:#e8e8e8; color:#111; border-color:#e8e8e8; } }\n.rf-status { margin-top:0.6rem; font-size:0.85rem; }\n.rf-row { padding:0.1rem 0; }\n.rf-row a { margin-left:0.3rem; }\n.rf-spin { display:inline-block; width:0.8em; height:0.8em; border:2px solid #999; border-top-color:transparent; border-radius:50%; animation:rf-spin 0.8s linear infinite; vertical-align:-0.1em; }\n@keyframes rf-spin { to { transform:rotate(360deg); } }\ndetails.tier { margin:0.75rem 0; }\ndetails.tier > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }\n.approve-strip { border:1px solid #ffe08a; background:#fff8e1; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }\n@media (prefers-color-scheme: dark) { .approve-strip { background:#241f00; border-color:#5a4d00; } }\n.approve-strip h2 { font-size:1rem; margin:0 0 0.5rem; }\n.approve-row { display:flex; flex-wrap:wrap; gap:0.5rem 1rem; align-items:center; padding:0.25rem 0; }\n.pill { font-size:0.75rem; padding:0.1rem 0.5rem; border-radius:999px; font-weight:700; }\n.pill.attention { background:#fdecea; color:#b00; }\n.pill.watch { background:#fff4e5; color:#a65a00; }\n.pill.healthy { background:#e8f5e9; color:#1b7a2f; }\n.chips { display:flex; flex-wrap:wrap; gap:0.4rem; margin-top:0.5rem; }\n.chip { font-size:0.8rem; padding:0.1rem 0.5rem; border-radius:6px; background:#f0f0f0; }\n@media (prefers-color-scheme: dark) { .chip { background:#222; } }\n.chip.critical { background:#fdecea; color:#b00; }\n.chip.stuck { border:1px solid #b00; font-weight:600; }\n.badge { font-weight:700; color:#C00; font-size:0.72rem; margin-right:0.25rem; }\n.all-clear { background:#e8f5e9; color:#1b7a2f; padding:0.6rem 1rem; border-radius:8px; margin-bottom:1.25rem; font-weight:600; }\n@media (prefers-color-scheme: dark) { .all-clear { background:#10240f; color:#7fce85; } }\n`;\n\nconst TIER_META: Record<Tier, { emoji: string; label: string; open: boolean }> = {\n attention: { emoji: \"🔴\", label: \"Needs attention\", open: true },\n watch: { emoji: \"🟡\", label: \"Watch\", open: false },\n healthy: { emoji: \"🟢\", label: \"Healthy\", open: false },\n};\n\nconst FILTERS = [\n \"all\",\n \"vulns\",\n \"lighthouse\",\n \"delivery\",\n \"prs\",\n \"ci\",\n \"auto-fix-failed\",\n \"stale\",\n \"no-domain\",\n \"pending\",\n \"submissions\",\n] as const;\n\nfunction summaryBar(model: CockpitModel): string {\n const s = model.summary;\n const heads = [\n `${s.criticalHighVulns} critical/high vuln${s.criticalHighVulns === 1 ? \"\" : \"s\"}`,\n `${s.lighthouseBelowFloor} Lighthouse<75`,\n `${s.deliveryFailures} delivery`,\n `${s.renovateFailing} PRs failing`,\n `${s.ciRed} CI red`,\n `${s.autoFixStuck} auto-fix stuck`,\n `${s.pending} pending`,\n `${s.newSubmissions ?? 0} new`,\n ].join(\" · \");\n const chips = FILTERS.map(\n (f) =>\n `<button type=\"button\" data-filter=\"${f}\" aria-pressed=\"${f === \"all\" ? \"true\" : \"false\"}\">${f}</button>`,\n ).join(\"\");\n return `<div class=\"summary\">\n <span class=\"tier\">🔴 ${s.attention} needs attention</span>\n <span class=\"tier\">🟡 ${s.watch} watch</span>\n <span class=\"tier\">🟢 ${s.healthy} healthy</span>\n </div>\n <div class=\"summary heads\">${escapeHtml(heads)}</div>\n <div class=\"filters\">${chips}</div>\n <div class=\"fleet-actions\">\n <button type=\"button\" class=\"refresh-fleet\" data-refresh-url=\"/api/fleet/refresh\">↻ Refresh fleet state</button>\n <div id=\"rf-status\" class=\"rf-status\" aria-live=\"polite\"></div>\n </div>`;\n}\n\n/** One-line fleet spam roll-up beneath the summary: caught (honeypot+too-fast) vs\n * through (marked spam) over the window. Omitted when there's no spam data, so a\n * fleet with no screen-out buckets reads clean rather than \"caught 0 · through 0\". */\nfunction spamRollup(model: CockpitModel): string {\n const s = model.spam;\n if (!s || (s.caught === 0 && s.through === 0)) return \"\";\n return `<div class=\"spam-rollup muted\">🛡 Spam (30d) — caught ${s.caught} · through ${s.through}</div>`;\n}\n\n/** Affirmative all-clear when nothing is on the 🔴 tier (spec §5.2/§12) — so a\n * healthy or empty fleet reads as \"all clear\", not three bare \"None.\" rows. */\nfunction allClearBanner(model: CockpitModel): string {\n if (model.summary.attention > 0) return \"\";\n const msg =\n model.cards.length === 0\n ? \"No sites on the fleet view yet.\"\n : \"All clear — nothing needs your attention.\";\n return `<div class=\"all-clear\">✓ ${escapeHtml(msg)}</div>`;\n}\n\nfunction approveStrip(model: CockpitModel): string {\n if (model.pending.length === 0) return \"\";\n const rows = model.pending\n .map((p) => {\n const href = `/s/${escapeHtml(p.slug)}`;\n const url = `/api/reports/${encodeURIComponent(p.reportId)}/approve`;\n return `<div class=\"approve-row\" data-signal=\"pending\">\n <strong>${escapeHtml(p.siteName)}</strong>\n <span class=\"muted\">${escapeHtml(p.reportType)} ${escapeHtml(p.period)}</span>\n <button class=\"approve\" data-report-id=\"${escapeHtml(p.reportId)}\" data-approve-url=\"${escapeHtml(url)}\">Approve</button>\n <a href=\"${href}\">open ▸</a>\n </div>`;\n })\n .join(\"\");\n return `<section class=\"approve-strip\" data-tier=\"pending\">\n <h2>Approve (${model.pending.length}) — your daily yes</h2>\n ${rows}\n </section>`;\n}\n\n/** Most submissions to render in the cockpit strip. The heading still shows the\n * true fleet total; overflow is triaged on each site's page (which lists 25). */\nconst SUBMISSIONS_STRIP_CAP = 10;\n\nfunction submissionsStrip(model: CockpitModel): string {\n const subs: SubmissionEntry[] = model.submissions ?? [];\n if (subs.length === 0) return \"\";\n // Render the newest N only — the strip is a triage prompt, not the inbox. Sort\n // defensively (the builder preserves input order, which is already newest-first).\n const shown = [...subs]\n .sort((a, b) => (b.submittedAt ?? \"\").localeCompare(a.submittedAt ?? \"\"))\n .slice(0, SUBMISSIONS_STRIP_CAP);\n const rows = shown\n .map((sub) => {\n const href = `/s/${escapeHtml(sub.slug)}`;\n const when = sub.submittedAt ? escapeHtml(relativeTimeFromNow(sub.submittedAt)) : \"\";\n const who = escapeHtml(sub.name || sub.email);\n return `<div class=\"approve-row\" data-signal=\"submissions\">\n <strong>${escapeHtml(sub.siteName)}</strong>\n <span class=\"muted\">${escapeHtml(sub.formType)} — ${who}</span>\n <span class=\"muted\">${when}</span>\n <a href=\"${href}\">open ▸</a>\n </div>`;\n })\n .join(\"\");\n const overflow = subs.length - shown.length;\n const more =\n overflow > 0\n ? `<div class=\"approve-row subm-more muted\"><a href=\"/submissions\">+${overflow} more — view all submissions</a></div>`\n : \"\";\n return `<section class=\"approve-strip subm-strip\" data-tier=\"submissions\">\n <h2>📥 New submissions (${subs.length}) <a class=\"subm-viewall\" href=\"/submissions\">View all →</a></h2>\n ${rows}${more}\n </section>`;\n}\n\nfunction submBadge(c: SiteCard): string {\n const n = c.newSubmissions ?? 0;\n return n > 0 ? `<span class=\"chip\">📥 ${n} new</span>` : \"\";\n}\n\nconst PILL_LABEL: Record<Tier, string> = { attention: \"failing\", watch: \"watch\", healthy: \"ok\" };\n\nfunction attentionBadge(status?: string): string {\n if (status === \"new\") return `<span class=\"badge\">NEW</span>`;\n if (status === \"worse\") return `<span class=\"badge\">WORSE</span>`;\n return \"\";\n}\n\nfunction chips(c: SiteCard): string {\n const items = c.items.map((it) => {\n const cls = it.autoFixExhausted\n ? \"chip critical stuck\"\n : it.severity === \"critical\"\n ? \"chip critical\"\n : \"chip\";\n return `<span class=\"${cls}\">${attentionBadge(it.status)}${escapeHtml(it.title)}</span>`;\n });\n for (const reason of c.watchReasons)\n items.push(`<span class=\"chip\">${escapeHtml(reason)}</span>`);\n return items.length ? `<div class=\"chips\">${items.join(\"\")}</div>` : \"\";\n}\n\n/** Space-separated signal tags for the client filter. Attention-item kinds\n * (\"vulns\"/\"lighthouse\"/\"delivery\"/\"prs\" from renovate/\"ci\") plus the structured\n * watch signals (\"lighthouse\" for a sub-floor-band score, \"stale\" for an old\n * commit) — so a watch-band Lighthouse card still matches the \"lighthouse\" filter. */\nfunction signalsAttr(c: SiteCard): string {\n const kinds = new Set<string>();\n for (const it of c.items) {\n kinds.add(it.kind === \"vuln\" ? \"vulns\" : it.kind === \"renovate\" ? \"prs\" : it.kind);\n }\n if (c.items.some((it) => it.autoFixExhausted)) kinds.add(\"auto-fix-failed\");\n for (const sig of c.watchSignals) kinds.add(sig);\n return [...kinds].join(\" \");\n}\n\n/** On-demand Renovate trigger button — only for repo-backed sites (nothing to\n * dispatch otherwise). Posts to the authed /api/sites/:slug/trigger-renovate. */\nfunction triggerRenovateBtn(c: SiteCard): string {\n if (!c.site.gitRepo?.trim()) return \"\";\n const url = `/api/sites/${escapeHtml(siteSlug(c.site.name))}/trigger-renovate`;\n return `<button class=\"trigger-renovate\" data-trigger-url=\"${url}\">Trigger Renovate</button>`;\n}\n\nfunction cockpitCard(c: SiteCard): string {\n const base = card(c.site); // existing header + metrics markup\n const pill = `<span class=\"pill ${c.tier}\">${PILL_LABEL[c.tier]}</span>`;\n const extra = `${pill}${chips(c)}${submBadge(c)}${triggerRenovateBtn(c)}`;\n const opening = `<article class=\"card\" data-signals=\"${signalsAttr(c)}\">`;\n // Inject the pill + chips before the article's closing tag, and add the filter\n // hook. Function replacers so a `$` in escaped chip text can't be read as a\n // String.replace special ($&, $1, …).\n return base\n .replace('<article class=\"card\">', () => opening)\n .replace(\"</article>\", () => `${extra}</article>`);\n}\n\nconst FILTER_SCRIPT = `<script>\n(function(){\n var btns = document.querySelectorAll('.filters button');\n var cards = document.querySelectorAll('.cards .card');\n var details = document.querySelectorAll('details.tier');\n btns.forEach(function(b){\n b.addEventListener('click', function(){\n var f = b.getAttribute('data-filter');\n btns.forEach(function(x){ x.setAttribute('aria-pressed', x===b ? 'true':'false'); });\n // \"pending\" lives on the approve strip, not on tier cards — just jump to it,\n // never hide the triage cards (else the whole board blanks).\n if (f === 'pending') { var s = document.querySelector('.approve-strip'); if (s) s.scrollIntoView({behavior:'smooth'}); return; }\n if (f === 'submissions') { var ss = document.querySelector('[data-tier=\"submissions\"]'); if (ss) ss.scrollIntoView({behavior:'smooth'}); return; }\n if (f !== 'all') details.forEach(function(d){ d.open = true; });\n cards.forEach(function(c){\n var sig = (c.getAttribute('data-signals')||'').split(' ');\n c.style.display = (f==='all' || sig.indexOf(f)!==-1) ? '' : 'none';\n });\n });\n });\n // approve buttons: mirror the per-site dashboard's inline POST.\n document.querySelectorAll('button.approve').forEach(function(b){\n b.addEventListener('click', async function(){\n b.disabled = true; b.textContent = 'Approving…';\n try { var res = await fetch(b.dataset.approveUrl, { method: 'POST' });\n b.textContent = res.ok ? 'Approved ✓' : 'Failed'; }\n catch(e){ b.textContent = 'Failed'; b.disabled = false; }\n });\n });\n // trigger-renovate buttons: fire the on-demand dispatch (async, fire-and-forget).\n document.querySelectorAll('button.trigger-renovate').forEach(function(b){\n b.addEventListener('click', async function(){\n b.disabled = true; b.textContent = 'Dispatching…';\n try { var res = await fetch(b.dataset.triggerUrl, { method: 'POST' });\n b.textContent = res.ok ? 'Dispatched ✓' : 'Failed';\n if (!res.ok) b.disabled = false; }\n catch(e){ b.textContent = 'Failed'; b.disabled = false; }\n });\n });\n // fleet-refresh live status: dispatch, then poll the actual runs and follow them.\n // Vanilla JS, string-concat only (no template literals) — this lives inside a TS\n // template string, so backticks or interpolation syntax would break the server render.\n var RF_KEY = 'reddoor:fleet-refresh';\n var RF_POLL_MS = 10000;\n var RF_MAX_MS = 90 * 60 * 1000; // safety ceiling; a full fleet Lighthouse run was ~48 min (2026-06-24)\n function rfPanel(){ return document.getElementById('rf-status'); }\n function rfStop(){ try { localStorage.removeItem(RF_KEY); } catch(e){} }\n // Safe to build raw HTML: workflow/state are server-fixed enums and url is GitHub's\n // own html_url for our central repo — none are user-supplied. Don't interpolate\n // untrusted fields here without escaping.\n function rfRender(status){\n var failed = function(s){ return s === 'failure' || s === 'cancelled' || s === 'timed_out'; };\n return status.perWorkflow.map(function(w){\n var label = w.workflow.replace('.yml','').replace('fleet-','');\n var icon = w.state === 'success' ? '✓' : failed(w.state) ? '✗' : '<span class=\"rf-spin\"></span>';\n var link = (failed(w.state) && w.url) ? ' <a href=\"'+w.url+'\" target=\"_blank\" rel=\"noopener\">run</a>' : '';\n return '<div class=\"rf-row\">'+icon+' '+label+' — '+w.state.replace('_',' ')+link+'</div>';\n }).join('');\n }\n function rfPoll(since, startedAt){\n fetch('/api/fleet/refresh/status?since=' + encodeURIComponent(since)).then(function(res){\n if (res.status === 401) return { authFail: true };\n return res.ok ? res.json() : null;\n }).then(function(data){\n var p = rfPanel();\n if (data && data.authFail){\n if (p) p.innerHTML += '<div class=\"rf-row\">Session expired — reload to sign in.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Refresh fleet state'; }\n rfStop(); return;\n }\n if (data && data.status){\n if (p) p.innerHTML = rfRender(data.status);\n if (data.status.allDone){\n if (!data.status.anyFailure){\n if (p) p.innerHTML += '<div class=\"rf-row\">✓ Done — reloading…</div>';\n rfStop(); setTimeout(function(){ location.reload(); }, 2000); return;\n }\n if (p) p.innerHTML += '<div class=\"rf-row\"><button type=\"button\" onclick=\"location.reload()\">Reload</button></div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Refresh fleet state'; }\n rfStop(); return;\n }\n }\n if (Date.now() - startedAt > RF_MAX_MS){\n if (p) p.innerHTML += '<div class=\"rf-row\">Still running — reload later.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Refresh fleet state'; }\n rfStop(); return;\n }\n setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);\n }).catch(function(){\n var p = rfPanel();\n if (Date.now() - startedAt > RF_MAX_MS){\n if (p) p.innerHTML += '<div class=\"rf-row\">Still running — reload later.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Refresh fleet state'; }\n rfStop(); return;\n }\n setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);\n });\n }\n function rfBegin(since, startedAt){\n try { localStorage.setItem(RF_KEY, JSON.stringify({ since: since, startedAt: startedAt })); } catch(e){}\n var p = rfPanel(); if (p) p.innerHTML = '<div class=\"rf-row\"><span class=\"rf-spin\"></span> starting…</div>';\n rfPoll(since, startedAt);\n }\n var rf = document.querySelector('button.refresh-fleet');\n if (rf) rf.addEventListener('click', async function(){\n if (!confirm('Kick off the security + Lighthouse sweeps for the whole fleet? They take a few minutes.')) return;\n rf.disabled = true; rf.textContent = 'Refreshing…';\n try {\n var res = await fetch(rf.dataset.refreshUrl, { method: 'POST' });\n if (res.ok){\n var data = await res.json();\n rf.textContent = '↻ Refresh running…';\n if (data && data.since) rfBegin(data.since, Date.now());\n } else { rf.textContent = 'Failed to start'; rf.disabled = false; }\n } catch(e){ rf.textContent = 'Failed to start'; rf.disabled = false; }\n });\n // Resume-on-reload: if a refresh is in flight (<90 min old), keep following it.\n try {\n var rfSaved = JSON.parse(localStorage.getItem(RF_KEY) || 'null');\n if (rfSaved && rfSaved.since && rfSaved.startedAt && (Date.now() - rfSaved.startedAt) < RF_MAX_MS){\n if (rf){ rf.disabled = true; rf.textContent = '↻ Refresh running…'; }\n rfBegin(rfSaved.since, rfSaved.startedAt);\n } else if (rfSaved) { rfStop(); }\n } catch(e){}\n})();\n</script>`;\n\n/**\n * Render the fleet cockpit as a single HTML document. Pure function: no Airtable\n * access, no env reads, no I/O. The Netlify function handler builds the\n * CockpitModel (visible-site filter, tiering, NEW/WORSE badging, pending list)\n * and hands it here. Renders the doc shell + summary bar + filter chips + pinned\n * approve strip + three <details> tier sections of cards.\n */\nexport function renderCockpitHtml(model: CockpitModel): string {\n const total = model.cards.length;\n const tiers: Tier[] = [\"attention\", \"watch\", \"healthy\"];\n const sections = tiers\n .map((tier) => {\n const cards = model.cards.filter((c) => c.tier === tier);\n const meta = TIER_META[tier];\n const body =\n cards.length === 0\n ? `<div class=\"empty\">None.</div>`\n : `<div class=\"cards\">${cards.map(cockpitCard).join(\"\")}</div>`;\n return `<details class=\"tier\" data-tier=\"${tier}\"${meta.open ? \" open\" : \"\"}>\n <summary>${meta.emoji} ${meta.label} (${cards.length})</summary>\n ${body}\n </details>`;\n })\n .join(\"\");\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n ${FAVICON_LINK}\n <title>Reddoor maintenance — fleet cockpit</title>\n <style>${STYLES}</style>\n</head>\n<body>\n <h1>Reddoor fleet cockpit</h1>\n <div class=\"meta\">${total} site${total === 1 ? \"\" : \"s\"} on the Reddoor stack.</div>\n ${summaryBar(model)}\n ${allClearBanner(model)}\n ${approveStrip(model)}\n ${sections}\n ${spamRollup(model)}\n ${submissionsStrip(model)}\n ${FILTER_SCRIPT}\n</body>\n</html>`;\n}\n","import { timingSafeEqual } from \"node:crypto\";\n\n/**\n * Verify an `Authorization: Basic <base64>` header against the configured\n * dashboard password. Username is intentionally ignored — operators may\n * type anything when the browser prompts; only the password gates entry.\n *\n * Returns false for any of:\n * - missing/empty Authorization header\n * - non-Basic auth scheme\n * - malformed base64 or payload (no colon to split user:password)\n * - wrong password\n * - expected password missing (DASHBOARD_PASSWORD not configured)\n *\n * Wrong-password compare is constant-time; BYTE lengths are checked first\n * (timingSafeEqual throws a RangeError on a buffer-length mismatch, and the\n * length itself doesn't leak — operator's password length is fixed per deploy).\n * Comparing JS-string lengths instead of byte lengths could let an equal-char\n * but unequal-byte password (a multibyte char) reach timingSafeEqual and throw,\n * turning a wrong password into an uncaught 500.\n */\nexport function verifyBasicAuth(\n authHeader: string | null | undefined,\n expectedPassword: string | null,\n): boolean {\n if (!authHeader || !expectedPassword) return false;\n // RFC 7235: scheme is case-insensitive.\n const match = /^basic\\s+(.+)$/i.exec(authHeader.trim());\n if (!match) return false;\n let decoded: string;\n try {\n decoded = Buffer.from(match[1]!, \"base64\").toString(\"utf-8\");\n } catch {\n return false;\n }\n // Base64-decoding never throws in Node, but a payload of garbage may\n // produce a string with no colon. user:password form is required.\n const colonIdx = decoded.indexOf(\":\");\n if (colonIdx === -1) return false;\n const provided = decoded.slice(colonIdx + 1);\n // Compare BYTE lengths, not JS-string lengths: timingSafeEqual compares the\n // underlying buffers and throws a RangeError if they differ in byte length.\n // Two strings can share a JS length but differ in UTF-8 byte length (e.g. a\n // multibyte char), so a JS-length guard would let mismatched buffers through\n // and crash the handler with a 500.\n const a = Buffer.from(provided, \"utf-8\");\n const b = Buffer.from(expectedPassword, \"utf-8\");\n if (a.length !== b.length) return false;\n return timingSafeEqual(a, b);\n}\n"],"mappings":"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","names":["DASH","STYLES","chips"]}
1
+ {"version":3,"sources":["../src/recipes/index.ts","../src/dashboard/relative-time.ts","../src/dashboard/favicon.ts","../src/dashboard/onboarding.ts","../src/dashboard/submission-view.ts","../src/dashboard/site-details.ts","../src/dashboard/render.ts","../src/dashboard/fleet-render.ts","../src/dashboard/basic-auth.ts"],"sourcesContent":["import type { RecipeName } from \"../types.js\";\nimport { syncConfigs, type SyncConfigsOptions } from \"./sync-configs.js\";\nimport { bumpDeps, type BumpDepsOptions } from \"./bump-deps.js\";\nimport { upgradeSvelte4to5, type UpgradeSvelte4to5Options } from \"./svelte-5/index.js\";\nimport { svelteCodemods } from \"./svelte-codemods.js\";\nimport { convertToPnpm, type ConvertToPnpmOptions } from \"./convert-to-pnpm.js\";\nimport { onboard, type OnboardOptions, type OnboardAudit } from \"./onboard.js\";\nimport { a11yFixturesPage } from \"./a11y-fixtures-page/index.js\";\nimport {\n init,\n DEFAULT_INIT_STEPS,\n type InitOptions,\n type InitResult,\n type InitStep,\n type InitStepResult,\n} from \"./init.js\";\n\nexport {\n syncConfigs,\n bumpDeps,\n upgradeSvelte4to5,\n svelteCodemods,\n convertToPnpm,\n onboard,\n a11yFixturesPage,\n init,\n DEFAULT_INIT_STEPS,\n};\nexport type {\n SyncConfigsOptions,\n BumpDepsOptions,\n UpgradeSvelte4to5Options,\n ConvertToPnpmOptions,\n OnboardOptions,\n OnboardAudit,\n InitOptions,\n InitResult,\n InitStep,\n InitStepResult,\n};\n\nexport const ALL_RECIPE_NAMES: RecipeName[] = [\n \"sync-configs\",\n \"bump-deps\",\n \"svelte-4-to-5\",\n \"svelte-codemods\",\n \"convert-to-pnpm\",\n \"onboard\",\n \"a11y-fixtures-page\",\n \"self-updating\",\n \"init\",\n];\n\nexport function isRecipeName(value: string): value is RecipeName {\n return (ALL_RECIPE_NAMES as string[]).includes(value);\n}\n","/** Render an absolute timestamp as a coarse \"Xd ago\" relative string for the\n * fleet card. Takes an explicit `now` for testability; defaults to wall clock\n * for callers (the Netlify function). Returns \"—\" for null / unparseable. */\nexport function relativeTimeFromNow(iso: string | null, now: Date = new Date()): string {\n if (!iso) return \"—\";\n const t = Date.parse(iso);\n if (Number.isNaN(t)) return \"—\";\n\n const seconds = Math.max(0, Math.floor((now.getTime() - t) / 1000));\n if (seconds < 60) return \"just now\";\n\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes}m ago`;\n\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return `${hours}h ago`;\n\n const days = Math.floor(hours / 24);\n if (days < 7) return `${days}d ago`;\n\n const weeks = Math.floor(days / 7);\n if (weeks < 4) return `${weeks}w ago`;\n\n const months = Math.floor(days / 30);\n return `${months}mo ago`;\n}\n","// The reddoor mark (32×32 PNG, ~554 B) inlined as a data-URI favicon. The\n// dashboard pages are rendered by Netlify functions with no static-asset\n// pipeline, so embedding the icon in the <head> brands every page without a\n// second request or a hosted file. Source: reddoor-website/static/favicon.png.\nconst REDDOOR_FAVICON_PNG_BASE64 =\n \"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAACXBIWXMAAAAnAAAAJwEqCZFPAAAAk1BMVEVHcEzkGTfjGDbjGTfjGTfjGTfjGTfjGDbjGDbjGTfjGTfjGTfkGTfjGTfjGDbjGDbkGTfjGDbjGDbjGTfjGTfjGDbjGTfjGDbjGTfjGDbjGTfjGTfjGDbjGDbjGDbjGDbjGTfjGTbjGDbjGDbjGDbjGDbjGTfjGTfjGTfjGDbjGDbjGTfjGDbjGDbjGTfjGTbkGTfxxbwzAAAALXRSTlMA4DABMOD8cOBwDltwi+bFLMlk54/BDVPk/I61/u2cFaBw9nqO1izBIQE+Becdo6eEAAABBElEQVQ4y91SCVYCMQwt4kxbcEHFfUFFcMGf9P6nM0kXEPEAmtdJs+cnU+f+He2fIIEAYuVgkmvDfTG9Rh+6LoRBUBqE7gi09l9eAeeTrZJoFd5uQWfj4VbPvRqwmj9zfzP6CYpyi48F6HW5A3WuMHsEPsfvO8cSkMPTe+pfRr/MTckdg+4enqL3PkYf/YFIqiiP8VAw6EZkH6QEO0xJLUhmdbY5+TjHkCVohprlcpzlnJw9GlNkrZC16uCmWIaAZItNrT4xV0T2yxrIWoNKy4rdVXOq0MycWp4r43FOMQWciibMYaOHCXKSPhapqTupGFAnwHoVuUUZq7jaSkqDb0/uD9MXqvJMDtU7lL0AAAAASUVORK5CYII=\";\n\n/** A ready-to-interpolate `<link rel=\"icon\">` carrying the reddoor mark. */\nexport const FAVICON_LINK = `<link rel=\"icon\" type=\"image/png\" href=\"data:image/png;base64,${REDDOOR_FAVICON_PNG_BASE64}\" />`;\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\n\nexport type OnboardingStatus = {\n score: number;\n total: 4;\n checks: {\n firstAudit: boolean;\n recipients: boolean;\n schedule: boolean;\n poc: boolean;\n };\n};\n\nfunction isNonEmpty(s: string | null | undefined): boolean {\n return typeof s === \"string\" && s.trim().length > 0;\n}\n\n/** Four-point onboarding signal for the fleet card. A site is \"fully onboarded\"\n * when it has been audited at least once, has a To-recipient for monthly\n * reports, has a maintenance schedule that isn't \"None\", and has a named POC. */\nexport function onboardingStatus(row: WebsiteRow): OnboardingStatus {\n const checks = {\n firstAudit: isNonEmpty(row.lastLighthouseAuditAt),\n recipients: isNonEmpty(row.reportRecipientsTo),\n schedule: row.maintenanceFreq !== \"None\",\n poc: isNonEmpty(row.pointOfContact),\n };\n const score = Object.values(checks).filter(Boolean).length;\n return { score, total: 4, checks };\n}\n\n/** Human label for each onboarding check, in canonical check order. Used by the\n * dashboards to spell out which signals a partially-onboarded site is missing\n * (cockpit setup-chip tooltip + per-site setup line). */\nexport const ONBOARDING_LABELS: Record<keyof OnboardingStatus[\"checks\"], string> = {\n firstAudit: \"First audit\",\n recipients: \"Report recipients\",\n schedule: \"Maintenance schedule\",\n poc: \"Point of contact\",\n};\n\n/** The labels of the onboarding checks this site has NOT satisfied, in check\n * order. Empty array → fully onboarded. */\nexport function missingOnboarding(row: WebsiteRow): string[] {\n const { checks } = onboardingStatus(row);\n return (Object.keys(ONBOARDING_LABELS) as Array<keyof typeof ONBOARDING_LABELS>)\n .filter((key) => !checks[key])\n .map((key) => ONBOARDING_LABELS[key]);\n}\n","import type { SubmissionRow } from \"../reports/submission-row.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\n\n/** Render a submission's `extraFields` JSON as a key/value list; on parse failure\n * show the raw string (escaped) rather than dropping it. Returns \"\" when blank. */\nfunction extraFieldsList(raw: string | null): string {\n if (!raw || raw.trim() === \"\") return \"\";\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return `<div class=\"subm-kv\"><span class=\"k\">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return `<div class=\"subm-kv\"><span class=\"k\">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;\n }\n const rows = Object.entries(parsed as Record<string, unknown>)\n .map(\n ([k, v]) =>\n `<div class=\"subm-kv\"><span class=\"k\">${escapeHtml(k)}</span> ${escapeHtml(String(v))}</div>`,\n )\n .join(\"\");\n return rows;\n}\n\nexport function renderSubmissionRow(s: SubmissionRow): string {\n const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : \"—\";\n const type = escapeHtml(s.formType);\n const who = escapeHtml(s.name || \"(no name)\");\n const email = escapeHtml(s.email || \"\");\n const status = escapeHtml(s.status);\n const id = escapeHtml(s.id);\n const url = `/api/submissions/${encodeURIComponent(s.id)}/status`;\n const btn = (label: string, action: string) =>\n `<button class=\"subm-status\" data-id=\"${id}\" data-status=\"${action}\" data-url=\"${url}\">${label}</button>`;\n\n // One detail row per present field; absent fields are omitted (no blank rows).\n const kv = (label: string, value: string | number | null) =>\n value === null || value === \"\"\n ? \"\"\n : `<div class=\"subm-kv\"><span class=\"k\">${label}</span> ${escapeHtml(String(value))}</div>`;\n const sourceLink = s.sourceUrl\n ? `<div class=\"subm-kv\"><span class=\"k\">Source</span> <a href=\"${escapeHtml(safeUrl(s.sourceUrl))}\" rel=\"noopener noreferrer\">${escapeHtml(s.sourceUrl)}</a></div>`\n : \"\";\n const messageBlock = s.message\n ? `<div class=\"subm-kv\"><span class=\"k\">Message</span></div><div class=\"subm-msg\">${escapeHtml(s.message)}</div>`\n : \"\";\n const details = [\n kv(\"Phone\", s.phone),\n messageBlock,\n sourceLink,\n kv(\"UTM\", s.utm),\n extraFieldsList(s.extraFields),\n kv(\"Notify\", s.notifyStatus),\n kv(\"Resend ID\", s.resendMessageId),\n kv(\"Submission #\", s.submissionId),\n ].join(\"\");\n\n return `<li class=\"subm-item\">\n <details>\n <summary class=\"subm-head\"><strong>${type}</strong> · ${who} <span class=\"muted\">${email}</span> <span class=\"pill subm-${status}\">${status}</span> <span class=\"muted\">${when}</span></summary>\n <div class=\"subm-detail\">${details}</div>\n </details>\n <div class=\"subm-actions\">${btn(\"Read\", \"read\")}${btn(\"Archive\", \"archived\")}${btn(\"Spam\", \"spam\")}</div>\n </li>`;\n}\n\n/** CSS rules for the submission list UI. Append to the page's <style> block. */\nexport const SUBMISSION_STYLES = `.subm-list { list-style: none; padding: 0; margin: 0; }\n.subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { .subm-item { border-color: #2a2a2a; } }\n.subm-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }\n.subm-msg { margin: 0.35rem 0; white-space: pre-wrap; }\n.subm-detail { padding: 0.35rem 0 0.2rem; }\n.subm-kv { font-size: 0.9rem; margin: 0.15rem 0; }\n.subm-kv .k { color: #888; margin-right: 0.4rem; }\nsummary.subm-head { cursor: pointer; }\n.subm-actions { display: flex; gap: 0.4rem; }\nbutton.subm-status { font: inherit; padding: 0.25rem 0.7rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }\nbutton.subm-status:disabled { opacity: 0.6; cursor: default; }\n.spam-screen .spam-kv { font-size: 0.95rem; margin: 0.2rem 0; }\n.spam-screen .spam-kv .k { color: #888; display: inline-block; min-width: 11rem; }\n.pill.subm-new { background: #e8f0fe; color: #1a56db; }\n.pill.subm-read { background: #f0f0f0; color: #555; }\n.pill.subm-archived { background: #eee; color: #888; }\n.pill.subm-spam { background: #fdecea; color: #b00; }\n.subm-viewall { font-size: 0.8rem; font-weight: normal; margin-left: 0.4rem; white-space: nowrap; }`;\n\n/** Client-side JS for the submission status triage buttons. Insert bare (no <script> wrapper). */\nexport const SUBMISSION_STATUS_SCRIPT = `document.querySelectorAll(\"button.subm-status\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n try {\n const res = await fetch(b.dataset.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ status: b.dataset.status }),\n });\n b.textContent = res.ok ? \"✓\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });`;\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\n\n/** Status options the editor offers (the code Status union; rare Airtable values\n * like \"legacy\" are set directly in Airtable, not from the dashboard). */\nexport const SITE_STATUS_OPTIONS = [\n \"in development\",\n \"launch period\",\n \"maintenance\",\n \"hosting\",\n \"probably not our problem\",\n \"deprecated\",\n] as const;\nexport const FREQ_OPTIONS = [\"None\", \"Monthly\", \"Quarterly\", \"Yearly\"] as const;\n\ntype FieldKind = \"text\" | \"email\" | \"emails\" | \"enum\" | \"gitrepo\";\nexport type EditableField = {\n column: string;\n kind: FieldKind;\n options?: readonly string[];\n maxLen?: number;\n};\n\n/**\n * The ONLY columns the dashboard editor may write. `column` is the EXACT Airtable\n * field name (note the lowercase / em-dash / misspelled ones), kept in lockstep\n * with `mapRow` in src/reports/airtable/websites.ts.\n */\nexport const EDITABLE_SITE_FIELDS: Record<string, EditableField> = {\n pointOfContact: { column: \"point of contact\", kind: \"email\" },\n reportRecipientsTo: { column: \"Report recipients (To)\", kind: \"emails\" },\n reportRecipientsCc: { column: \"Report recipients (CC)\", kind: \"emails\" },\n copyIntro: { column: \"Copy — Intro\", kind: \"text\", maxLen: 2000 },\n copyContact: { column: \"Copy — Contact\", kind: \"text\", maxLen: 2000 },\n copyFooter: { column: \"Copy — Footer\", kind: \"text\", maxLen: 2000 },\n searchQuery: { column: \"Search query\", kind: \"text\", maxLen: 500 },\n ga4PropertyId: { column: \"GA4 property ID\", kind: \"text\", maxLen: 500 },\n gitRepo: { column: \"Git repo\", kind: \"gitrepo\" },\n status: { column: \"Status\", kind: \"enum\", options: SITE_STATUS_OPTIONS },\n maintenanceFreq: { column: \"maintenence freq\", kind: \"enum\", options: FREQ_OPTIONS },\n testingFreq: { column: \"testing freq\", kind: \"enum\", options: FREQ_OPTIONS },\n};\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst REPO_RE = /^[\\w.-]+\\/[\\w.-]+$/;\n\n/**\n * Validate/normalize a raw value for a field kind. Returns the string to write, or\n * `null` when invalid. Empty (after trim) is allowed — it clears the cell — for\n * every kind EXCEPT `enum`, which must be one of its options.\n */\nexport function normalizeFieldValue(f: EditableField, raw: string): string | null {\n const v = raw.trim();\n // Hard upper bound across every kind (text additionally enforces its own\n // tighter maxLen below) — a single absurdly long value can't reach Airtable.\n if (v.length > 2000) return null;\n switch (f.kind) {\n case \"enum\":\n return f.options!.includes(v) ? v : null;\n case \"email\":\n return v === \"\" ? \"\" : EMAIL_RE.test(v) ? v : null;\n case \"emails\": {\n if (v === \"\") return \"\";\n const parts = v\n .split(/[,\\n]/)\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n return parts.every((p) => EMAIL_RE.test(p)) ? parts.join(\", \") : null;\n }\n case \"gitrepo\":\n return v === \"\" ? \"\" : REPO_RE.test(v) ? v : null;\n case \"text\":\n return v.length <= (f.maxLen ?? 500) ? v : null;\n }\n}\n\n/** Injected IO — the `.mts` binds these to a live Airtable base; tests bind fakes. */\nexport type SiteDetailDeps = {\n getSite: (slug: string) => Promise<WebsiteRow | null>;\n updateField: (recordId: string, column: string, value: string) => Promise<void>;\n};\n\nexport type SiteDetailResult =\n | { status: \"updated\"; slug: string; field: string }\n | { status: \"bad-field\"; slug: string; field: string }\n | { status: \"invalid\"; slug: string; field: string }\n | { status: \"not-found\"; slug: string };\n\n/**\n * Write one allowlisted site-detail field from the dashboard editor.\n *\n * SAFETY: an unknown `field` is rejected BEFORE any read (a hand-crafted authed\n * POST can never write an arbitrary Airtable column), and the value is\n * validated/normalized per kind before the write — invalid input never reaches\n * Airtable.\n */\nexport async function setSiteDetail(\n deps: SiteDetailDeps,\n slug: string,\n field: string,\n rawValue: string,\n): Promise<SiteDetailResult> {\n const f = EDITABLE_SITE_FIELDS[field];\n if (!f) return { status: \"bad-field\", slug, field };\n const value = normalizeFieldValue(f, rawValue);\n if (value === null) return { status: \"invalid\", slug, field };\n const site = await deps.getSite(slug);\n if (!site) return { status: \"not-found\", slug };\n await deps.updateField(site.id, f.column, value);\n return { status: \"updated\", slug, field };\n}\n","import type { WebsiteRow, SecurityAdvisory } from \"../reports/airtable/websites.js\";\nimport { SEVERITY_RANK, siteSlug } from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { isPendingApproval } from \"../reports/airtable/reports.js\";\nimport type { SubmissionRow } from \"../reports/submission-row.js\";\nimport type { ScreenOutTotals } from \"../db/screenouts.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { FAVICON_LINK } from \"./favicon.js\";\nimport { onboardingStatus, missingOnboarding } from \"./onboarding.js\";\nimport { checklistFor, isChecklistComplete } from \"../reports/checklist.js\";\nimport {\n renderSubmissionRow,\n SUBMISSION_STYLES,\n SUBMISSION_STATUS_SCRIPT,\n} from \"./submission-view.js\";\nimport { SITE_STATUS_OPTIONS, FREQ_OPTIONS } from \"./site-details.js\";\n\nconst DASH = \"—\";\n\nfunction scoreTile(label: string, value: number | null): string {\n const display = value === null ? \"—\" : String(value);\n return `<div class=\"tile\"><div class=\"tile-value\">${escapeHtml(display)}</div><div class=\"tile-label\">${escapeHtml(label)}</div></div>`;\n}\n\nfunction healthTile(label: string, value: number | null, sub: string | null): string {\n const display = value === null ? \"—\" : String(value);\n const subLine = sub ? `<div class=\"tile-sub\">${escapeHtml(sub)}</div>` : \"\";\n return `<div class=\"tile\"><div class=\"tile-value\">${escapeHtml(display)}</div><div class=\"tile-label\">${escapeHtml(label)}</div>${subLine}</div>`;\n}\n\nfunction depsSub(majorBehind: number | null): string | null {\n if (majorBehind === null || majorBehind === 0) return null;\n return `${majorBehind} major behind`;\n}\n\nfunction securityTotal(site: WebsiteRow): number | null {\n const parts = [\n site.securityVulnsCritical,\n site.securityVulnsHigh,\n site.securityVulnsModerate,\n site.securityVulnsLow,\n ];\n if (parts.every((p) => p === null)) return null;\n return parts.reduce<number>((sum, p) => sum + (p ?? 0), 0);\n}\n\nfunction securitySub(site: WebsiteRow): string | null {\n const total = securityTotal(site);\n if (total === null || total === 0) return null;\n const c = site.securityVulnsCritical ?? 0;\n const h = site.securityVulnsHigh ?? 0;\n const m = site.securityVulnsModerate ?? 0;\n const l = site.securityVulnsLow ?? 0;\n return `${c}C / ${h}H / ${m}M / ${l}L`;\n}\n\n/** One advisory line: a severity pill, the vulnerable module, the advisory title, any CVEs,\n * and a link to the advisory when present. All Airtable-sourced text is escaped. */\nfunction advisoryRow(a: SecurityAdvisory): string {\n const sev = escapeHtml(a.severity);\n const module = escapeHtml(a.module);\n const title = a.title ? ` — ${escapeHtml(a.title)}` : \"\";\n const cves =\n a.cves.length > 0 ? ` <span class=\"muted\">(${escapeHtml(a.cves.join(\", \"))})</span>` : \"\";\n const link = a.url\n ? ` <a href=\"${escapeHtml(safeUrl(a.url))}\" rel=\"noopener noreferrer\">advisory ▸</a>`\n : \"\";\n return `<li class=\"vuln-item\">\n <span class=\"pill sev-${sev}\">${sev}</span>\n <strong>${module}</strong>${title}${cves}${link}\n </li>`;\n}\n\n/** The per-site vulnerability list — which packages are vulnerable, severity-sorted, not just the\n * totals tile. Omitted entirely when the site was never audited (`null`) or is clean (empty). */\nfunction securitySection(site: WebsiteRow): string {\n const advisories = site.securityAdvisories;\n if (!advisories || advisories.length === 0) return \"\";\n const sorted = [...advisories].sort(\n (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],\n );\n return `<div class=\"section vulns\">\n <h2>Vulnerabilities (${sorted.length})</h2>\n <ul class=\"vuln-list\">${sorted.map(advisoryRow).join(\"\")}</ul>\n </div>`;\n}\n\n/** The interactive operator-checklist for one pending report: one checkbox per\n * `checklistFor(reportType)` item, current state from `report.checklist`, each\n * carrying the report record id + the Airtable field name so the client can POST\n * to /api/reports/:id/checklist and re-gate the Approve button. Launch/Announcement\n * reports (empty checklist) render NOTHING — they are never gated. */\nfunction checklistBlock(r: ReportRow): string {\n const items = checklistFor(r.reportType);\n if (items.length === 0) return \"\";\n const rid = escapeHtml(r.id);\n const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;\n const boxes = items\n .map((item) => {\n const checked = r.checklist[item.field] === true ? \" checked\" : \"\";\n const ev = r.autoEvidence?.[item.field];\n // Auto-tick provenance beside the box: green when the signal proved it (box also `checked`),\n // amber when a signal ran but isn't green (box left unticked, reason shown). No evidence →\n // a plain manual checkbox, exactly as before.\n const badge = ev\n ? ev.result === \"pass\"\n ? ` <span class=\"auto-badge auto-pass\" title=\"${escapeHtml(ev.note)}\">auto ✓</span>`\n : ` <span class=\"auto-badge auto-amber\" title=\"${escapeHtml(ev.note)}\">auto: ${escapeHtml(ev.note)}</span>`\n : \"\";\n return `<label class=\"check-item\"><input type=\"checkbox\" class=\"checklist-checkbox\" data-checklist-report-id=\"${rid}\" data-field=\"${escapeHtml(item.field)}\" data-checklist-url=\"${escapeHtml(url)}\"${checked} /> ${escapeHtml(item.label)}${badge}</label>`;\n })\n .join(\"\");\n return `<div class=\"checklist\" data-checklist-for=\"${rid}\">${boxes}</div>`;\n}\n\n/** The Approve button for a pending report. Server-renders `disabled` when the\n * report's checklist is incomplete (the convenience gate — approve.ts + orchestrate.ts\n * are the hard backstops). Launch/Announcement have an empty checklist → never gated. */\nfunction approveButton(r: ReportRow): string {\n const disabled = isChecklistComplete(r) ? \"\" : \" disabled\";\n return `<button class=\"approve\" data-report-id=\"${escapeHtml(r.id)}\" data-approve-url=\"${escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve`)}\"${disabled}>Approve</button>`;\n}\n\nfunction pendingRow(r: ReportRow): string {\n const type = escapeHtml(r.reportType);\n const period = r.period ? escapeHtml(r.period) : \"—\";\n return `<li><div class=\"pending-head\"><strong>${type}</strong> <span class=\"muted\">${period}</span> ${approveButton(r)}</div>${checklistBlock(r)}</li>`;\n}\n\nfunction pendingSection(reports: ReportRow[]): string {\n const pending = reports.filter(isPendingApproval);\n if (pending.length === 0) return \"\";\n return `<div class=\"section pending\">\n <h2>Pending your yes (${pending.length})</h2>\n <ul class=\"pending-list\">${pending.map(pendingRow).join(\"\")}</ul>\n </div>`;\n}\n\n/** The GA \"Users\" cell for a report row: current count plus the signed delta vs\n * the previous period when both are known. Renders \"—\" when there's no current\n * count (GA not configured / fetch failed → blank in Airtable). */\nfunction gaUsersCell(r: ReportRow): string {\n if (r.gaUsersCurrent === null) return DASH;\n const current = String(r.gaUsersCurrent);\n if (r.gaUsersPrevious === null) return escapeHtml(current);\n const delta = r.gaUsersCurrent - r.gaUsersPrevious;\n const sign = delta > 0 ? \"+\" : \"\"; // negatives carry their own \"-\"; zero shows \"0\"\n return `${escapeHtml(current)} <span class=\"muted\">(${escapeHtml(`${sign}${delta}`)})</span>`;\n}\n\n/** The search-presence cell: the page-1 position when the site was found on\n * page 1, otherwise \"—\" (not-found OR the check didn't run). */\nfunction searchCell(r: ReportRow): string {\n if (r.searchFoundPage1 && r.searchPosition !== null) {\n return escapeHtml(`#${r.searchPosition}`);\n }\n return DASH;\n}\n\nfunction reportRow(r: ReportRow): string {\n const date = r.completedOn ? escapeHtml(r.completedOn) : DASH;\n const type = escapeHtml(r.reportType);\n const id = escapeHtml(r.reportId);\n const ga = gaUsersCell(r);\n const search = searchCell(r);\n const link = r.renderedHtmlAttachment\n ? `<a href=\"${escapeHtml(safeUrl(r.renderedHtmlAttachment.url))}\">view</a>`\n : `<span class=\"muted\">no attachment</span>`;\n const action = isPendingApproval(r) ? approveButton(r) : \"\";\n return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${ga}</td><td>${search}</td><td>${link}</td><td>${action}</td></tr>`;\n}\n\nconst SUBMISSIONS_PER_SITE_CAP = 25;\n\nfunction submissionsSection(submissions: SubmissionRow[], site: WebsiteRow): string {\n if (submissions.length === 0) return \"\";\n const recent = [...submissions]\n .sort((a, b) => (b.submittedAt ?? \"\").localeCompare(a.submittedAt ?? \"\"))\n .slice(0, SUBMISSIONS_PER_SITE_CAP);\n // The heading shows the true total; when we only list a slice, say so rather\n // than implying every one of the N is on the page.\n const note =\n submissions.length > recent.length\n ? `<span class=\"muted\"> — showing ${recent.length} of ${submissions.length}</span>`\n : \"\";\n const viewAll = `<a class=\"subm-viewall\" href=\"/submissions?site=${escapeHtml(siteSlug(site.name))}\">View all for this site →</a>`;\n return `<div class=\"section submissions\">\n <h2>Form submissions (${submissions.length})${note} ${viewAll}</h2>\n <ul class=\"subm-list\">${recent.map(renderSubmissionRow).join(\"\")}</ul>\n </div>`;\n}\n\nconst SPAM_WINDOW_DAYS = 30;\n\n/** The per-site spam panel: caught (honeypot/too-fast) + marked-spam from the screen-out\n * buckets, and delivered counted from the submissions loaded for this page within the\n * window. Omitted when there's nothing to show. `delivered` undercounts only if the site\n * exceeds the 200-row submissions fetch within the window (rare at fleet scale). */\nfunction spamScreenSection(\n totals: ScreenOutTotals | null,\n submissions: SubmissionRow[],\n now: Date,\n): string {\n const sinceMs = now.getTime() - SPAM_WINDOW_DAYS * 24 * 60 * 60 * 1000;\n const delivered = submissions.filter(\n (s) => s.submittedAt !== null && Date.parse(s.submittedAt) >= sinceMs,\n ).length;\n const t = totals ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };\n if (delivered === 0 && t.honeypot === 0 && t.tooFast === 0 && t.markedSpam === 0) return \"\";\n const row = (label: string, n: number) =>\n `<div class=\"spam-kv\"><span class=\"k\">${label}</span> ${escapeHtml(String(n))}</div>`;\n return `<div class=\"section spam-screen\">\n <h2>Spam screen (30d)</h2>\n ${row(\"Caught — honeypot\", t.honeypot)}\n ${row(\"Caught — too-fast\", t.tooFast)}\n ${row(\"Delivered\", delivered)}\n ${row(\"Marked spam\", t.markedSpam)}\n </div>`;\n}\n\n/** Setup (N/4) status near the page header. Lists the missing onboarding items\n * visibly (the cockpit chip only hovers them) so the operator sees what's left\n * to wire up without leaving the page. */\nfunction setupSection(site: WebsiteRow): string {\n const { score, total } = onboardingStatus(site);\n const missing = missingOnboarding(site);\n const detail =\n missing.length === 0\n ? `<span class=\"setup-ok\">complete</span>`\n : `<span class=\"setup-missing\">Missing: ${escapeHtml(missing.join(\", \"))}</span>`;\n return `<div class=\"setup-line\">Setup ${score}/${total} — ${detail}</div>`;\n}\n\n/** One read-only \"Site details\" row: a label and a value that degrades to \"—\". */\nfunction detailRow(label: string, value: string | null | undefined): string {\n const display =\n typeof value === \"string\" && value.trim().length > 0 ? escapeHtml(value.trim()) : DASH;\n return `<div class=\"detail\"><dt>${escapeHtml(label)}</dt><dd>${display}</dd></div>`;\n}\n\n/** A per-field \"saved\" indicator the page script flips to ✓ / ✗ after a POST. */\nfunction savedSpan(field: string): string {\n return `<span class=\"detail-saved\" data-for=\"${field}\"></span>`;\n}\n\n/** Editable `<select>` row for an enum field (Status / cadence). */\nfunction selectRow(\n label: string,\n field: string,\n options: readonly string[],\n current: string | null,\n url: string,\n): string {\n const inList = current !== null && options.includes(current);\n const opts = options\n .map(\n (o) =>\n `<option value=\"${escapeHtml(o)}\"${o === current ? \" selected\" : \"\"}>${escapeHtml(o)}</option>`,\n )\n .join(\"\");\n // When the stored value isn't one of the offered options (e.g. a null cadence,\n // or an Airtable-only \"legacy\" status), show a disabled placeholder selected\n // first so the operator must actively pick — never silently overwrites.\n const placeholder = inList ? \"\" : `<option value=\"\" disabled selected hidden>— select —</option>`;\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><select id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${placeholder}${opts}</select>${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable single-line `<input>` row for a text/email/repo field. */\nfunction inputRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><input type=\"text\" id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\" value=\"${escapeHtml(value ?? \"\")}\" />${savedSpan(field)}</dd></div>`;\n}\n\n/** Editable multi-line `<textarea>` row for the copy override fields. */\nfunction textareaRow(label: string, field: string, value: string | null, url: string): string {\n return `<div class=\"detail wide\"><dt><label for=\"detail-${field}\">${escapeHtml(label)}</label></dt><dd><textarea id=\"detail-${field}\" data-detail-field=\"${field}\" data-details-url=\"${url}\">${escapeHtml(value ?? \"\")}</textarea>${savedSpan(field)}</dd></div>`;\n}\n\n/** \"Site details\" section — inline-editable for the safe-text + operational fields\n * (writes via the authed /api/sites/:slug/details endpoint). `Last commit` stays\n * read-only (machine-derived). The Trigger Renovate button rides the heading. */\nfunction siteDetailsSection(site: WebsiteRow): string {\n const url = `/api/sites/${escapeHtml(siteSlug(site.name))}/details`;\n const lastCommit = site.lastCommitAt ? `${relativeTimeFromNow(site.lastCommitAt)}` : null;\n const rows = [\n selectRow(\"Status\", \"status\", SITE_STATUS_OPTIONS, site.status, url),\n selectRow(\"Maintenance cadence\", \"maintenanceFreq\", FREQ_OPTIONS, site.maintenanceFreq, url),\n selectRow(\"Testing cadence\", \"testingFreq\", FREQ_OPTIONS, site.testingFreq, url),\n inputRow(\"Report recipients (To)\", \"reportRecipientsTo\", site.reportRecipientsTo, url),\n inputRow(\"Report recipients (CC)\", \"reportRecipientsCc\", site.reportRecipientsCc, url),\n inputRow(\"Point of contact\", \"pointOfContact\", site.pointOfContact, url),\n inputRow(\"GA4 property\", \"ga4PropertyId\", site.ga4PropertyId, url),\n inputRow(\"Search query\", \"searchQuery\", site.searchQuery, url),\n inputRow(\"Git repo\", \"gitRepo\", site.gitRepo, url),\n textareaRow(\"Copy — Intro\", \"copyIntro\", site.copyIntro, url),\n textareaRow(\"Copy — Contact\", \"copyContact\", site.copyContact, url),\n textareaRow(\"Copy — Footer\", \"copyFooter\", site.copyFooter, url),\n detailRow(\"Last commit\", lastCommit),\n ].join(\"\");\n const triggerBtn = site.gitRepo?.trim()\n ? `<button class=\"trigger-renovate\" data-trigger-url=\"/api/sites/${escapeHtml(siteSlug(site.name))}/trigger-renovate\">Trigger Renovate</button>`\n : \"\";\n return `<div class=\"section site-details\">\n <h2>Site details ${triggerBtn}</h2>\n <dl class=\"details\">${rows}</dl>\n </div>`;\n}\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.75rem; }\n.meta { color: #666; margin-bottom: 2rem; }\n.meta a { color: inherit; }\n.audited { color: #999; font-size: 0.85rem; margin-bottom: 1.5rem; }\n.section { margin: 2rem 0; }\n.section h2 { font-size: 1.1rem; margin: 0 0 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #666; }\n.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; }\n.tile { padding: 1rem; border: 1px solid #ddd; border-radius: 6px; text-align: center; }\n@media (prefers-color-scheme: dark) { .tile { border-color: #333; } }\n.tile-value { font-size: 2rem; font-weight: 600; }\n.tile-label { font-size: 0.85rem; color: #666; margin-top: 0.25rem; }\n.tile-sub { font-size: 0.75rem; color: #999; margin-top: 0.15rem; }\ntable { width: 100%; border-collapse: collapse; }\nth, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { th, td { border-color: #2a2a2a; } }\n.muted { color: #999; }\n.empty { color: #999; padding: 1rem; border: 1px dashed #ccc; border-radius: 6px; text-align: center; }\nbutton.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; }\nbutton.approve:disabled { opacity: 0.6; cursor: default; }\n.pending-list { list-style: none; padding: 0; margin: 0; }\n.pending-list li { padding: 0.5rem; border-bottom: 1px solid #eee; }\n@media (prefers-color-scheme: dark) { .pending-list li { border-color: #2a2a2a; } }\n.pending-head { display: flex; align-items: center; gap: 0.5rem; }\n.checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }\n.check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }\n.check-item input { margin: 0; }\n.auto-badge { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; }\n.auto-pass { background: #e6f4ea; color: #137333; }\n.auto-amber { background: #fef7e0; color: #b06000; }\n.pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }\n.vuln-list { list-style: none; padding: 0; margin: 0; }\n.vuln-item { padding: 0.45rem 0; border-bottom: 1px solid #eee; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; }\n@media (prefers-color-scheme: dark) { .vuln-item { border-color: #2a2a2a; } }\n.pill.sev-critical { background: #fdecea; color: #b00; }\n.pill.sev-high { background: #fff0e6; color: #c4500a; }\n.pill.sev-moderate { background: #fff8e1; color: #8a6d00; }\n.pill.sev-low { background: #f0f0f0; color: #555; }\n.home { display: inline-block; font-size: 0.9rem; margin-bottom: 0.75rem; text-decoration: none; }\n.setup-line { font-size: 0.9rem; color: #666; margin-bottom: 1rem; }\n.setup-ok { color: #1b7a2f; font-weight: 600; }\n.setup-missing { color: #a65a00; }\n.details { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.5rem 1.5rem; margin: 0; }\n.detail { display: flex; flex-direction: column; }\n.detail.wide { grid-column: 1 / -1; }\n.detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }\n.detail dd { margin: 0; }\n.detail dd input, .detail dd select, .detail dd textarea { width: 100%; box-sizing: border-box; font: inherit; padding: 0.25rem 0.4rem; border: 1px solid #ccc; border-radius: 4px; background: transparent; color: inherit; }\n.detail dd textarea { min-height: 3.5rem; resize: vertical; }\n.detail-saved { font-size: 0.8rem; color: #2a8; }\nbutton.trigger-renovate { font: inherit; font-size: 0.8rem; padding: 0.15rem 0.6rem; margin-left: 0.5rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }\n`;\n\n/**\n * Render the per-site dashboard as a single HTML document. Pure function:\n * no Airtable access, no env reads, no I/O. The Netlify function handler\n * fetches data, then hands it here. Easier to unit-test, easier to render\n * a static preview from CLI later.\n */\nexport function renderSiteDashboardHtml(\n site: WebsiteRow,\n reports: ReportRow[],\n submissions: SubmissionRow[] = [],\n spamTotals: ScreenOutTotals | null = null,\n now: Date = new Date(),\n): string {\n const name = escapeHtml(site.name);\n const urlSafe = safeUrl(site.url);\n const allScoresNull =\n site.pScore === null && site.rScore === null && site.bpScore === null && site.seoScore === null;\n\n const scoresSection = allScoresNull\n ? `<div class=\"empty\">No lighthouse data yet — run <code>reddoor-maint audit --write-airtable</code> from the site checkout.</div>`\n : `<div class=\"tiles\">\n ${scoreTile(\"Performance\", site.pScore)}\n ${scoreTile(\"Accessibility\", site.rScore)}\n ${scoreTile(\"Best Practices\", site.bpScore)}\n ${scoreTile(\"SEO\", site.seoScore)}\n </div>`;\n\n const secTotal = securityTotal(site);\n const allHealthNull =\n site.a11yViolations === null && site.depsDrifted === null && secTotal === null;\n const healthSection = allHealthNull\n ? `<div class=\"empty\">No health data yet — run <code>reddoor-maint audit --write-airtable</code> from the site checkout.</div>`\n : `<div class=\"tiles\">\n ${healthTile(\"Accessibility issues\", site.a11yViolations, null)}\n ${healthTile(\"Dependency updates\", site.depsDrifted, depsSub(site.depsMajorBehind))}\n ${healthTile(\"Security alerts\", secTotal, securitySub(site))}\n </div>`;\n\n const auditedLine = site.lastLighthouseAuditAt\n ? `<div class=\"audited\">Last audited ${escapeHtml(relativeTimeFromNow(site.lastLighthouseAuditAt))}</div>`\n : \"\";\n\n // The report-history TABLE is the only place the \"recent 6\" slice belongs:\n // long enough to show a quarter of monthly reports plus the latest testing\n // report, short enough to keep the page a single scroll. The pending list +\n // approve buttons above intentionally see the FULL `reports` set — an OLD\n // pending report that falls outside this slice must still be approvable\n // (and must not disagree with the fleet banner, which counts ALL reports).\n const recentReports = [...reports]\n .sort((a, b) => (b.completedOn ?? \"\").localeCompare(a.completedOn ?? \"\"))\n .slice(0, 6);\n const reportsSection =\n recentReports.length === 0\n ? `<div class=\"empty\">No reports yet.</div>`\n : `<table>\n <thead><tr><th>Completed</th><th>Type</th><th>ID</th><th>GA users</th><th>Search</th><th>Report</th><th></th></tr></thead>\n <tbody>${recentReports.map(reportRow).join(\"\")}</tbody>\n </table>`;\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n ${FAVICON_LINK}\n <title>${name} — Reddoor maintenance</title>\n <style>${STYLES}${SUBMISSION_STYLES}</style>\n</head>\n<body>\n <a class=\"home\" href=\"/\">← Fleet home</a>\n <h1>${name}</h1>\n <div class=\"meta\"><a href=\"${escapeHtml(urlSafe)}\">${escapeHtml(site.url)}</a></div>\n ${auditedLine}\n ${setupSection(site)}\n ${pendingSection(reports)}\n\n <div class=\"section\">\n <h2>Lighthouse</h2>\n ${scoresSection}\n </div>\n\n <div class=\"section\">\n <h2>Site Health</h2>\n ${healthSection}\n </div>\n\n ${securitySection(site)}\n\n <div class=\"section\">\n <h2>Reports</h2>\n ${reportsSection}\n </div>\n\n ${siteDetailsSection(site)}\n ${spamScreenSection(spamTotals, submissions, now)}\n ${submissionsSection(submissions, site)}\n <script>\n document.querySelectorAll(\"button.approve\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n try {\n const res = await fetch(b.dataset.approveUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Approved\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n // Network rejection (offline, DNS, abort): mirror the !res.ok path so\n // the button doesn't sit permanently disabled reading \"Approve\".\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Trigger-renovate button: async on-demand dispatch (mirrors the cockpit).\n document.querySelectorAll(\"button.trigger-renovate\").forEach((b) => {\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n b.textContent = \"Dispatching…\";\n try {\n const res = await fetch(b.dataset.triggerUrl, { method: \"POST\" });\n b.textContent = res.ok ? \"Dispatched ✓\" : \"Failed\";\n if (!res.ok) b.disabled = false;\n } catch {\n b.textContent = \"Failed\";\n b.disabled = false;\n }\n });\n });\n // Site-details editor: save on change (selects) / blur (inputs+textareas, only\n // when the value actually changed). The per-field span shows ✓ / ✗.\n function saveDetail(el) {\n const span = document.querySelector('.detail-saved[data-for=\"' + el.dataset.detailField + '\"]');\n fetch(el.dataset.detailsUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ field: el.dataset.detailField, value: el.value }),\n })\n .then((r) => {\n if (span) span.textContent = r.ok ? \" ✓\" : \" ✗\";\n })\n .catch(() => {\n if (span) span.textContent = \" ✗\";\n });\n }\n document.querySelectorAll(\"select[data-detail-field]\").forEach((s) => {\n s.addEventListener(\"change\", () => saveDetail(s));\n });\n document.querySelectorAll(\"input[data-detail-field], textarea[data-detail-field]\").forEach((i) => {\n i.addEventListener(\"blur\", () => {\n if (i.value !== i.defaultValue) saveDetail(i);\n });\n });\n ${SUBMISSION_STATUS_SCRIPT}\n // Checklist gate: ticking a box POSTs the one field; the response { complete }\n // decides whether THIS report's Approve button is enabled. Scoped per report by\n // matching the checkbox's report id to the Approve button's id, so multiple\n // pending reports on one page never cross-toggle. On failure the checkbox reverts.\n document.querySelectorAll(\"input.checklist-checkbox\").forEach((cb) => {\n cb.addEventListener(\"change\", async () => {\n const reportId = cb.dataset.checklistReportId;\n const approveBtn = document.querySelector(\n 'button.approve[data-report-id=\"' + (window.CSS && CSS.escape ? CSS.escape(reportId) : reportId) + '\"]',\n );\n cb.disabled = true;\n try {\n const res = await fetch(cb.dataset.checklistUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ reportId, field: cb.dataset.field, value: cb.checked }),\n });\n if (!res.ok) throw new Error(\"bad status\");\n const data = await res.json();\n if (approveBtn) approveBtn.disabled = !data.complete;\n } catch {\n // Revert the optimistic flip so the box reflects the (unchanged) server state.\n cb.checked = !cb.checked;\n } finally {\n cb.disabled = false;\n }\n });\n });\n </script>\n</body>\n</html>`;\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { siteSlug } from \"../reports/airtable/websites.js\";\nimport type { CockpitModel, SiteCard, Tier, SubmissionEntry } from \"./fleet-cockpit.js\";\nimport { onboardingStatus, missingOnboarding } from \"./onboarding.js\";\nimport { relativeTimeFromNow } from \"./relative-time.js\";\nimport { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { FAVICON_LINK } from \"./favicon.js\";\n\nconst DASH = \"—\";\n\nfunction scoreSpan(category: \"perf\" | \"a11y-lh\" | \"bp\" | \"seo\", value: number | null): string {\n const display = value === null ? DASH : String(value);\n return `<span class=\"score ${category}\">${escapeHtml(display)}</span>`;\n}\n\nfunction a11ySpan(value: number | null): string {\n const display = value === null ? DASH : String(value);\n return `<span class=\"metric a11y\">${escapeHtml(display)}</span>`;\n}\n\nfunction depsSpan(\n drifted: number | null,\n majorBehind: number | null,\n outdated: number | null,\n): string {\n if (drifted === null || majorBehind === null) {\n return `<span class=\"metric deps\">${DASH}</span>`;\n }\n // Declared-range drift vs baseline, plus the real outdated-install count when\n // it was determined (null = not checked this run → omit, don't imply clean).\n const driftPart = drifted === 0 ? \"0\" : `${drifted} drifted (${majorBehind} major)`;\n const display = outdated === null ? driftPart : `${driftPart} · ${outdated} outdated`;\n return `<span class=\"metric deps\">${escapeHtml(display)}</span>`;\n}\n\nfunction securitySpan(\n critical: number | null,\n high: number | null,\n moderate: number | null,\n low: number | null,\n): string {\n if (critical === null || high === null || moderate === null || low === null) {\n return `<span class=\"metric sec\">${DASH}</span>`;\n }\n const total = critical + high + moderate + low;\n const display = total === 0 ? \"0\" : `${critical}C/${high}H/${moderate}M/${low}L`;\n return `<span class=\"metric sec\">${escapeHtml(display)}</span>`;\n}\n\nfunction card(site: WebsiteRow): string {\n const name = escapeHtml(site.name);\n // The per-site dashboard at /s/<slug> is operator-only, gated by the shared\n // dashboard password (no per-site token). Cockpit visibility is Status-based;\n // the caller filters the fleet view.\n const href = `/s/${escapeHtml(siteSlug(site.name))}`;\n const onboarding = onboardingStatus(site);\n const missing = missingOnboarding(site);\n const setupTitle = escapeHtml(\n missing.length === 0 ? \"Setup complete\" : `Missing: ${missing.join(\", \")}`,\n );\n const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);\n const safeSiteUrl = escapeHtml(safeUrl(site.url));\n const visibleUrl = escapeHtml(site.url);\n\n return `<article class=\"card\">\n <header class=\"card-head\">\n <a class=\"site\" href=\"${href}\">${name}</a>\n <a class=\"url\" href=\"${safeSiteUrl}\" target=\"_blank\" rel=\"noopener\">${visibleUrl}</a>\n <span class=\"setup\" title=\"${setupTitle}\">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>\n <span class=\"audited\">Audited: <strong>${escapeHtml(audited)}</strong></span>\n </header>\n <div class=\"card-metrics\">\n <span class=\"cluster lighthouse\">\n <span class=\"metric-label\">Perf</span> ${scoreSpan(\"perf\", site.pScore)}\n <span class=\"metric-label\">Access</span> ${scoreSpan(\"a11y-lh\", site.rScore)}\n <span class=\"metric-label\">BP</span> ${scoreSpan(\"bp\", site.bpScore)}\n <span class=\"metric-label\">SEO</span> ${scoreSpan(\"seo\", site.seoScore)}\n </span>\n <span class=\"cluster health\">\n <span class=\"metric-label\">a11y</span> ${a11ySpan(site.a11yViolations)}\n <span class=\"metric-label\">deps</span> ${depsSpan(site.depsDrifted, site.depsMajorBehind, site.depsOutdated)}\n <span class=\"metric-label\">sec</span> ${securitySpan(\n site.securityVulnsCritical,\n site.securityVulnsHigh,\n site.securityVulnsModerate,\n site.securityVulnsLow,\n )}\n </span>\n </div>\n </article>`;\n}\n\nconst STYLES = `\n:root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }\n@media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }\nh1 { margin: 0 0 0.25rem; font-size: 1.75rem; }\n.meta { color: #666; margin-bottom: 1.5rem; }\n.empty { color: #999; padding: 2rem; text-align: center; border: 1px dashed #ccc; border-radius: 6px; }\n.cards { display: flex; flex-direction: column; gap: 0.75rem; }\n.card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 0.9rem 1.1rem; }\n@media (prefers-color-scheme: dark) { .card { border-color: #2a2a2a; background: #181818; } }\n.card-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: baseline; }\n.card-head .site { font-weight: 600; font-size: 1.05rem; }\n.card-head .url { color: #666; font-size: 0.85rem; }\n.card-head .setup, .card-head .audited { color: #666; font-size: 0.85rem; }\n.card-head .setup { margin-left: auto; }\n.card-metrics { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin-top: 0.5rem; font-variant-numeric: tabular-nums; }\n.cluster { display: inline-flex; gap: 0.5rem; align-items: baseline; }\n.cluster.lighthouse .score { display: inline-block; min-width: 2.25rem; text-align: right; }\n.metric-label { color: #999; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }\n.metric { font-feature-settings: \"tnum\"; }\n.summary { display:flex; flex-wrap:wrap; gap:0.5rem 1.25rem; align-items:baseline; margin-bottom:0.5rem; }\n.summary .tier { font-weight:700; }\n.summary .heads { color:#666; font-size:0.9rem; }\n.spam-rollup { font-size:0.9rem; margin-bottom:1rem; }\n.muted { color:#999; }\n.subm-viewall { font-size:0.8rem; font-weight:normal; margin-left:0.4rem; white-space:nowrap; }\n.filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }\n.filters button { font:inherit; font-size:0.85rem; padding:0.25rem 0.7rem; border:1px solid #ccc; border-radius:999px; background:transparent; color:inherit; cursor:pointer; }\n.filters button[aria-pressed=\"true\"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }\n@media (prefers-color-scheme: dark) { .filters button[aria-pressed=\"true\"] { background:#e8e8e8; color:#111; } }\n.fleet-actions { margin-bottom:1.25rem; }\n.refresh-fleet { font:inherit; font-size:0.85rem; padding:0.3rem 0.8rem; border:1px solid #1a1a1a; border-radius:999px; background:#1a1a1a; color:#fff; cursor:pointer; }\n.refresh-fleet:disabled { opacity:0.6; cursor:default; }\n@media (prefers-color-scheme: dark) { .refresh-fleet { background:#e8e8e8; color:#111; border-color:#e8e8e8; } }\n.rf-status { margin-top:0.6rem; font-size:0.85rem; }\n.rf-row { padding:0.1rem 0; }\n.rf-row a { margin-left:0.3rem; }\n.rf-sub { color:#999; font-size:0.8rem; margin-left:1.1rem; }\n.rf-spin { display:inline-block; width:0.8em; height:0.8em; border:2px solid #999; border-top-color:transparent; border-radius:50%; animation:rf-spin 0.8s linear infinite; vertical-align:-0.1em; }\n@keyframes rf-spin { to { transform:rotate(360deg); } }\ndetails.tier { margin:0.75rem 0; }\ndetails.tier > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }\n.approve-strip { border:1px solid #ffe08a; background:#fff8e1; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }\n@media (prefers-color-scheme: dark) { .approve-strip { background:#241f00; border-color:#5a4d00; } }\n.approve-strip h2 { font-size:1rem; margin:0 0 0.5rem; }\n.approve-row { display:flex; flex-wrap:wrap; gap:0.5rem 1rem; align-items:center; padding:0.25rem 0; }\n.pill { font-size:0.75rem; padding:0.1rem 0.5rem; border-radius:999px; font-weight:700; }\n.pill.attention { background:#fdecea; color:#b00; }\n.pill.watch { background:#fff4e5; color:#a65a00; }\n.pill.healthy { background:#e8f5e9; color:#1b7a2f; }\n.chips { display:flex; flex-wrap:wrap; gap:0.4rem; margin-top:0.5rem; }\n.chip { font-size:0.8rem; padding:0.1rem 0.5rem; border-radius:6px; background:#f0f0f0; }\n@media (prefers-color-scheme: dark) { .chip { background:#222; } }\n.chip.critical { background:#fdecea; color:#b00; }\n.chip.stuck { border:1px solid #b00; font-weight:600; }\n.badge { font-weight:700; color:#C00; font-size:0.72rem; margin-right:0.25rem; }\n.all-clear { background:#e8f5e9; color:#1b7a2f; padding:0.6rem 1rem; border-radius:8px; margin-bottom:1.25rem; font-weight:600; }\n@media (prefers-color-scheme: dark) { .all-clear { background:#10240f; color:#7fce85; } }\n`;\n\nconst TIER_META: Record<Tier, { emoji: string; label: string; open: boolean }> = {\n attention: { emoji: \"🔴\", label: \"Needs attention\", open: true },\n watch: { emoji: \"🟡\", label: \"Watch\", open: false },\n healthy: { emoji: \"🟢\", label: \"Healthy\", open: false },\n};\n\nconst FILTERS = [\n \"all\",\n \"vulns\",\n \"lighthouse\",\n \"delivery\",\n \"prs\",\n \"ci\",\n \"auto-fix-failed\",\n \"stale\",\n \"no-domain\",\n \"pending\",\n \"submissions\",\n] as const;\n\nfunction summaryBar(model: CockpitModel): string {\n const s = model.summary;\n const heads = [\n `${s.criticalHighVulns} critical/high vuln${s.criticalHighVulns === 1 ? \"\" : \"s\"}`,\n `${s.lighthouseBelowFloor} Lighthouse<75`,\n `${s.deliveryFailures} delivery`,\n `${s.renovateFailing} PRs failing`,\n `${s.ciRed} CI red`,\n `${s.autoFixStuck} auto-fix stuck`,\n `${s.pending} pending`,\n `${s.newSubmissions ?? 0} new`,\n ].join(\" · \");\n const chips = FILTERS.map(\n (f) =>\n `<button type=\"button\" data-filter=\"${f}\" aria-pressed=\"${f === \"all\" ? \"true\" : \"false\"}\">${f}</button>`,\n ).join(\"\");\n return `<div class=\"summary\">\n <span class=\"tier\">🔴 ${s.attention} needs attention</span>\n <span class=\"tier\">🟡 ${s.watch} watch</span>\n <span class=\"tier\">🟢 ${s.healthy} healthy</span>\n </div>\n <div class=\"summary heads\">${escapeHtml(heads)}</div>\n <div class=\"filters\">${chips}</div>\n <div class=\"fleet-actions\">\n <button type=\"button\" class=\"refresh-fleet\" data-refresh-url=\"/api/fleet/refresh\">↻ Refresh fleet state</button>\n <div id=\"rf-status\" class=\"rf-status\" aria-live=\"polite\"></div>\n </div>`;\n}\n\n/** One-line fleet spam roll-up beneath the summary: caught (honeypot+too-fast) vs\n * through (marked spam) over the window. Omitted when there's no spam data, so a\n * fleet with no screen-out buckets reads clean rather than \"caught 0 · through 0\". */\nfunction spamRollup(model: CockpitModel): string {\n const s = model.spam;\n if (!s || (s.caught === 0 && s.through === 0)) return \"\";\n return `<div class=\"spam-rollup muted\">🛡 Spam (30d) — caught ${s.caught} · through ${s.through}</div>`;\n}\n\n/** Affirmative all-clear when nothing is on the 🔴 tier (spec §5.2/§12) — so a\n * healthy or empty fleet reads as \"all clear\", not three bare \"None.\" rows. */\nfunction allClearBanner(model: CockpitModel): string {\n if (model.summary.attention > 0) return \"\";\n const msg =\n model.cards.length === 0\n ? \"No sites on the fleet view yet.\"\n : \"All clear — nothing needs your attention.\";\n return `<div class=\"all-clear\">✓ ${escapeHtml(msg)}</div>`;\n}\n\nfunction approveStrip(model: CockpitModel): string {\n if (model.pending.length === 0) return \"\";\n const rows = model.pending\n .map((p) => {\n const href = `/s/${escapeHtml(p.slug)}`;\n const url = `/api/reports/${encodeURIComponent(p.reportId)}/approve`;\n return `<div class=\"approve-row\" data-signal=\"pending\">\n <strong>${escapeHtml(p.siteName)}</strong>\n <span class=\"muted\">${escapeHtml(p.reportType)} ${escapeHtml(p.period)}</span>\n <button class=\"approve\" data-report-id=\"${escapeHtml(p.reportId)}\" data-approve-url=\"${escapeHtml(url)}\">Approve</button>\n <a href=\"${href}\">open ▸</a>\n </div>`;\n })\n .join(\"\");\n return `<section class=\"approve-strip\" data-tier=\"pending\">\n <h2>Approve (${model.pending.length}) — your daily yes</h2>\n ${rows}\n </section>`;\n}\n\n/** Most submissions to render in the cockpit strip. The heading still shows the\n * true fleet total; overflow is triaged on each site's page (which lists 25). */\nconst SUBMISSIONS_STRIP_CAP = 10;\n\nfunction submissionsStrip(model: CockpitModel): string {\n const subs: SubmissionEntry[] = model.submissions ?? [];\n if (subs.length === 0) return \"\";\n // Render the newest N only — the strip is a triage prompt, not the inbox. Sort\n // defensively (the builder preserves input order, which is already newest-first).\n const shown = [...subs]\n .sort((a, b) => (b.submittedAt ?? \"\").localeCompare(a.submittedAt ?? \"\"))\n .slice(0, SUBMISSIONS_STRIP_CAP);\n const rows = shown\n .map((sub) => {\n const href = `/s/${escapeHtml(sub.slug)}`;\n const when = sub.submittedAt ? escapeHtml(relativeTimeFromNow(sub.submittedAt)) : \"\";\n const who = escapeHtml(sub.name || sub.email);\n return `<div class=\"approve-row\" data-signal=\"submissions\">\n <strong>${escapeHtml(sub.siteName)}</strong>\n <span class=\"muted\">${escapeHtml(sub.formType)} — ${who}</span>\n <span class=\"muted\">${when}</span>\n <a href=\"${href}\">open ▸</a>\n </div>`;\n })\n .join(\"\");\n const overflow = subs.length - shown.length;\n const more =\n overflow > 0\n ? `<div class=\"approve-row subm-more muted\"><a href=\"/submissions\">+${overflow} more — view all submissions</a></div>`\n : \"\";\n return `<section class=\"approve-strip subm-strip\" data-tier=\"submissions\">\n <h2>📥 New submissions (${subs.length}) <a class=\"subm-viewall\" href=\"/submissions\">View all →</a></h2>\n ${rows}${more}\n </section>`;\n}\n\nfunction submBadge(c: SiteCard): string {\n const n = c.newSubmissions ?? 0;\n return n > 0 ? `<span class=\"chip\">📥 ${n} new</span>` : \"\";\n}\n\nconst PILL_LABEL: Record<Tier, string> = { attention: \"failing\", watch: \"watch\", healthy: \"ok\" };\n\nfunction attentionBadge(status?: string): string {\n if (status === \"new\") return `<span class=\"badge\">NEW</span>`;\n if (status === \"worse\") return `<span class=\"badge\">WORSE</span>`;\n return \"\";\n}\n\nfunction chips(c: SiteCard): string {\n const items = c.items.map((it) => {\n const cls = it.autoFixExhausted\n ? \"chip critical stuck\"\n : it.severity === \"critical\"\n ? \"chip critical\"\n : \"chip\";\n return `<span class=\"${cls}\">${attentionBadge(it.status)}${escapeHtml(it.title)}</span>`;\n });\n for (const reason of c.watchReasons)\n items.push(`<span class=\"chip\">${escapeHtml(reason)}</span>`);\n return items.length ? `<div class=\"chips\">${items.join(\"\")}</div>` : \"\";\n}\n\n/** Space-separated signal tags for the client filter. Attention-item kinds\n * (\"vulns\"/\"lighthouse\"/\"delivery\"/\"prs\" from renovate/\"ci\") plus the structured\n * watch signals (\"lighthouse\" for a sub-floor-band score, \"stale\" for an old\n * commit) — so a watch-band Lighthouse card still matches the \"lighthouse\" filter. */\nfunction signalsAttr(c: SiteCard): string {\n const kinds = new Set<string>();\n for (const it of c.items) {\n kinds.add(it.kind === \"vuln\" ? \"vulns\" : it.kind === \"renovate\" ? \"prs\" : it.kind);\n }\n if (c.items.some((it) => it.autoFixExhausted)) kinds.add(\"auto-fix-failed\");\n for (const sig of c.watchSignals) kinds.add(sig);\n return [...kinds].join(\" \");\n}\n\n/** On-demand Renovate trigger button — only for repo-backed sites (nothing to\n * dispatch otherwise). Posts to the authed /api/sites/:slug/trigger-renovate. */\nfunction triggerRenovateBtn(c: SiteCard): string {\n if (!c.site.gitRepo?.trim()) return \"\";\n const url = `/api/sites/${escapeHtml(siteSlug(c.site.name))}/trigger-renovate`;\n return `<button class=\"trigger-renovate\" data-trigger-url=\"${url}\">Trigger Renovate</button>`;\n}\n\nfunction cockpitCard(c: SiteCard): string {\n const base = card(c.site); // existing header + metrics markup\n const pill = `<span class=\"pill ${c.tier}\">${PILL_LABEL[c.tier]}</span>`;\n const extra = `${pill}${chips(c)}${submBadge(c)}${triggerRenovateBtn(c)}`;\n const opening = `<article class=\"card\" data-signals=\"${signalsAttr(c)}\">`;\n // Inject the pill + chips before the article's closing tag, and add the filter\n // hook. Function replacers so a `$` in escaped chip text can't be read as a\n // String.replace special ($&, $1, …).\n return base\n .replace('<article class=\"card\">', () => opening)\n .replace(\"</article>\", () => `${extra}</article>`);\n}\n\nconst FILTER_SCRIPT = `<script>\n(function(){\n var btns = document.querySelectorAll('.filters button');\n var cards = document.querySelectorAll('.cards .card');\n var details = document.querySelectorAll('details.tier');\n btns.forEach(function(b){\n b.addEventListener('click', function(){\n var f = b.getAttribute('data-filter');\n btns.forEach(function(x){ x.setAttribute('aria-pressed', x===b ? 'true':'false'); });\n // \"pending\" lives on the approve strip, not on tier cards — just jump to it,\n // never hide the triage cards (else the whole board blanks).\n if (f === 'pending') { var s = document.querySelector('.approve-strip'); if (s) s.scrollIntoView({behavior:'smooth'}); return; }\n if (f === 'submissions') { var ss = document.querySelector('[data-tier=\"submissions\"]'); if (ss) ss.scrollIntoView({behavior:'smooth'}); return; }\n if (f !== 'all') details.forEach(function(d){ d.open = true; });\n cards.forEach(function(c){\n var sig = (c.getAttribute('data-signals')||'').split(' ');\n c.style.display = (f==='all' || sig.indexOf(f)!==-1) ? '' : 'none';\n });\n });\n });\n // approve buttons: mirror the per-site dashboard's inline POST.\n document.querySelectorAll('button.approve').forEach(function(b){\n b.addEventListener('click', async function(){\n b.disabled = true; b.textContent = 'Approving…';\n try { var res = await fetch(b.dataset.approveUrl, { method: 'POST' });\n b.textContent = res.ok ? 'Approved ✓' : 'Failed'; }\n catch(e){ b.textContent = 'Failed'; b.disabled = false; }\n });\n });\n // trigger-renovate buttons: fire the on-demand dispatch (async, fire-and-forget).\n document.querySelectorAll('button.trigger-renovate').forEach(function(b){\n b.addEventListener('click', async function(){\n b.disabled = true; b.textContent = 'Dispatching…';\n try { var res = await fetch(b.dataset.triggerUrl, { method: 'POST' });\n b.textContent = res.ok ? 'Dispatched ✓' : 'Failed';\n if (!res.ok) b.disabled = false; }\n catch(e){ b.textContent = 'Failed'; b.disabled = false; }\n });\n });\n // fleet-refresh live status: dispatch, then poll the actual runs and follow them.\n // Vanilla JS, string-concat only (no template literals) — this lives inside a TS\n // template string, so backticks or interpolation syntax would break the server render.\n var RF_KEY = 'reddoor:fleet-refresh';\n var RF_POLL_MS = 10000;\n var RF_MAX_MS = 90 * 60 * 1000; // safety ceiling; a full fleet Lighthouse run was ~48 min (2026-06-24)\n function rfPanel(){ return document.getElementById('rf-status'); }\n function rfStop(){ try { localStorage.removeItem(RF_KEY); } catch(e){} }\n // Per-workflow ETA hint (the lighthouse fleet sweep runs ~48 min; security ~1-2 min).\n function rfEta(label){ return label === 'lighthouse' ? '~48m' : label === 'security' ? '~2m' : ''; }\n // Map a raw GitHub step name to a coarse human phase. Order matters (the audit step\n // name also contains 'browser'; 'playwright install' also contains 'install').\n function rfPhase(step){\n if (!step) return '';\n var s = step.toLowerCase();\n if (s.indexOf('audit') !== -1 || s.indexOf('lighthouse') !== -1) return 'auditing the fleet…';\n if (s.indexOf('build') !== -1) return 'building…';\n if (s.indexOf('playwright') !== -1 || s.indexOf('browser') !== -1) return 'installing browsers…';\n if (s.indexOf('install') !== -1 || s.indexOf('depend') !== -1) return 'installing dependencies…';\n if (s.indexOf('set up') !== -1 || s.indexOf('checkout') !== -1) return 'setting up…';\n return step;\n }\n // Safe to build raw HTML: workflow/state/step are server-fixed (enums + GitHub step\n // names) and url is GitHub's own html_url for our central repo — none are\n // user-supplied. Don't interpolate untrusted fields here without escaping.\n function rfRender(status, startedAt){\n var failed = function(s){ return s === 'failure' || s === 'cancelled' || s === 'timed_out'; };\n var mins = Math.floor((Date.now() - startedAt) / 60000);\n var elapsed = mins < 1 ? '<1m' : mins + 'm';\n return status.perWorkflow.map(function(w){\n var label = w.workflow.replace('.yml','').replace('fleet-','');\n var done = w.state === 'success';\n var isFailed = failed(w.state);\n var icon = done ? '✓' : isFailed ? '✗' : '<span class=\"rf-spin\"></span>';\n var line = icon + ' ' + label + ' — ' + w.state.replace('_',' ');\n if (!done && !isFailed){\n var phase = rfPhase(w.step);\n var eta = rfEta(label);\n var detail = [];\n if (phase) detail.push(phase);\n detail.push(elapsed + (eta ? ' / ' + eta : ''));\n var link = w.url ? ' · <a href=\"'+w.url+'\" target=\"_blank\" rel=\"noopener\">view run ↗</a>' : '';\n line += '<div class=\"rf-sub\">' + detail.join(' · ') + link + '</div>';\n } else if (isFailed && w.url){\n line += ' <a href=\"'+w.url+'\" target=\"_blank\" rel=\"noopener\">run</a>';\n }\n return '<div class=\"rf-row\">' + line + '</div>';\n }).join('');\n }\n function rfPoll(since, startedAt){\n fetch('/api/fleet/refresh/status?since=' + encodeURIComponent(since)).then(function(res){\n if (res.status === 401) return { authFail: true };\n return res.ok ? res.json() : null;\n }).then(function(data){\n var p = rfPanel();\n if (data && data.authFail){\n if (p) p.innerHTML += '<div class=\"rf-row\">Session expired — reload to sign in.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Refresh fleet state'; }\n rfStop(); return;\n }\n if (data && data.status){\n if (p) p.innerHTML = rfRender(data.status, startedAt);\n if (data.status.allDone){\n if (!data.status.anyFailure){\n if (p) p.innerHTML += '<div class=\"rf-row\">✓ Done — reloading…</div>';\n rfStop(); setTimeout(function(){ location.reload(); }, 2000); return;\n }\n if (p) p.innerHTML += '<div class=\"rf-row\"><button type=\"button\" onclick=\"location.reload()\">Reload</button></div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Refresh fleet state'; }\n rfStop(); return;\n }\n }\n if (Date.now() - startedAt > RF_MAX_MS){\n if (p) p.innerHTML += '<div class=\"rf-row\">Still running — reload later.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Refresh fleet state'; }\n rfStop(); return;\n }\n setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);\n }).catch(function(){\n var p = rfPanel();\n if (Date.now() - startedAt > RF_MAX_MS){\n if (p) p.innerHTML += '<div class=\"rf-row\">Still running — reload later.</div>';\n if (rf){ rf.disabled = false; rf.textContent = '↻ Refresh fleet state'; }\n rfStop(); return;\n }\n setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);\n });\n }\n function rfBegin(since, startedAt){\n try { localStorage.setItem(RF_KEY, JSON.stringify({ since: since, startedAt: startedAt })); } catch(e){}\n var p = rfPanel(); if (p) p.innerHTML = '<div class=\"rf-row\"><span class=\"rf-spin\"></span> starting…</div>';\n rfPoll(since, startedAt);\n }\n var rf = document.querySelector('button.refresh-fleet');\n if (rf) rf.addEventListener('click', async function(){\n if (!confirm('Kick off the security + Lighthouse sweeps for the whole fleet? They take a few minutes.')) return;\n rf.disabled = true; rf.textContent = 'Refreshing…';\n try {\n var res = await fetch(rf.dataset.refreshUrl, { method: 'POST' });\n if (res.ok){\n var data = await res.json();\n rf.textContent = '↻ Refresh running…';\n if (data && data.since) rfBegin(data.since, Date.now());\n } else { rf.textContent = 'Failed to start'; rf.disabled = false; }\n } catch(e){ rf.textContent = 'Failed to start'; rf.disabled = false; }\n });\n // Resume-on-reload: if a refresh is in flight (<90 min old), keep following it.\n try {\n var rfSaved = JSON.parse(localStorage.getItem(RF_KEY) || 'null');\n if (rfSaved && rfSaved.since && rfSaved.startedAt && (Date.now() - rfSaved.startedAt) < RF_MAX_MS){\n if (rf){ rf.disabled = true; rf.textContent = '↻ Refresh running…'; }\n rfBegin(rfSaved.since, rfSaved.startedAt);\n } else if (rfSaved) { rfStop(); }\n } catch(e){}\n})();\n</script>`;\n\n/**\n * Render the fleet cockpit as a single HTML document. Pure function: no Airtable\n * access, no env reads, no I/O. The Netlify function handler builds the\n * CockpitModel (visible-site filter, tiering, NEW/WORSE badging, pending list)\n * and hands it here. Renders the doc shell + summary bar + filter chips + pinned\n * approve strip + three <details> tier sections of cards.\n */\nexport function renderCockpitHtml(model: CockpitModel): string {\n const total = model.cards.length;\n const tiers: Tier[] = [\"attention\", \"watch\", \"healthy\"];\n const sections = tiers\n .map((tier) => {\n const cards = model.cards.filter((c) => c.tier === tier);\n const meta = TIER_META[tier];\n const body =\n cards.length === 0\n ? `<div class=\"empty\">None.</div>`\n : `<div class=\"cards\">${cards.map(cockpitCard).join(\"\")}</div>`;\n return `<details class=\"tier\" data-tier=\"${tier}\"${meta.open ? \" open\" : \"\"}>\n <summary>${meta.emoji} ${meta.label} (${cards.length})</summary>\n ${body}\n </details>`;\n })\n .join(\"\");\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n ${FAVICON_LINK}\n <title>Reddoor maintenance — fleet cockpit</title>\n <style>${STYLES}</style>\n</head>\n<body>\n <h1>Reddoor fleet cockpit</h1>\n <div class=\"meta\">${total} site${total === 1 ? \"\" : \"s\"} on the Reddoor stack.</div>\n ${summaryBar(model)}\n ${allClearBanner(model)}\n ${approveStrip(model)}\n ${sections}\n ${spamRollup(model)}\n ${submissionsStrip(model)}\n ${FILTER_SCRIPT}\n</body>\n</html>`;\n}\n","import { timingSafeEqual } from \"node:crypto\";\n\n/**\n * Verify an `Authorization: Basic <base64>` header against the configured\n * dashboard password. Username is intentionally ignored — operators may\n * type anything when the browser prompts; only the password gates entry.\n *\n * Returns false for any of:\n * - missing/empty Authorization header\n * - non-Basic auth scheme\n * - malformed base64 or payload (no colon to split user:password)\n * - wrong password\n * - expected password missing (DASHBOARD_PASSWORD not configured)\n *\n * Wrong-password compare is constant-time; BYTE lengths are checked first\n * (timingSafeEqual throws a RangeError on a buffer-length mismatch, and the\n * length itself doesn't leak — operator's password length is fixed per deploy).\n * Comparing JS-string lengths instead of byte lengths could let an equal-char\n * but unequal-byte password (a multibyte char) reach timingSafeEqual and throw,\n * turning a wrong password into an uncaught 500.\n */\nexport function verifyBasicAuth(\n authHeader: string | null | undefined,\n expectedPassword: string | null,\n): boolean {\n if (!authHeader || !expectedPassword) return false;\n // RFC 7235: scheme is case-insensitive.\n const match = /^basic\\s+(.+)$/i.exec(authHeader.trim());\n if (!match) return false;\n let decoded: string;\n try {\n decoded = Buffer.from(match[1]!, \"base64\").toString(\"utf-8\");\n } catch {\n return false;\n }\n // Base64-decoding never throws in Node, but a payload of garbage may\n // produce a string with no colon. user:password form is required.\n const colonIdx = decoded.indexOf(\":\");\n if (colonIdx === -1) return false;\n const provided = decoded.slice(colonIdx + 1);\n // Compare BYTE lengths, not JS-string lengths: timingSafeEqual compares the\n // underlying buffers and throws a RangeError if they differ in byte length.\n // Two strings can share a JS length but differ in UTF-8 byte length (e.g. a\n // multibyte char), so a JS-length guard would let mismatched buffers through\n // and crash the handler with a 500.\n const a = Buffer.from(provided, \"utf-8\");\n const b = Buffer.from(expectedPassword, \"utf-8\");\n if (a.length !== b.length) return false;\n return timingSafeEqual(a, b);\n}\n"],"mappings":"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","names":["DASH","STYLES","chips"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddoorla/maintenance",
3
- "version": "0.60.0",
3
+ "version": "0.60.1",
4
4
  "description": "Canonical maintenance configs, audits, and recipes for the reddoor stack.",
5
5
  "type": "module",
6
6
  "license": "MIT",