@reddoorla/maintenance 0.59.0 → 0.60.0
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/{chunk-MKSZBODM.js → chunk-D3LFQNJK.js} +2 -2
- package/dist/{chunk-J4ECCFRT.js → chunk-XB6T4ETK.js} +1 -1
- package/dist/chunk-XB6T4ETK.js.map +1 -0
- package/dist/cli/bin.js +4 -4
- package/dist/{github-signals-NCDBYSVV.js → github-signals-UL2BBS3Q.js} +2 -2
- package/dist/index.js +99 -1
- package/dist/index.js.map +1 -1
- package/dist/{launch-35KSDHYO.js → launch-UWCI3JRM.js} +3 -3
- package/dist/{renovate-dispatch-P2FAJTQK.js → renovate-dispatch-NG5KUQKY.js} +2 -2
- package/dist/{self-updating-HLMF7KEG.js → self-updating-CFHXJSAX.js} +3 -3
- package/package.json +1 -1
- package/dist/chunk-J4ECCFRT.js.map +0 -1
- /package/dist/{chunk-MKSZBODM.js.map → chunk-D3LFQNJK.js.map} +0 -0
- /package/dist/{github-signals-NCDBYSVV.js.map → github-signals-UL2BBS3Q.js.map} +0 -0
- /package/dist/{launch-35KSDHYO.js.map → launch-UWCI3JRM.js.map} +0 -0
- /package/dist/{renovate-dispatch-P2FAJTQK.js.map → renovate-dispatch-NG5KUQKY.js.map} +0 -0
- /package/dist/{self-updating-HLMF7KEG.js.map → self-updating-CFHXJSAX.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
makeGitHub
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-XB6T4ETK.js";
|
|
4
4
|
import {
|
|
5
5
|
templatesByName
|
|
6
6
|
} from "./chunk-CASCWQQX.js";
|
|
@@ -159,4 +159,4 @@ async function selfUpdating(site, deps = {}) {
|
|
|
159
159
|
export {
|
|
160
160
|
selfUpdating
|
|
161
161
|
};
|
|
162
|
-
//# sourceMappingURL=chunk-
|
|
162
|
+
//# sourceMappingURL=chunk-D3LFQNJK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/github/gh.ts"],"sourcesContent":["import { defaultSpawn, type SpawnFn } from \"../audits/util/spawn.js\";\n\n/** Aggregate CI state of a PR's head commit, normalized from GitHub's rollup. */\nexport type CiState = \"passing\" | \"failing\" | \"pending\" | \"none\";\n\n/** GitHub's computed mergeability of a PR. `UNKNOWN` is transient — GitHub is\n * still computing it (e.g. right after a push) — so it should be read as \"not\n * known to conflict\", never as conflicting. */\nexport type PrMergeable = \"MERGEABLE\" | \"CONFLICTING\" | \"UNKNOWN\";\n\n/** A minimal open-PR summary with its head-commit CI rollup state + mergeability. */\nexport type PullRequestSummary = {\n number: number;\n title: string;\n url: string;\n headRef: string;\n ciState: CiState;\n mergeable: PrMergeable;\n};\n\n/**\n * Reject a value before it's interpolated into a `gh api` URL path. The\n * `owner/repo` split methods already validate shape; this is the defense-in-depth\n * guard for the `branch` and file-`path` segments. An unexpected value (`..`, a\n * leading `/`, whitespace, or a URL-structural char like `?#%` or a backslash)\n * could otherwise retarget the endpoint (escape the intended path, smuggle a\n * query string, or traverse). Conservative by design — legit branch names like\n * `maint/self-updating-x` and paths like `.github/workflows/ci.yml` pass.\n *\n * Both branch refs (`maint/self-updating-x`) and file paths\n * (`.github/workflows/ci.yml`) legitimately contain `/`, so a single slash is\n * allowed; what's rejected is `..`, a leading `/`, whitespace, or a\n * URL-structural char (`?`, `#`, `%`, backslash) that could escape or retarget\n * the endpoint.\n */\nexport function assertUrlSegment(kind: \"branch\" | \"path\", value: string): void {\n const structural = /[\\s?#%\\\\]|\\.\\./;\n if (value.length === 0 || value.startsWith(\"/\") || structural.test(value)) {\n throw new Error(\n `unsafe ${kind} for gh api path (illegal characters or traversal): ${JSON.stringify(value)}`,\n );\n }\n}\n\n/** Map GitHub's `statusCheckRollup.state` enum to our normalized CiState. */\nfunction mapRollupState(state: string | null | undefined): CiState {\n switch (state) {\n case \"SUCCESS\":\n return \"passing\";\n case \"FAILURE\":\n case \"ERROR\":\n return \"failing\";\n case \"PENDING\":\n case \"EXPECTED\":\n return \"pending\";\n default:\n return \"none\"; // null/undefined = no checks reported\n }\n}\n\n/** Coerce GitHub's `mergeable` enum to our PrMergeable. Anything unexpected\n * (including the literal `UNKNOWN` GitHub returns while still computing) maps to\n * `UNKNOWN` — i.e. \"not known to conflict\". Only an explicit `CONFLICTING` is. */\nfunction mapMergeable(state: string | null | undefined): PrMergeable {\n return state === \"MERGEABLE\" || state === \"CONFLICTING\" ? state : \"UNKNOWN\";\n}\n\nexport type GitHub = {\n openPullRequest: (\n repo: string,\n pr: { head: string; base: string; title: string; body: string },\n ) => Promise<{ url: string }>;\n enableRepoAutoMerge: (repo: string) => Promise<void>;\n protectBranch: (repo: string, branch: string, requiredChecks: string[]) => Promise<void>;\n setRepoSecret: (repo: string, name: string, value: string) => Promise<void>;\n repoExists: (repo: string) => Promise<boolean>;\n defaultBranch: (repo: string) => Promise<string>;\n filesOnBranch: (repo: string, branch: string, paths: string[]) => Promise<string[]>;\n branchProtectionContexts: (repo: string, branch: string) => Promise<string[]>;\n secretExists: (repo: string, name: string) => Promise<boolean>;\n autoMergeEnabled: (repo: string) => Promise<boolean>;\n findOpenSelfUpdatingPR: (repo: string) => Promise<string | null>;\n /** All open PRs on a repo with each head commit's normalized CI rollup state. */\n openPullRequests: (repo: string) => Promise<PullRequestSummary[]>;\n /** The default branch's latest-commit date + normalized CI rollup, one query. */\n defaultBranchStatus: (repo: string) => Promise<{ ciState: CiState; lastCommitAt: string | null }>;\n /** Fire a `workflow_dispatch` for `<workflow>` (a filename like `renovate.yml`)\n * on `ref`. Requires the token's `actions:write` scope; a 404 (no such\n * workflow) or 403 (missing scope) surfaces as a thrown error. */\n dispatchWorkflow: (repo: string, workflow: string, ref: string) => Promise<void>;\n};\n\nexport function makeGitHub(deps: { token: string; spawn?: SpawnFn }): GitHub {\n const spawn = deps.spawn ?? defaultSpawn;\n const env = { ...process.env, GH_TOKEN: deps.token };\n\n async function gh(args: string[]): Promise<string> {\n const r = await spawn(\"gh\", args, { env, timeoutMs: 60_000 });\n if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);\n return r.stdout;\n }\n\n return {\n async openPullRequest(repo, pr) {\n const out = await gh([\n \"pr\",\n \"create\",\n \"--repo\",\n repo,\n \"--head\",\n pr.head,\n \"--base\",\n pr.base,\n \"--title\",\n pr.title,\n \"--body\",\n pr.body,\n ]);\n return { url: out.trim() };\n },\n async enableRepoAutoMerge(repo) {\n await gh([\"api\", \"-X\", \"PATCH\", `repos/${repo}`, \"-F\", \"allow_auto_merge=true\"]);\n },\n async protectBranch(repo, branch, requiredChecks) {\n assertUrlSegment(\"branch\", branch);\n const args = [\n \"api\",\n \"-X\",\n \"PUT\",\n `repos/${repo}/branches/${branch}/protection`,\n \"-H\",\n \"Accept: application/vnd.github+json\",\n \"-F\",\n \"required_status_checks[strict]=true\",\n ...requiredChecks.flatMap((c) => [\"-f\", `required_status_checks[contexts][]=${c}`]),\n \"-F\",\n \"enforce_admins=true\",\n \"-F\",\n \"required_pull_request_reviews=null\",\n \"-F\",\n \"restrictions=null\",\n ];\n await gh(args);\n },\n async setRepoSecret(repo, name, value) {\n await gh([\"secret\", \"set\", name, \"--repo\", repo, \"--body\", value]);\n },\n async repoExists(repo) {\n const r = await spawn(\"gh\", [\"api\", `repos/${repo}`], { env, timeoutMs: 60_000 });\n return r.code === 0;\n },\n async defaultBranch(repo) {\n const out = await gh([\"api\", `repos/${repo}`, \"--jq\", \".default_branch\"]);\n return out.trim();\n },\n // filesOnBranch and branchProtectionContexts call `spawn` directly (not the\n // throwing `gh()` helper) because a 404 is an expected, meaningful answer —\n // \"file/protection absent\" — not an error. The remaining readers use `gh()`\n // since a non-200 there is a genuine failure (e.g. missing token scope).\n async filesOnBranch(repo, branch, paths) {\n assertUrlSegment(\"branch\", branch);\n const present: string[] = [];\n for (const p of paths) {\n assertUrlSegment(\"path\", p);\n const r = await spawn(\"gh\", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {\n env,\n timeoutMs: 60_000,\n });\n if (r.code === 0) present.push(p);\n }\n return present;\n },\n async branchProtectionContexts(repo, branch) {\n assertUrlSegment(\"branch\", branch);\n const r = await spawn(\n \"gh\",\n [\n \"api\",\n `repos/${repo}/branches/${branch}/protection`,\n \"--jq\",\n \".required_status_checks.contexts[]?\",\n ],\n { env, timeoutMs: 60_000 },\n );\n if (r.code !== 0) return []; // 404 = no protection configured\n return r.stdout\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l.length > 0);\n },\n async secretExists(repo, name) {\n // per_page=100: the REST default of 30 would false-negative on a repo with >30 secrets,\n // wrongly reporting an existing secret absent (→ a needless overwrite).\n const out = await gh([\n \"api\",\n `repos/${repo}/actions/secrets?per_page=100`,\n \"--jq\",\n \".secrets[].name\",\n ]);\n return out\n .split(\"\\n\")\n .map((l) => l.trim())\n .includes(name);\n },\n async autoMergeEnabled(repo) {\n const out = await gh([\"api\", `repos/${repo}`, \"--jq\", \".allow_auto_merge\"]);\n return out.trim() === \"true\";\n },\n async findOpenSelfUpdatingPR(repo) {\n // per_page=100: with the REST default of 30, a repo with >30 open PRs (plausible under\n // Renovate) could page past the existing self-updating PR and open a duplicate.\n const out = await gh([\n \"api\",\n `repos/${repo}/pulls?state=open&per_page=100`,\n \"--jq\",\n '.[] | select(.head.ref | startswith(\"maint/self-updating-\")) | .html_url',\n ]);\n const first = out\n .split(\"\\n\")\n .map((l) => l.trim())\n .find((l) => l.length > 0);\n return first ?? null;\n },\n async openPullRequests(repo) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`openPullRequests: expected \"owner/repo\", got \"${repo}\"`);\n }\n const query =\n \"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){\" +\n \"pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName mergeable \" +\n \"commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}\";\n const out = await gh([\n \"api\",\n \"graphql\",\n \"-f\",\n `query=${query}`,\n \"-F\",\n `owner=${owner}`,\n \"-F\",\n `name=${name}`,\n ]);\n const parsed = JSON.parse(out) as {\n data?: {\n repository?: {\n pullRequests?: {\n nodes?: Array<{\n number: number;\n title: string;\n url: string;\n headRefName: string;\n mergeable?: string;\n commits?: {\n nodes?: Array<{ commit?: { statusCheckRollup?: { state?: string } } }>;\n };\n }>;\n };\n };\n };\n };\n const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];\n return nodes.map((n) => ({\n number: n.number,\n title: n.title,\n url: n.url,\n headRef: n.headRefName,\n ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state),\n mergeable: mapMergeable(n.mergeable),\n }));\n },\n async defaultBranchStatus(repo) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`defaultBranchStatus: expected \"owner/repo\", got \"${repo}\"`);\n }\n const query =\n \"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){\" +\n \"defaultBranchRef{target{... on Commit{committedDate statusCheckRollup{state}}}}}}\";\n const out = await gh([\n \"api\",\n \"graphql\",\n \"-f\",\n `query=${query}`,\n \"-F\",\n `owner=${owner}`,\n \"-F\",\n `name=${name}`,\n ]);\n const parsed = JSON.parse(out) as {\n data?: {\n repository?: {\n defaultBranchRef?: {\n target?: { committedDate?: string; statusCheckRollup?: { state?: string } | null };\n } | null;\n };\n };\n };\n const target = parsed.data?.repository?.defaultBranchRef?.target;\n return {\n ciState: mapRollupState(target?.statusCheckRollup?.state),\n lastCommitAt: target?.committedDate ?? null,\n };\n },\n async dispatchWorkflow(repo, workflow, ref) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`dispatchWorkflow: expected \"owner/repo\", got \"${repo}\"`);\n }\n // Every segment interpolates into the API path, so guard them all like the\n // other write methods do (defense in depth). `owner`/`name` are the most\n // operator-controlled (typed into Airtable's \"Git repo\"); `workflow` is a\n // constant today; `ref` is repo-sourced. A junk value like `repo?x=1` would\n // otherwise smuggle a query string past the bare two-part shape check.\n assertUrlSegment(\"path\", owner);\n assertUrlSegment(\"path\", name);\n assertUrlSegment(\"path\", workflow);\n assertUrlSegment(\"branch\", ref);\n await gh([\n \"api\",\n \"-X\",\n \"POST\",\n `repos/${owner}/${name}/actions/workflows/${workflow}/dispatches`,\n \"-f\",\n `ref=${ref}`,\n ]);\n },\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/cli/bin.js
CHANGED
|
@@ -122,7 +122,7 @@ cli.command(
|
|
|
122
122
|
"Bootstrap a repo to keep itself updated (CI + Renovate + auto-merge)."
|
|
123
123
|
).option("--dry", "List what would be enabled without writing or opening PRs").option("--fleet <inventory>", 'Inventory file (.json or .mjs/.js), or "airtable"').option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
124
124
|
async (site, opts) => runOrExit(
|
|
125
|
-
async () => (await import("../self-updating-
|
|
125
|
+
async () => (await import("../self-updating-CFHXJSAX.js")).runSelfUpdatingCommand(site, opts),
|
|
126
126
|
opts
|
|
127
127
|
)
|
|
128
128
|
);
|
|
@@ -182,7 +182,7 @@ cli.command(
|
|
|
182
182
|
"Bootstrap + first-audit a site, then draft its launch email for approval."
|
|
183
183
|
).action(
|
|
184
184
|
async (site, opts) => runOrExit(
|
|
185
|
-
async () => (await import("../launch-
|
|
185
|
+
async () => (await import("../launch-UWCI3JRM.js")).runLaunchCommand(site, opts),
|
|
186
186
|
opts
|
|
187
187
|
)
|
|
188
188
|
);
|
|
@@ -215,7 +215,7 @@ cli.command(
|
|
|
215
215
|
"Sweep the fleet for GitHub signals (Renovate-failing/CI/last-commit) and write Airtable."
|
|
216
216
|
).option("--fleet", "Run across every site in the Airtable inventory.").option("--write-airtable", "Write each site's signals back to its Websites row.").action(
|
|
217
217
|
async (opts) => runOrExit(
|
|
218
|
-
async () => (await import("../github-signals-
|
|
218
|
+
async () => (await import("../github-signals-UL2BBS3Q.js")).runGitHubSignalsCommand({
|
|
219
219
|
fleet: opts.fleet,
|
|
220
220
|
writeAirtable: opts.writeAirtable
|
|
221
221
|
}),
|
|
@@ -233,7 +233,7 @@ cli.command(
|
|
|
233
233
|
"Trigger Renovate on fleet sites the security sweep flagged with critical/high vulns."
|
|
234
234
|
).option("--fleet", "Run across every active, repo-backed site in the Airtable inventory.").action(
|
|
235
235
|
async (opts) => runOrExit(
|
|
236
|
-
async () => (await import("../renovate-dispatch-
|
|
236
|
+
async () => (await import("../renovate-dispatch-NG5KUQKY.js")).runRenovateDispatchCommand({
|
|
237
237
|
fleet: opts.fleet
|
|
238
238
|
}),
|
|
239
239
|
opts
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import "./chunk-HPSGCDWY.js";
|
|
8
8
|
import {
|
|
9
9
|
makeGitHub
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-XB6T4ETK.js";
|
|
11
11
|
import "./chunk-HVOOCK6L.js";
|
|
12
12
|
import {
|
|
13
13
|
listWebsites,
|
|
@@ -113,4 +113,4 @@ export {
|
|
|
113
113
|
githubSignalsExitCode,
|
|
114
114
|
runGitHubSignalsCommand
|
|
115
115
|
};
|
|
116
|
-
//# sourceMappingURL=github-signals-
|
|
116
|
+
//# sourceMappingURL=github-signals-UL2BBS3Q.js.map
|
package/dist/index.js
CHANGED
|
@@ -730,6 +730,15 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
|
|
|
730
730
|
.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; }
|
|
731
731
|
.filters button[aria-pressed="true"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
|
|
732
732
|
@media (prefers-color-scheme: dark) { .filters button[aria-pressed="true"] { background:#e8e8e8; color:#111; } }
|
|
733
|
+
.fleet-actions { margin-bottom:1.25rem; }
|
|
734
|
+
.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; }
|
|
735
|
+
.refresh-fleet:disabled { opacity:0.6; cursor:default; }
|
|
736
|
+
@media (prefers-color-scheme: dark) { .refresh-fleet { background:#e8e8e8; color:#111; border-color:#e8e8e8; } }
|
|
737
|
+
.rf-status { margin-top:0.6rem; font-size:0.85rem; }
|
|
738
|
+
.rf-row { padding:0.1rem 0; }
|
|
739
|
+
.rf-row a { margin-left:0.3rem; }
|
|
740
|
+
.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
|
+
@keyframes rf-spin { to { transform:rotate(360deg); } }
|
|
733
742
|
details.tier { margin:0.75rem 0; }
|
|
734
743
|
details.tier > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }
|
|
735
744
|
.approve-strip { border:1px solid #ffe08a; background:#fff8e1; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }
|
|
@@ -788,7 +797,11 @@ function summaryBar(model) {
|
|
|
788
797
|
<span class="tier">\u{1F7E2} ${s.healthy} healthy</span>
|
|
789
798
|
</div>
|
|
790
799
|
<div class="summary heads">${escapeHtml(heads)}</div>
|
|
791
|
-
<div class="filters">${chips2}</div
|
|
800
|
+
<div class="filters">${chips2}</div>
|
|
801
|
+
<div class="fleet-actions">
|
|
802
|
+
<button type="button" class="refresh-fleet" data-refresh-url="/api/fleet/refresh">\u21BB Refresh fleet state</button>
|
|
803
|
+
<div id="rf-status" class="rf-status" aria-live="polite"></div>
|
|
804
|
+
</div>`;
|
|
792
805
|
}
|
|
793
806
|
function spamRollup(model) {
|
|
794
807
|
const s = model.spam;
|
|
@@ -919,6 +932,91 @@ var FILTER_SCRIPT = `<script>
|
|
|
919
932
|
catch(e){ b.textContent = 'Failed'; b.disabled = false; }
|
|
920
933
|
});
|
|
921
934
|
});
|
|
935
|
+
// fleet-refresh live status: dispatch, then poll the actual runs and follow them.
|
|
936
|
+
// Vanilla JS, string-concat only (no template literals) \u2014 this lives inside a TS
|
|
937
|
+
// template string, so backticks or interpolation syntax would break the server render.
|
|
938
|
+
var RF_KEY = 'reddoor:fleet-refresh';
|
|
939
|
+
var RF_POLL_MS = 10000;
|
|
940
|
+
var RF_MAX_MS = 90 * 60 * 1000; // safety ceiling; a full fleet Lighthouse run was ~48 min (2026-06-24)
|
|
941
|
+
function rfPanel(){ return document.getElementById('rf-status'); }
|
|
942
|
+
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){
|
|
947
|
+
var failed = function(s){ return s === 'failure' || s === 'cancelled' || s === 'timed_out'; };
|
|
948
|
+
return status.perWorkflow.map(function(w){
|
|
949
|
+
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>';
|
|
953
|
+
}).join('');
|
|
954
|
+
}
|
|
955
|
+
function rfPoll(since, startedAt){
|
|
956
|
+
fetch('/api/fleet/refresh/status?since=' + encodeURIComponent(since)).then(function(res){
|
|
957
|
+
if (res.status === 401) return { authFail: true };
|
|
958
|
+
return res.ok ? res.json() : null;
|
|
959
|
+
}).then(function(data){
|
|
960
|
+
var p = rfPanel();
|
|
961
|
+
if (data && data.authFail){
|
|
962
|
+
if (p) p.innerHTML += '<div class="rf-row">Session expired \u2014 reload to sign in.</div>';
|
|
963
|
+
if (rf){ rf.disabled = false; rf.textContent = '\u21BB Refresh fleet state'; }
|
|
964
|
+
rfStop(); return;
|
|
965
|
+
}
|
|
966
|
+
if (data && data.status){
|
|
967
|
+
if (p) p.innerHTML = rfRender(data.status);
|
|
968
|
+
if (data.status.allDone){
|
|
969
|
+
if (!data.status.anyFailure){
|
|
970
|
+
if (p) p.innerHTML += '<div class="rf-row">\u2713 Done \u2014 reloading\u2026</div>';
|
|
971
|
+
rfStop(); setTimeout(function(){ location.reload(); }, 2000); return;
|
|
972
|
+
}
|
|
973
|
+
if (p) p.innerHTML += '<div class="rf-row"><button type="button" onclick="location.reload()">Reload</button></div>';
|
|
974
|
+
if (rf){ rf.disabled = false; rf.textContent = '\u21BB Refresh fleet state'; }
|
|
975
|
+
rfStop(); return;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
if (Date.now() - startedAt > RF_MAX_MS){
|
|
979
|
+
if (p) p.innerHTML += '<div class="rf-row">Still running \u2014 reload later.</div>';
|
|
980
|
+
if (rf){ rf.disabled = false; rf.textContent = '\u21BB Refresh fleet state'; }
|
|
981
|
+
rfStop(); return;
|
|
982
|
+
}
|
|
983
|
+
setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);
|
|
984
|
+
}).catch(function(){
|
|
985
|
+
var p = rfPanel();
|
|
986
|
+
if (Date.now() - startedAt > RF_MAX_MS){
|
|
987
|
+
if (p) p.innerHTML += '<div class="rf-row">Still running \u2014 reload later.</div>';
|
|
988
|
+
if (rf){ rf.disabled = false; rf.textContent = '\u21BB Refresh fleet state'; }
|
|
989
|
+
rfStop(); return;
|
|
990
|
+
}
|
|
991
|
+
setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
function rfBegin(since, startedAt){
|
|
995
|
+
try { localStorage.setItem(RF_KEY, JSON.stringify({ since: since, startedAt: startedAt })); } catch(e){}
|
|
996
|
+
var p = rfPanel(); if (p) p.innerHTML = '<div class="rf-row"><span class="rf-spin"></span> starting\u2026</div>';
|
|
997
|
+
rfPoll(since, startedAt);
|
|
998
|
+
}
|
|
999
|
+
var rf = document.querySelector('button.refresh-fleet');
|
|
1000
|
+
if (rf) rf.addEventListener('click', async function(){
|
|
1001
|
+
if (!confirm('Kick off the security + Lighthouse sweeps for the whole fleet? They take a few minutes.')) return;
|
|
1002
|
+
rf.disabled = true; rf.textContent = 'Refreshing\u2026';
|
|
1003
|
+
try {
|
|
1004
|
+
var res = await fetch(rf.dataset.refreshUrl, { method: 'POST' });
|
|
1005
|
+
if (res.ok){
|
|
1006
|
+
var data = await res.json();
|
|
1007
|
+
rf.textContent = '\u21BB Refresh running\u2026';
|
|
1008
|
+
if (data && data.since) rfBegin(data.since, Date.now());
|
|
1009
|
+
} else { rf.textContent = 'Failed to start'; rf.disabled = false; }
|
|
1010
|
+
} catch(e){ rf.textContent = 'Failed to start'; rf.disabled = false; }
|
|
1011
|
+
});
|
|
1012
|
+
// Resume-on-reload: if a refresh is in flight (<90 min old), keep following it.
|
|
1013
|
+
try {
|
|
1014
|
+
var rfSaved = JSON.parse(localStorage.getItem(RF_KEY) || 'null');
|
|
1015
|
+
if (rfSaved && rfSaved.since && rfSaved.startedAt && (Date.now() - rfSaved.startedAt) < RF_MAX_MS){
|
|
1016
|
+
if (rf){ rf.disabled = true; rf.textContent = '\u21BB Refresh running\u2026'; }
|
|
1017
|
+
rfBegin(rfSaved.since, rfSaved.startedAt);
|
|
1018
|
+
} else if (rfSaved) { rfStop(); }
|
|
1019
|
+
} catch(e){}
|
|
922
1020
|
})();
|
|
923
1021
|
</script>`;
|
|
924
1022
|
function renderCockpitHtml(model) {
|
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; } }\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}\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})();\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-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"]}
|
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
} from "./chunk-HPSGCDWY.js";
|
|
8
8
|
import {
|
|
9
9
|
selfUpdating
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import "./chunk-
|
|
10
|
+
} from "./chunk-D3LFQNJK.js";
|
|
11
|
+
import "./chunk-XB6T4ETK.js";
|
|
12
12
|
import {
|
|
13
13
|
resolveSites
|
|
14
14
|
} from "./chunk-6CSCLBAA.js";
|
|
@@ -210,4 +210,4 @@ async function runLaunchCommand(site, opts) {
|
|
|
210
210
|
export {
|
|
211
211
|
runLaunchCommand
|
|
212
212
|
};
|
|
213
|
-
//# sourceMappingURL=launch-
|
|
213
|
+
//# sourceMappingURL=launch-UWCI3JRM.js.map
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-PZQ3WED2.js";
|
|
4
4
|
import {
|
|
5
5
|
makeGitHub
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-XB6T4ETK.js";
|
|
7
7
|
import "./chunk-HVOOCK6L.js";
|
|
8
8
|
import {
|
|
9
9
|
isDashboardVisible,
|
|
@@ -136,4 +136,4 @@ No active repo-backed sites with critical/high vulnerabilities \u2014 nothing to
|
|
|
136
136
|
export {
|
|
137
137
|
runRenovateDispatchCommand
|
|
138
138
|
};
|
|
139
|
-
//# sourceMappingURL=renovate-dispatch-
|
|
139
|
+
//# sourceMappingURL=renovate-dispatch-NG5KUQKY.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
selfUpdating
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-D3LFQNJK.js";
|
|
4
|
+
import "./chunk-XB6T4ETK.js";
|
|
5
5
|
import {
|
|
6
6
|
appendSkipNotice,
|
|
7
7
|
fleetWorkdir,
|
|
@@ -58,4 +58,4 @@ async function runSelfUpdatingCommand(site, opts) {
|
|
|
58
58
|
export {
|
|
59
59
|
runSelfUpdatingCommand
|
|
60
60
|
};
|
|
61
|
-
//# sourceMappingURL=self-updating-
|
|
61
|
+
//# sourceMappingURL=self-updating-CFHXJSAX.js.map
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/github/gh.ts"],"sourcesContent":["import { defaultSpawn, type SpawnFn } from \"../audits/util/spawn.js\";\n\n/** Aggregate CI state of a PR's head commit, normalized from GitHub's rollup. */\nexport type CiState = \"passing\" | \"failing\" | \"pending\" | \"none\";\n\n/** GitHub's computed mergeability of a PR. `UNKNOWN` is transient — GitHub is\n * still computing it (e.g. right after a push) — so it should be read as \"not\n * known to conflict\", never as conflicting. */\nexport type PrMergeable = \"MERGEABLE\" | \"CONFLICTING\" | \"UNKNOWN\";\n\n/** A minimal open-PR summary with its head-commit CI rollup state + mergeability. */\nexport type PullRequestSummary = {\n number: number;\n title: string;\n url: string;\n headRef: string;\n ciState: CiState;\n mergeable: PrMergeable;\n};\n\n/**\n * Reject a value before it's interpolated into a `gh api` URL path. The\n * `owner/repo` split methods already validate shape; this is the defense-in-depth\n * guard for the `branch` and file-`path` segments. An unexpected value (`..`, a\n * leading `/`, whitespace, or a URL-structural char like `?#%` or a backslash)\n * could otherwise retarget the endpoint (escape the intended path, smuggle a\n * query string, or traverse). Conservative by design — legit branch names like\n * `maint/self-updating-x` and paths like `.github/workflows/ci.yml` pass.\n *\n * Both branch refs (`maint/self-updating-x`) and file paths\n * (`.github/workflows/ci.yml`) legitimately contain `/`, so a single slash is\n * allowed; what's rejected is `..`, a leading `/`, whitespace, or a\n * URL-structural char (`?`, `#`, `%`, backslash) that could escape or retarget\n * the endpoint.\n */\nfunction assertUrlSegment(kind: \"branch\" | \"path\", value: string): void {\n const structural = /[\\s?#%\\\\]|\\.\\./;\n if (value.length === 0 || value.startsWith(\"/\") || structural.test(value)) {\n throw new Error(\n `unsafe ${kind} for gh api path (illegal characters or traversal): ${JSON.stringify(value)}`,\n );\n }\n}\n\n/** Map GitHub's `statusCheckRollup.state` enum to our normalized CiState. */\nfunction mapRollupState(state: string | null | undefined): CiState {\n switch (state) {\n case \"SUCCESS\":\n return \"passing\";\n case \"FAILURE\":\n case \"ERROR\":\n return \"failing\";\n case \"PENDING\":\n case \"EXPECTED\":\n return \"pending\";\n default:\n return \"none\"; // null/undefined = no checks reported\n }\n}\n\n/** Coerce GitHub's `mergeable` enum to our PrMergeable. Anything unexpected\n * (including the literal `UNKNOWN` GitHub returns while still computing) maps to\n * `UNKNOWN` — i.e. \"not known to conflict\". Only an explicit `CONFLICTING` is. */\nfunction mapMergeable(state: string | null | undefined): PrMergeable {\n return state === \"MERGEABLE\" || state === \"CONFLICTING\" ? state : \"UNKNOWN\";\n}\n\nexport type GitHub = {\n openPullRequest: (\n repo: string,\n pr: { head: string; base: string; title: string; body: string },\n ) => Promise<{ url: string }>;\n enableRepoAutoMerge: (repo: string) => Promise<void>;\n protectBranch: (repo: string, branch: string, requiredChecks: string[]) => Promise<void>;\n setRepoSecret: (repo: string, name: string, value: string) => Promise<void>;\n repoExists: (repo: string) => Promise<boolean>;\n defaultBranch: (repo: string) => Promise<string>;\n filesOnBranch: (repo: string, branch: string, paths: string[]) => Promise<string[]>;\n branchProtectionContexts: (repo: string, branch: string) => Promise<string[]>;\n secretExists: (repo: string, name: string) => Promise<boolean>;\n autoMergeEnabled: (repo: string) => Promise<boolean>;\n findOpenSelfUpdatingPR: (repo: string) => Promise<string | null>;\n /** All open PRs on a repo with each head commit's normalized CI rollup state. */\n openPullRequests: (repo: string) => Promise<PullRequestSummary[]>;\n /** The default branch's latest-commit date + normalized CI rollup, one query. */\n defaultBranchStatus: (repo: string) => Promise<{ ciState: CiState; lastCommitAt: string | null }>;\n /** Fire a `workflow_dispatch` for `<workflow>` (a filename like `renovate.yml`)\n * on `ref`. Requires the token's `actions:write` scope; a 404 (no such\n * workflow) or 403 (missing scope) surfaces as a thrown error. */\n dispatchWorkflow: (repo: string, workflow: string, ref: string) => Promise<void>;\n};\n\nexport function makeGitHub(deps: { token: string; spawn?: SpawnFn }): GitHub {\n const spawn = deps.spawn ?? defaultSpawn;\n const env = { ...process.env, GH_TOKEN: deps.token };\n\n async function gh(args: string[]): Promise<string> {\n const r = await spawn(\"gh\", args, { env, timeoutMs: 60_000 });\n if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);\n return r.stdout;\n }\n\n return {\n async openPullRequest(repo, pr) {\n const out = await gh([\n \"pr\",\n \"create\",\n \"--repo\",\n repo,\n \"--head\",\n pr.head,\n \"--base\",\n pr.base,\n \"--title\",\n pr.title,\n \"--body\",\n pr.body,\n ]);\n return { url: out.trim() };\n },\n async enableRepoAutoMerge(repo) {\n await gh([\"api\", \"-X\", \"PATCH\", `repos/${repo}`, \"-F\", \"allow_auto_merge=true\"]);\n },\n async protectBranch(repo, branch, requiredChecks) {\n assertUrlSegment(\"branch\", branch);\n const args = [\n \"api\",\n \"-X\",\n \"PUT\",\n `repos/${repo}/branches/${branch}/protection`,\n \"-H\",\n \"Accept: application/vnd.github+json\",\n \"-F\",\n \"required_status_checks[strict]=true\",\n ...requiredChecks.flatMap((c) => [\"-f\", `required_status_checks[contexts][]=${c}`]),\n \"-F\",\n \"enforce_admins=true\",\n \"-F\",\n \"required_pull_request_reviews=null\",\n \"-F\",\n \"restrictions=null\",\n ];\n await gh(args);\n },\n async setRepoSecret(repo, name, value) {\n await gh([\"secret\", \"set\", name, \"--repo\", repo, \"--body\", value]);\n },\n async repoExists(repo) {\n const r = await spawn(\"gh\", [\"api\", `repos/${repo}`], { env, timeoutMs: 60_000 });\n return r.code === 0;\n },\n async defaultBranch(repo) {\n const out = await gh([\"api\", `repos/${repo}`, \"--jq\", \".default_branch\"]);\n return out.trim();\n },\n // filesOnBranch and branchProtectionContexts call `spawn` directly (not the\n // throwing `gh()` helper) because a 404 is an expected, meaningful answer —\n // \"file/protection absent\" — not an error. The remaining readers use `gh()`\n // since a non-200 there is a genuine failure (e.g. missing token scope).\n async filesOnBranch(repo, branch, paths) {\n assertUrlSegment(\"branch\", branch);\n const present: string[] = [];\n for (const p of paths) {\n assertUrlSegment(\"path\", p);\n const r = await spawn(\"gh\", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {\n env,\n timeoutMs: 60_000,\n });\n if (r.code === 0) present.push(p);\n }\n return present;\n },\n async branchProtectionContexts(repo, branch) {\n assertUrlSegment(\"branch\", branch);\n const r = await spawn(\n \"gh\",\n [\n \"api\",\n `repos/${repo}/branches/${branch}/protection`,\n \"--jq\",\n \".required_status_checks.contexts[]?\",\n ],\n { env, timeoutMs: 60_000 },\n );\n if (r.code !== 0) return []; // 404 = no protection configured\n return r.stdout\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l.length > 0);\n },\n async secretExists(repo, name) {\n // per_page=100: the REST default of 30 would false-negative on a repo with >30 secrets,\n // wrongly reporting an existing secret absent (→ a needless overwrite).\n const out = await gh([\n \"api\",\n `repos/${repo}/actions/secrets?per_page=100`,\n \"--jq\",\n \".secrets[].name\",\n ]);\n return out\n .split(\"\\n\")\n .map((l) => l.trim())\n .includes(name);\n },\n async autoMergeEnabled(repo) {\n const out = await gh([\"api\", `repos/${repo}`, \"--jq\", \".allow_auto_merge\"]);\n return out.trim() === \"true\";\n },\n async findOpenSelfUpdatingPR(repo) {\n // per_page=100: with the REST default of 30, a repo with >30 open PRs (plausible under\n // Renovate) could page past the existing self-updating PR and open a duplicate.\n const out = await gh([\n \"api\",\n `repos/${repo}/pulls?state=open&per_page=100`,\n \"--jq\",\n '.[] | select(.head.ref | startswith(\"maint/self-updating-\")) | .html_url',\n ]);\n const first = out\n .split(\"\\n\")\n .map((l) => l.trim())\n .find((l) => l.length > 0);\n return first ?? null;\n },\n async openPullRequests(repo) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`openPullRequests: expected \"owner/repo\", got \"${repo}\"`);\n }\n const query =\n \"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){\" +\n \"pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName mergeable \" +\n \"commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}\";\n const out = await gh([\n \"api\",\n \"graphql\",\n \"-f\",\n `query=${query}`,\n \"-F\",\n `owner=${owner}`,\n \"-F\",\n `name=${name}`,\n ]);\n const parsed = JSON.parse(out) as {\n data?: {\n repository?: {\n pullRequests?: {\n nodes?: Array<{\n number: number;\n title: string;\n url: string;\n headRefName: string;\n mergeable?: string;\n commits?: {\n nodes?: Array<{ commit?: { statusCheckRollup?: { state?: string } } }>;\n };\n }>;\n };\n };\n };\n };\n const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];\n return nodes.map((n) => ({\n number: n.number,\n title: n.title,\n url: n.url,\n headRef: n.headRefName,\n ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state),\n mergeable: mapMergeable(n.mergeable),\n }));\n },\n async defaultBranchStatus(repo) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`defaultBranchStatus: expected \"owner/repo\", got \"${repo}\"`);\n }\n const query =\n \"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){\" +\n \"defaultBranchRef{target{... on Commit{committedDate statusCheckRollup{state}}}}}}\";\n const out = await gh([\n \"api\",\n \"graphql\",\n \"-f\",\n `query=${query}`,\n \"-F\",\n `owner=${owner}`,\n \"-F\",\n `name=${name}`,\n ]);\n const parsed = JSON.parse(out) as {\n data?: {\n repository?: {\n defaultBranchRef?: {\n target?: { committedDate?: string; statusCheckRollup?: { state?: string } | null };\n } | null;\n };\n };\n };\n const target = parsed.data?.repository?.defaultBranchRef?.target;\n return {\n ciState: mapRollupState(target?.statusCheckRollup?.state),\n lastCommitAt: target?.committedDate ?? null,\n };\n },\n async dispatchWorkflow(repo, workflow, ref) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`dispatchWorkflow: expected \"owner/repo\", got \"${repo}\"`);\n }\n // Every segment interpolates into the API path, so guard them all like the\n // other write methods do (defense in depth). `owner`/`name` are the most\n // operator-controlled (typed into Airtable's \"Git repo\"); `workflow` is a\n // constant today; `ref` is repo-sourced. A junk value like `repo?x=1` would\n // otherwise smuggle a query string past the bare two-part shape check.\n assertUrlSegment(\"path\", owner);\n assertUrlSegment(\"path\", name);\n assertUrlSegment(\"path\", workflow);\n assertUrlSegment(\"branch\", ref);\n await gh([\n \"api\",\n \"-X\",\n \"POST\",\n `repos/${owner}/${name}/actions/workflows/${workflow}/dispatches`,\n \"-f\",\n `ref=${ref}`,\n ]);\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|