@reddoorla/maintenance 0.60.1 → 0.62.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.
Files changed (104) hide show
  1. package/dist/airtable-HHAWHBNM.js +9 -0
  2. package/dist/{announce-Q2DXP5OO.js → announce-54EBITHX.js} +16 -6
  3. package/dist/announce-54EBITHX.js.map +1 -0
  4. package/dist/{bump-deps-RYHQIKW4.js → bump-deps-2U6GIHM5.js} +2 -2
  5. package/dist/{chunk-BWLUNY7L.js → chunk-2T3HZ3DB.js} +24 -14
  6. package/dist/chunk-2T3HZ3DB.js.map +1 -0
  7. package/dist/{chunk-D3LFQNJK.js → chunk-3HM4S422.js} +2 -2
  8. package/dist/{chunk-LMXIZITU.js → chunk-4REUHZAW.js} +40 -4
  9. package/dist/chunk-4REUHZAW.js.map +1 -0
  10. package/dist/chunk-5L3FPQIJ.js +92 -0
  11. package/dist/chunk-5L3FPQIJ.js.map +1 -0
  12. package/dist/{client-JA6QLEDW.js → chunk-6ZKM7OIJ.js} +5 -4
  13. package/dist/{chunk-KC4HU7BC.js → chunk-76VIUWR3.js} +31 -6
  14. package/dist/chunk-76VIUWR3.js.map +1 -0
  15. package/dist/{chunk-L4ULXHIN.js → chunk-BLD6AYJO.js} +15 -1
  16. package/dist/chunk-BLD6AYJO.js.map +1 -0
  17. package/dist/chunk-BMDBOPN3.js +211 -0
  18. package/dist/chunk-BMDBOPN3.js.map +1 -0
  19. package/dist/{chunk-HB7QPECR.js → chunk-KTPPKO2C.js} +6 -6
  20. package/dist/chunk-L5F664ZA.js +45 -0
  21. package/dist/chunk-L5F664ZA.js.map +1 -0
  22. package/dist/{chunk-XB6T4ETK.js → chunk-OSRFDGQM.js} +21 -1
  23. package/dist/chunk-OSRFDGQM.js.map +1 -0
  24. package/dist/{chunk-6CSCLBAA.js → chunk-SAOCVNX7.js} +2 -2
  25. package/dist/{chunk-5PHCZ6L5.js → chunk-SS7BKOIX.js} +89 -2
  26. package/dist/chunk-SS7BKOIX.js.map +1 -0
  27. package/dist/{chunk-TZN2G6FD.js → chunk-TYRCYHRA.js} +3 -3
  28. package/dist/{chunk-HG5DTFNF.js → chunk-XKKTK6WA.js} +17 -1
  29. package/dist/chunk-XKKTK6WA.js.map +1 -0
  30. package/dist/{chunk-JNXCDUYB.js → chunk-XVR7UM6Z.js} +4 -4
  31. package/dist/{chunk-HPSGCDWY.js → chunk-YYUY2OVP.js} +2 -2
  32. package/dist/{chunk-TLZ4V2BE.js → chunk-ZEX2T6JH.js} +3 -2
  33. package/dist/{chunk-TLZ4V2BE.js.map → chunk-ZEX2T6JH.js.map} +1 -1
  34. package/dist/cli/bin.js +16 -15
  35. package/dist/cli/bin.js.map +1 -1
  36. package/dist/cli/commands/audit.d.ts +3 -2
  37. package/dist/cli/commands/audit.js +29 -8
  38. package/dist/cli/commands/audit.js.map +1 -1
  39. package/dist/client-GTMJTXBK.js +11 -0
  40. package/dist/{convert-to-pnpm-KNCXU6FM.js → convert-to-pnpm-JG3767ZQ.js} +2 -2
  41. package/dist/{db-AXKMRTVA.js → db-Y6BVTNH7.js} +3 -3
  42. package/dist/{digest-RVIOHUXR.js → digest-ABJROO4H.js} +13 -197
  43. package/dist/digest-ABJROO4H.js.map +1 -0
  44. package/dist/{github-signals-UL2BBS3Q.js → github-signals-XTJLKOXJ.js} +26 -5
  45. package/dist/github-signals-XTJLKOXJ.js.map +1 -0
  46. package/dist/index.d.ts +62 -5
  47. package/dist/index.js +345 -206
  48. package/dist/index.js.map +1 -1
  49. package/dist/{init-KD56UENX.js → init-DMAIKUQE.js} +8 -8
  50. package/dist/{launch-UWCI3JRM.js → launch-UZPPB4IE.js} +10 -9
  51. package/dist/{launch-UWCI3JRM.js.map → launch-UZPPB4IE.js.map} +1 -1
  52. package/dist/{lighthouse-airtable-N3KCBPVA.js → lighthouse-airtable-HTEW6BBT.js} +3 -3
  53. package/dist/migrate-BFOG2OCP.js +7 -0
  54. package/dist/{onboard-SCZ3UKZ2.js → onboard-TTIC3YZI.js} +2 -2
  55. package/dist/{orchestrate-D72PB6NG.js → orchestrate-5GGOL5EX.js} +7 -4
  56. package/dist/recipes/sync-configs.d.ts +1 -1
  57. package/dist/recipes/sync-configs.js +2 -2
  58. package/dist/{renovate-dispatch-NG5KUQKY.js → renovate-dispatch-QDDMTATD.js} +3 -3
  59. package/dist/{report-CN56TGMK.js → report-SZGZIDDG.js} +6 -6
  60. package/dist/{self-updating-CFHXJSAX.js → self-updating-2RLUP4AA.js} +4 -4
  61. package/dist/{svelte-codemods-HNITLWQM.js → svelte-codemods-BPLKSVIA.js} +2 -2
  62. package/dist/{sync-configs-BZXBYKTI.js → sync-configs-VFRV3TJ4.js} +4 -4
  63. package/dist/{types-QG-QhCYh.d.ts → types-Dg4mHvyf.d.ts} +6 -1
  64. package/dist/{upgrade-6YDUELFA.js → upgrade-WCFD2TRN.js} +2 -2
  65. package/dist/{websites-IYP7IGLU.js → websites-HM2NMZMR.js} +2 -2
  66. package/dist/{write-audits-to-airtable-VDGHCZGQ.js → write-audits-to-airtable-QF26PCAR.js} +5 -4
  67. package/dist/write-audits-to-airtable-QF26PCAR.js.map +1 -0
  68. package/package.json +1 -1
  69. package/dist/airtable-YZ6VMSQZ.js +0 -9
  70. package/dist/announce-Q2DXP5OO.js.map +0 -1
  71. package/dist/chunk-5PHCZ6L5.js.map +0 -1
  72. package/dist/chunk-BWLUNY7L.js.map +0 -1
  73. package/dist/chunk-HG5DTFNF.js.map +0 -1
  74. package/dist/chunk-KC4HU7BC.js.map +0 -1
  75. package/dist/chunk-L4ULXHIN.js.map +0 -1
  76. package/dist/chunk-LMXIZITU.js.map +0 -1
  77. package/dist/chunk-XB6T4ETK.js.map +0 -1
  78. package/dist/digest-RVIOHUXR.js.map +0 -1
  79. package/dist/github-signals-UL2BBS3Q.js.map +0 -1
  80. package/dist/migrate-P2LS4WXI.js +0 -7
  81. /package/dist/{airtable-YZ6VMSQZ.js.map → airtable-HHAWHBNM.js.map} +0 -0
  82. /package/dist/{bump-deps-RYHQIKW4.js.map → bump-deps-2U6GIHM5.js.map} +0 -0
  83. /package/dist/{chunk-D3LFQNJK.js.map → chunk-3HM4S422.js.map} +0 -0
  84. /package/dist/{client-JA6QLEDW.js.map → chunk-6ZKM7OIJ.js.map} +0 -0
  85. /package/dist/{chunk-HB7QPECR.js.map → chunk-KTPPKO2C.js.map} +0 -0
  86. /package/dist/{chunk-6CSCLBAA.js.map → chunk-SAOCVNX7.js.map} +0 -0
  87. /package/dist/{chunk-TZN2G6FD.js.map → chunk-TYRCYHRA.js.map} +0 -0
  88. /package/dist/{chunk-JNXCDUYB.js.map → chunk-XVR7UM6Z.js.map} +0 -0
  89. /package/dist/{chunk-HPSGCDWY.js.map → chunk-YYUY2OVP.js.map} +0 -0
  90. /package/dist/{lighthouse-airtable-N3KCBPVA.js.map → client-GTMJTXBK.js.map} +0 -0
  91. /package/dist/{convert-to-pnpm-KNCXU6FM.js.map → convert-to-pnpm-JG3767ZQ.js.map} +0 -0
  92. /package/dist/{db-AXKMRTVA.js.map → db-Y6BVTNH7.js.map} +0 -0
  93. /package/dist/{init-KD56UENX.js.map → init-DMAIKUQE.js.map} +0 -0
  94. /package/dist/{migrate-P2LS4WXI.js.map → lighthouse-airtable-HTEW6BBT.js.map} +0 -0
  95. /package/dist/{orchestrate-D72PB6NG.js.map → migrate-BFOG2OCP.js.map} +0 -0
  96. /package/dist/{onboard-SCZ3UKZ2.js.map → onboard-TTIC3YZI.js.map} +0 -0
  97. /package/dist/{websites-IYP7IGLU.js.map → orchestrate-5GGOL5EX.js.map} +0 -0
  98. /package/dist/{renovate-dispatch-NG5KUQKY.js.map → renovate-dispatch-QDDMTATD.js.map} +0 -0
  99. /package/dist/{report-CN56TGMK.js.map → report-SZGZIDDG.js.map} +0 -0
  100. /package/dist/{self-updating-CFHXJSAX.js.map → self-updating-2RLUP4AA.js.map} +0 -0
  101. /package/dist/{svelte-codemods-HNITLWQM.js.map → svelte-codemods-BPLKSVIA.js.map} +0 -0
  102. /package/dist/{sync-configs-BZXBYKTI.js.map → sync-configs-VFRV3TJ4.js.map} +0 -0
  103. /package/dist/{upgrade-6YDUELFA.js.map → upgrade-WCFD2TRN.js.map} +0 -0
  104. /package/dist/{write-audits-to-airtable-VDGHCZGQ.js.map → websites-HM2NMZMR.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,33 +1,41 @@
1
+ import {
2
+ findDueReports
3
+ } from "./chunk-WVRPOXKH.js";
4
+ import {
5
+ upgradeSvelte4to5
6
+ } from "./chunk-O65I7G7G.js";
1
7
  import {
2
8
  DEFAULT_INIT_STEPS,
3
9
  a11yFixturesPage,
4
10
  init
5
- } from "./chunk-HB7QPECR.js";
11
+ } from "./chunk-KTPPKO2C.js";
12
+ import {
13
+ convertToPnpm
14
+ } from "./chunk-2X322ZES.js";
6
15
  import {
7
16
  svelteCodemods
8
17
  } from "./chunk-FUD4I2ZW.js";
18
+ import "./chunk-OQPMZ6BM.js";
9
19
  import {
10
20
  onboard,
11
21
  selfCaretRange,
12
22
  selfPackageVersion
13
23
  } from "./chunk-ZYBOT5PM.js";
14
- import {
15
- findDueReports
16
- } from "./chunk-WVRPOXKH.js";
17
24
  import {
18
25
  draftReportForSite
19
- } from "./chunk-TZN2G6FD.js";
26
+ } from "./chunk-TYRCYHRA.js";
20
27
  import "./chunk-6LIWDIXU.js";
28
+ import "./chunk-BMDBOPN3.js";
21
29
  import {
22
30
  sendApprovedReports
23
- } from "./chunk-KC4HU7BC.js";
24
- import "./chunk-OGGRFBL4.js";
31
+ } from "./chunk-76VIUWR3.js";
25
32
  import {
26
33
  BLURRED_CID,
27
34
  CHECK_CID,
28
35
  loadBundledImages,
29
36
  renderReportHtml
30
- } from "./chunk-BWLUNY7L.js";
37
+ } from "./chunk-2T3HZ3DB.js";
38
+ import "./chunk-OGGRFBL4.js";
31
39
  import {
32
40
  checklistFor,
33
41
  escapeHtml,
@@ -38,18 +46,11 @@ import {
38
46
  import {
39
47
  bumpDeps
40
48
  } from "./chunk-D3DQFC32.js";
41
- import {
42
- upgradeSvelte4to5
43
- } from "./chunk-O65I7G7G.js";
44
- import "./chunk-OQPMZ6BM.js";
45
- import {
46
- convertToPnpm
47
- } from "./chunk-2X322ZES.js";
48
49
  import {
49
50
  syncConfigs
50
- } from "./chunk-JNXCDUYB.js";
51
- import "./chunk-CASCWQQX.js";
51
+ } from "./chunk-XVR7UM6Z.js";
52
52
  import "./chunk-XKOZUVK2.js";
53
+ import "./chunk-CASCWQQX.js";
53
54
  import "./chunk-OTGB6YQO.js";
54
55
  import {
55
56
  ALL_AUDIT_NAMES,
@@ -60,8 +61,11 @@ import {
60
61
  runAudits,
61
62
  runAuditsAcross,
62
63
  securityAudit
63
- } from "./chunk-5PHCZ6L5.js";
64
+ } from "./chunk-SS7BKOIX.js";
64
65
  import "./chunk-LKGVSM2O.js";
66
+ import "./chunk-L5F664ZA.js";
67
+ import "./chunk-6ZKM7OIJ.js";
68
+ import "./chunk-XKKTK6WA.js";
65
69
  import {
66
70
  fromJsonFile,
67
71
  localPath
@@ -71,12 +75,12 @@ import "./chunk-XXTZBPUY.js";
71
75
  import "./chunk-XTK5VIQB.js";
72
76
  import {
73
77
  fromAirtableBase
74
- } from "./chunk-TLZ4V2BE.js";
78
+ } from "./chunk-ZEX2T6JH.js";
75
79
  import "./chunk-U5SZ4FOP.js";
76
80
  import {
77
81
  SEVERITY_RANK,
78
82
  siteSlug
79
- } from "./chunk-L4ULXHIN.js";
83
+ } from "./chunk-BLD6AYJO.js";
80
84
  import "./chunk-HN6I2XWI.js";
81
85
  import "./chunk-WEKL2HYJ.js";
82
86
  import "./chunk-6VAL7XJT.js";
@@ -636,7 +640,94 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
636
640
  </html>`;
637
641
  }
638
642
 
639
- // src/dashboard/fleet-render.ts
643
+ // src/dashboard/fleet-cockpit.ts
644
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
645
+ var READY_DEPLOY_STATES = /* @__PURE__ */ new Set(["ready"]);
646
+ var FAILED_DEPLOY_STATES = /* @__PURE__ */ new Set(["error", "failed", "rejected"]);
647
+ function isReadyDeployStatus(status) {
648
+ return status !== null && READY_DEPLOY_STATES.has(status.toLowerCase());
649
+ }
650
+ function isFailedDeployStatus(status) {
651
+ return status !== null && FAILED_DEPLOY_STATES.has(status.toLowerCase());
652
+ }
653
+ var NEEDS_YOU_GROUP_RANK = { broken: 0, approval: 1, slipping: 2 };
654
+ function buildNeedsYouFeed(model) {
655
+ const bySlug = /* @__PURE__ */ new Map();
656
+ const get = (siteName) => {
657
+ const slug = siteSlug(siteName);
658
+ let a = bySlug.get(slug);
659
+ if (!a) {
660
+ a = {
661
+ slug,
662
+ siteName,
663
+ reasons: [],
664
+ hasCritical: false,
665
+ broken: false,
666
+ approval: false,
667
+ slipping: false
668
+ };
669
+ bySlug.set(slug, a);
670
+ }
671
+ return a;
672
+ };
673
+ for (const card2 of model.cards) {
674
+ if (card2.tier === "attention") {
675
+ for (const item of card2.items) {
676
+ if (item.kind === "vuln" && item.autoFixExhausted !== true) continue;
677
+ const a = get(card2.site.name);
678
+ a.reasons.push(item.title);
679
+ a.broken = true;
680
+ if (item.severity === "critical") a.hasCritical = true;
681
+ }
682
+ } else if (card2.tier === "watch" && card2.watchReasons.length > 0) {
683
+ const a = get(card2.site.name);
684
+ for (const r of card2.watchReasons) a.reasons.push(r);
685
+ a.slipping = true;
686
+ }
687
+ }
688
+ for (const p of model.pending) {
689
+ const a = get(p.siteName);
690
+ a.reasons.push(`${p.reportType} ${p.period} ready`);
691
+ a.approval = true;
692
+ }
693
+ const items = [];
694
+ for (const a of bySlug.values()) {
695
+ if (a.reasons.length === 0) continue;
696
+ const group = a.broken ? "broken" : a.approval ? "approval" : "slipping";
697
+ items.push({
698
+ group,
699
+ hasCritical: a.hasCritical,
700
+ slug: a.slug,
701
+ siteName: a.siteName,
702
+ reasons: a.reasons,
703
+ url: `/s/${a.slug}`
704
+ });
705
+ }
706
+ items.sort((x, y) => {
707
+ if (NEEDS_YOU_GROUP_RANK[x.group] !== NEEDS_YOU_GROUP_RANK[y.group])
708
+ return NEEDS_YOU_GROUP_RANK[x.group] - NEEDS_YOU_GROUP_RANK[y.group];
709
+ if (x.group === "broken" && x.hasCritical !== y.hasCritical) return x.hasCritical ? -1 : 1;
710
+ return x.siteName.toLowerCase().localeCompare(y.siteName.toLowerCase());
711
+ });
712
+ return items;
713
+ }
714
+ function fleetLastAuditedAt(cards) {
715
+ let latestIso = null;
716
+ let latestMs = -Infinity;
717
+ for (const c of cards) {
718
+ const iso = c.site.lastLighthouseAuditAt;
719
+ if (!iso) continue;
720
+ const ms = Date.parse(iso);
721
+ if (!Number.isFinite(ms)) continue;
722
+ if (ms > latestMs) {
723
+ latestMs = ms;
724
+ latestIso = iso;
725
+ }
726
+ }
727
+ return latestIso;
728
+ }
729
+
730
+ // src/dashboard/fleet-browse-render.ts
640
731
  var DASH2 = "\u2014";
641
732
  function scoreSpan(category, value) {
642
733
  const display = value === null ? DASH2 : String(value);
@@ -696,10 +787,115 @@ function card(site) {
696
787
  site.securityVulnsModerate,
697
788
  site.securityVulnsLow
698
789
  )}
790
+ <span class="metric-label">deploy</span> ${deployBadge(site)}
699
791
  </span>
700
792
  </div>
701
793
  </article>`;
702
794
  }
795
+ function deployBadge(site) {
796
+ const state = site.deployStatus;
797
+ if (state === null) {
798
+ return `<span class="metric deploy unknown" title="No deploy data">${DASH2}</span>`;
799
+ }
800
+ const cls = isReadyDeployStatus(state) ? "deploy ready" : isFailedDeployStatus(state) ? "deploy failed" : "deploy building";
801
+ const when = relativeTimeFromNow(site.lastDeployAt);
802
+ const label = escapeHtml(`${state}${when !== DASH2 ? ` \xB7 ${when}` : ""}`);
803
+ const url = site.deployLogUrl ? safeUrl(site.deployLogUrl) : "#";
804
+ if (url !== "#") {
805
+ return `<a class="metric ${cls}" href="${escapeHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
806
+ }
807
+ return `<span class="metric ${cls}">${label}</span>`;
808
+ }
809
+ function submBadge(c) {
810
+ const n = c.newSubmissions ?? 0;
811
+ return n > 0 ? `<span class="chip">\u{1F4E5} ${n} new</span>` : "";
812
+ }
813
+ var PILL_LABEL = { attention: "failing", watch: "watch", healthy: "ok" };
814
+ function attentionBadge(status) {
815
+ if (status === "new") return `<span class="badge">NEW</span>`;
816
+ if (status === "worse") return `<span class="badge">WORSE</span>`;
817
+ return "";
818
+ }
819
+ function chips(c) {
820
+ const items = c.items.map((it) => {
821
+ const cls = it.autoFixExhausted ? "chip critical stuck" : it.severity === "critical" ? "chip critical" : "chip";
822
+ return `<span class="${cls}">${attentionBadge(it.status)}${escapeHtml(it.title)}</span>`;
823
+ });
824
+ for (const reason of c.watchReasons)
825
+ items.push(`<span class="chip">${escapeHtml(reason)}</span>`);
826
+ return items.length ? `<div class="chips">${items.join("")}</div>` : "";
827
+ }
828
+ function signalsAttr(c) {
829
+ const kinds = /* @__PURE__ */ new Set();
830
+ for (const it of c.items) {
831
+ kinds.add(it.kind === "vuln" ? "vulns" : it.kind === "renovate" ? "prs" : it.kind);
832
+ }
833
+ if (c.items.some((it) => it.autoFixExhausted)) kinds.add("auto-fix-failed");
834
+ for (const sig of c.watchSignals) kinds.add(sig);
835
+ return [...kinds].join(" ");
836
+ }
837
+ function triggerRenovateBtn(c) {
838
+ if (!c.site.gitRepo?.trim()) return "";
839
+ const url = `/api/sites/${escapeHtml(siteSlug(c.site.name))}/trigger-renovate`;
840
+ return `<button class="trigger-renovate" data-trigger-url="${url}">Trigger Renovate</button>`;
841
+ }
842
+ function cockpitCard(c) {
843
+ const base = card(c.site);
844
+ const pill = `<span class="pill ${c.tier}">${PILL_LABEL[c.tier]}</span>`;
845
+ const extra = `${pill}${chips(c)}${submBadge(c)}${triggerRenovateBtn(c)}`;
846
+ const opening = `<article class="card" data-signals="${signalsAttr(c)}">`;
847
+ return base.replace('<article class="card">', () => opening).replace("</article>", () => `${extra}</article>`);
848
+ }
849
+ var FLEET_FILTERS = [
850
+ "all",
851
+ "vulns",
852
+ "lighthouse",
853
+ "delivery",
854
+ "prs",
855
+ "ci",
856
+ "auto-fix-failed",
857
+ "stale",
858
+ "no-domain"
859
+ ];
860
+ function renderFleetBrowsePanel(model) {
861
+ const total = model.cards.length;
862
+ const chips2 = FLEET_FILTERS.map(
863
+ (f) => `<button type="button" data-filter="${f}" aria-pressed="${f === "all" ? "true" : "false"}">${f}</button>`
864
+ ).join("");
865
+ const body = total === 0 ? `<div class="empty">No sites on the fleet view yet.</div>` : `<div class="cards">${model.cards.map(cockpitCard).join("")}</div>`;
866
+ return `<details class="fleet-browse">
867
+ <summary>Fleet (${total})</summary>
868
+ <div class="filters">${chips2}</div>
869
+ ${body}
870
+ </details>`;
871
+ }
872
+ var FLEET_BROWSE_SCRIPT = `<script>
873
+ (function(){
874
+ var btns = document.querySelectorAll('.fleet-browse .filters button');
875
+ var cards = document.querySelectorAll('.fleet-browse .cards .card');
876
+ btns.forEach(function(b){
877
+ b.addEventListener('click', function(){
878
+ var f = b.getAttribute('data-filter');
879
+ btns.forEach(function(x){ x.setAttribute('aria-pressed', x===b ? 'true':'false'); });
880
+ cards.forEach(function(c){
881
+ var sig = (c.getAttribute('data-signals')||'').split(' ');
882
+ c.style.display = (f==='all' || sig.indexOf(f)!==-1) ? '' : 'none';
883
+ });
884
+ });
885
+ });
886
+ document.querySelectorAll('.fleet-browse button.trigger-renovate').forEach(function(b){
887
+ b.addEventListener('click', async function(){
888
+ b.disabled = true; b.textContent = 'Dispatching\u2026';
889
+ try { var res = await fetch(b.dataset.triggerUrl, { method: 'POST' });
890
+ b.textContent = res.ok ? 'Dispatched \u2713' : 'Failed';
891
+ if (!res.ok) b.disabled = false; }
892
+ catch(e){ b.textContent = 'Failed'; b.disabled = false; }
893
+ });
894
+ });
895
+ })();
896
+ </script>`;
897
+
898
+ // src/dashboard/fleet-render.ts
703
899
  var STYLES2 = `
704
900
  :root { color-scheme: light dark; }
705
901
  body { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
@@ -720,12 +916,18 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
720
916
  .cluster.lighthouse .score { display: inline-block; min-width: 2.25rem; text-align: right; }
721
917
  .metric-label { color: #999; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
722
918
  .metric { font-feature-settings: "tnum"; }
723
- .summary { display:flex; flex-wrap:wrap; gap:0.5rem 1.25rem; align-items:baseline; margin-bottom:0.5rem; }
724
- .summary .tier { font-weight:700; }
725
- .summary .heads { color:#666; font-size:0.9rem; }
919
+ .metric.deploy { font-size:0.78rem; padding:0.05rem 0.4rem; border-radius:6px; text-decoration:none; }
920
+ .metric.deploy.ready { background:#e6f4ea; color:#1a7f37; }
921
+ .metric.deploy.failed { background:#fdecea; color:#b00; font-weight:600; }
922
+ .metric.deploy.building { background:#fff4e5; color:#9a6700; }
923
+ .metric.deploy.unknown { color:#999; }
924
+ @media (prefers-color-scheme: dark) {
925
+ .metric.deploy.ready { background:#0f2417; color:#4ac26b; }
926
+ .metric.deploy.failed { background:#3a1412; color:#ff6b6b; }
927
+ .metric.deploy.building { background:#2e2410; color:#e3b341; }
928
+ }
726
929
  .spam-rollup { font-size:0.9rem; margin-bottom:1rem; }
727
930
  .muted { color:#999; }
728
- .subm-viewall { font-size:0.8rem; font-weight:normal; margin-left:0.4rem; white-space:nowrap; }
729
931
  .filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }
730
932
  .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
933
  .filters button[aria-pressed="true"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
@@ -740,11 +942,8 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
740
942
  .rf-sub { color:#999; font-size:0.8rem; margin-left:1.1rem; }
741
943
  .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; }
742
944
  @keyframes rf-spin { to { transform:rotate(360deg); } }
743
- details.tier { margin:0.75rem 0; }
744
- details.tier > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }
745
- .approve-strip { border:1px solid #ffe08a; background:#fff8e1; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }
746
- @media (prefers-color-scheme: dark) { .approve-strip { background:#241f00; border-color:#5a4d00; } }
747
- .approve-strip h2 { font-size:1rem; margin:0 0 0.5rem; }
945
+ details.fleet-browse, details.inbox { margin:0.75rem 0; }
946
+ details.fleet-browse > summary, details.inbox > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }
748
947
  .approve-row { display:flex; flex-wrap:wrap; gap:0.5rem 1rem; align-items:center; padding:0.25rem 0; }
749
948
  .pill { font-size:0.75rem; padding:0.1rem 0.5rem; border-radius:999px; font-weight:700; }
750
949
  .pill.attention { background:#fdecea; color:#b00; }
@@ -756,85 +955,108 @@ details.tier > summary { cursor:pointer; font-weight:700; font-size:1.05rem; pad
756
955
  .chip.critical { background:#fdecea; color:#b00; }
757
956
  .chip.stuck { border:1px solid #b00; font-weight:600; }
758
957
  .badge { font-weight:700; color:#C00; font-size:0.72rem; margin-right:0.25rem; }
759
- .all-clear { background:#e8f5e9; color:#1b7a2f; padding:0.6rem 1rem; border-radius:8px; margin-bottom:1.25rem; font-weight:600; }
760
- @media (prefers-color-scheme: dark) { .all-clear { background:#10240f; color:#7fce85; } }
958
+ .verdict { border-radius:8px; padding:0.9rem 1.1rem; margin-bottom:1.25rem; }
959
+ .verdict .verdict-line { font-weight:800; font-size:1.4rem; }
960
+ .verdict .verdict-meta { color:#666; font-size:0.9rem; margin-top:0.2rem; }
961
+ .verdict.ok { background:#e8f5e9; color:#1b7a2f; }
962
+ .verdict.ok .verdict-meta { color:#2e7d32; }
963
+ .verdict.warn { background:#fdecea; color:#b00; }
964
+ .verdict.warn .verdict-meta { color:#b00; opacity:0.85; }
965
+ @media (prefers-color-scheme: dark) { .verdict.ok { background:#10240f; color:#7fce85; } .verdict.warn { background:#2a0f0d; color:#ff8a80; } }
966
+ .verdict .fleet-actions { margin:0.6rem 0 0; }
967
+ .needs-you { border:1px solid #e5e5e5; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }
968
+ @media (prefers-color-scheme: dark) { .needs-you { border-color:#2a2a2a; background:#181818; } }
969
+ .needs-you h2 { font-size:1.05rem; margin:0 0 0.5rem; }
970
+ .feed-group-label { text-transform:uppercase; letter-spacing:0.04em; font-size:0.72rem; color:#999; margin:0.6rem 0 0.2rem; }
971
+ .feed-row { display:flex; gap:0.5rem; align-items:center; padding:0.3rem 0; border-bottom:1px dashed #eee; }
972
+ .feed-row:last-child { border-bottom:0; }
973
+ @media (prefers-color-scheme: dark) { .feed-row { border-bottom-color:#262626; } }
974
+ .feed-what { flex:1; }
975
+ .feed-open { white-space:nowrap; }
976
+ .dot { width:0.55rem; height:0.55rem; border-radius:50%; display:inline-block; flex:0 0 auto; }
977
+ .dot.broken { background:#dc2626; }
978
+ .dot.approval { background:#2563eb; }
979
+ .dot.slipping { background:#f59e0b; }
761
980
  `;
762
- var TIER_META = {
763
- attention: { emoji: "\u{1F534}", label: "Needs attention", open: true },
764
- watch: { emoji: "\u{1F7E1}", label: "Watch", open: false },
765
- healthy: { emoji: "\u{1F7E2}", label: "Healthy", open: false }
766
- };
767
- var FILTERS = [
768
- "all",
769
- "vulns",
770
- "lighthouse",
771
- "delivery",
772
- "prs",
773
- "ci",
774
- "auto-fix-failed",
775
- "stale",
776
- "no-domain",
777
- "pending",
778
- "submissions"
779
- ];
780
- function summaryBar(model) {
781
- const s = model.summary;
782
- const heads = [
783
- `${s.criticalHighVulns} critical/high vuln${s.criticalHighVulns === 1 ? "" : "s"}`,
784
- `${s.lighthouseBelowFloor} Lighthouse<75`,
785
- `${s.deliveryFailures} delivery`,
786
- `${s.renovateFailing} PRs failing`,
787
- `${s.ciRed} CI red`,
788
- `${s.autoFixStuck} auto-fix stuck`,
789
- `${s.pending} pending`,
790
- `${s.newSubmissions ?? 0} new`
791
- ].join(" \xB7 ");
792
- const chips2 = FILTERS.map(
793
- (f) => `<button type="button" data-filter="${f}" aria-pressed="${f === "all" ? "true" : "false"}">${f}</button>`
794
- ).join("");
795
- return `<div class="summary">
796
- <span class="tier">\u{1F534} ${s.attention} needs attention</span>
797
- <span class="tier">\u{1F7E1} ${s.watch} watch</span>
798
- <span class="tier">\u{1F7E2} ${s.healthy} healthy</span>
799
- </div>
800
- <div class="summary heads">${escapeHtml(heads)}</div>
801
- <div class="filters">${chips2}</div>
802
- <div class="fleet-actions">
803
- <button type="button" class="refresh-fleet" data-refresh-url="/api/fleet/refresh">\u21BB Refresh fleet state</button>
981
+ function verdictBar(model, feedCount) {
982
+ const auditedIso = fleetLastAuditedAt(model.cards);
983
+ const audited = auditedIso ? ` \xB7 fleet last audited ${escapeHtml(relativeTimeFromNow(auditedIso))}` : "";
984
+ const sites = model.cards.length;
985
+ const sitesWord = `${sites} site${sites === 1 ? "" : "s"}`;
986
+ const actions = `<div class="fleet-actions">
987
+ <button type="button" class="refresh-fleet" data-refresh-url="/api/fleet/refresh">\u21BB Audit fleet</button>
804
988
  <div id="rf-status" class="rf-status" aria-live="polite"></div>
805
989
  </div>`;
990
+ if (feedCount === 0) {
991
+ return `<div class="verdict ok">
992
+ <div class="verdict-line">\u2713 All clear</div>
993
+ <div class="verdict-meta">${sitesWord} healthy${audited}</div>
994
+ ${actions}
995
+ </div>`;
996
+ }
997
+ const noun = feedCount === 1 ? "site needs" : "sites need";
998
+ return `<div class="verdict warn">
999
+ <div class="verdict-line">\u26A0 ${feedCount} ${noun} you</div>
1000
+ <div class="verdict-meta">${sitesWord}${audited}</div>
1001
+ ${actions}
1002
+ </div>`;
806
1003
  }
807
- function spamRollup(model) {
808
- const s = model.spam;
809
- if (!s || s.caught === 0 && s.through === 0) return "";
810
- return `<div class="spam-rollup muted">\u{1F6E1} Spam (30d) \u2014 caught ${s.caught} \xB7 through ${s.through}</div>`;
811
- }
812
- function allClearBanner(model) {
813
- if (model.summary.attention > 0) return "";
814
- const msg = model.cards.length === 0 ? "No sites on the fleet view yet." : "All clear \u2014 nothing needs your attention.";
815
- return `<div class="all-clear">\u2713 ${escapeHtml(msg)}</div>`;
1004
+ var NEEDS_YOU_GROUP_LABEL = {
1005
+ broken: "Broken",
1006
+ approval: "Waiting on your yes",
1007
+ slipping: "Slipping"
1008
+ };
1009
+ function renderNeedsYouFeed(feed) {
1010
+ if (feed.length === 0) return "";
1011
+ const groups = ["broken", "approval", "slipping"];
1012
+ const blocks = groups.map((g) => {
1013
+ const rows = feed.filter((i) => i.group === g);
1014
+ if (rows.length === 0) return "";
1015
+ const lis = rows.map(
1016
+ (i) => `<div class="feed-row" data-group="${g}">
1017
+ <span class="dot ${g}"></span>
1018
+ <span class="feed-what"><strong>${escapeHtml(i.siteName)}</strong> \u2014 ${escapeHtml(i.reasons.join(" \xB7 "))}</span>
1019
+ <a class="feed-open" href="${escapeHtml(i.url)}">Open \u25B8</a>
1020
+ </div>`
1021
+ ).join("");
1022
+ return `<div class="feed-group"><div class="feed-group-label">${NEEDS_YOU_GROUP_LABEL[g]}</div>${lis}</div>`;
1023
+ }).join("");
1024
+ return `<section class="needs-you"><h2>Needs you (${feed.length})</h2>${blocks}</section>`;
816
1025
  }
817
- function approveStrip(model) {
818
- if (model.pending.length === 0) return "";
819
- const rows = model.pending.map((p) => {
820
- const href = `/s/${escapeHtml(p.slug)}`;
821
- const url = `/api/reports/${encodeURIComponent(p.reportId)}/approve`;
822
- return `<div class="approve-row" data-signal="pending">
823
- <strong>${escapeHtml(p.siteName)}</strong>
824
- <span class="muted">${escapeHtml(p.reportType)} ${escapeHtml(p.period)}</span>
825
- <button class="approve" data-report-id="${escapeHtml(p.reportId)}" data-approve-url="${escapeHtml(url)}">Approve</button>
826
- <a href="${href}">open \u25B8</a>
1026
+ var SUBMISSIONS_STRIP_CAP = 10;
1027
+ var RECENT_ICON = {
1028
+ pr_automerged: "\u{1F527}",
1029
+ vuln_cleared: "\u{1F6E1}",
1030
+ ci_recovered: "\u2705",
1031
+ site_launched: "\u{1F680}",
1032
+ cert_renewed: "\u{1F512}",
1033
+ fleet_swept: "\u{1F504}"
1034
+ };
1035
+ function renderRecentlyLane(model) {
1036
+ const events = model.recent ?? [];
1037
+ if (events.length === 0) return "";
1038
+ const rows = events.map((e) => {
1039
+ const icon = RECENT_ICON[e.type] ?? "\u2022";
1040
+ const when = escapeHtml(relativeTimeFromNow(e.ts));
1041
+ const site = e.siteName ? `<strong>${escapeHtml(e.siteName)}</strong> \u2014 ` : "";
1042
+ const link = e.url ? `<a href="${escapeHtml(e.url)}">view \u25B8</a>` : e.slug ? `<a href="/s/${escapeHtml(e.slug)}">open \u25B8</a>` : "";
1043
+ return `<div class="recent-row" data-type="${e.type}">
1044
+ <span class="recent-icon">${icon}</span>
1045
+ <span class="recent-what">${site}${escapeHtml(e.summary)}</span>
1046
+ <span class="muted">${when}</span>
1047
+ ${link}
827
1048
  </div>`;
828
1049
  }).join("");
829
- return `<section class="approve-strip" data-tier="pending">
830
- <h2>Approve (${model.pending.length}) \u2014 your daily yes</h2>
1050
+ return `<details class="recently">
1051
+ <summary>\u{1F527} Recently (${events.length})</summary>
831
1052
  ${rows}
832
- </section>`;
1053
+ </details>`;
833
1054
  }
834
- var SUBMISSIONS_STRIP_CAP = 10;
835
- function submissionsStrip(model) {
1055
+ function renderInboxLane(model) {
836
1056
  const subs = model.submissions ?? [];
837
- if (subs.length === 0) return "";
1057
+ const spam = model.spam;
1058
+ const hasSpam = !!spam && (spam.caught > 0 || spam.through > 0);
1059
+ if (subs.length === 0 && !hasSpam) return "";
838
1060
  const shown = [...subs].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_STRIP_CAP);
839
1061
  const rows = shown.map((sub) => {
840
1062
  const href = `/s/${escapeHtml(sub.slug)}`;
@@ -848,91 +1070,17 @@ function submissionsStrip(model) {
848
1070
  </div>`;
849
1071
  }).join("");
850
1072
  const overflow = subs.length - shown.length;
851
- const more = overflow > 0 ? `<div class="approve-row subm-more muted"><a href="/submissions">+${overflow} more \u2014 view all submissions</a></div>` : "";
852
- return `<section class="approve-strip subm-strip" data-tier="submissions">
853
- <h2>\u{1F4E5} New submissions (${subs.length}) <a class="subm-viewall" href="/submissions">View all \u2192</a></h2>
854
- ${rows}${more}
855
- </section>`;
1073
+ const more = overflow > 0 ? `<div class="approve-row subm-more muted"><a href="/submissions">+${overflow} more \u2014 view all submissions</a></div>` : `<div class="approve-row subm-more muted"><a href="/submissions">View all submissions \u2192</a></div>`;
1074
+ const spamLine = hasSpam ? `<div class="spam-rollup muted">\u{1F6E1} Spam (30d) \u2014 caught ${spam.caught} \xB7 through ${spam.through}</div>` : "";
1075
+ const spamInSummary = hasSpam ? " \xB7 \u{1F6E1} Spam (30d)" : "";
1076
+ return `<details class="inbox">
1077
+ <summary>\u{1F4E5} Submissions (${subs.length} new)${spamInSummary}</summary>
1078
+ ${rows}${subs.length > 0 ? more : ""}
1079
+ ${spamLine}
1080
+ </details>`;
856
1081
  }
857
- function submBadge(c) {
858
- const n = c.newSubmissions ?? 0;
859
- return n > 0 ? `<span class="chip">\u{1F4E5} ${n} new</span>` : "";
860
- }
861
- var PILL_LABEL = { attention: "failing", watch: "watch", healthy: "ok" };
862
- function attentionBadge(status) {
863
- if (status === "new") return `<span class="badge">NEW</span>`;
864
- if (status === "worse") return `<span class="badge">WORSE</span>`;
865
- return "";
866
- }
867
- function chips(c) {
868
- const items = c.items.map((it) => {
869
- const cls = it.autoFixExhausted ? "chip critical stuck" : it.severity === "critical" ? "chip critical" : "chip";
870
- return `<span class="${cls}">${attentionBadge(it.status)}${escapeHtml(it.title)}</span>`;
871
- });
872
- for (const reason of c.watchReasons)
873
- items.push(`<span class="chip">${escapeHtml(reason)}</span>`);
874
- return items.length ? `<div class="chips">${items.join("")}</div>` : "";
875
- }
876
- function signalsAttr(c) {
877
- const kinds = /* @__PURE__ */ new Set();
878
- for (const it of c.items) {
879
- kinds.add(it.kind === "vuln" ? "vulns" : it.kind === "renovate" ? "prs" : it.kind);
880
- }
881
- if (c.items.some((it) => it.autoFixExhausted)) kinds.add("auto-fix-failed");
882
- for (const sig of c.watchSignals) kinds.add(sig);
883
- return [...kinds].join(" ");
884
- }
885
- function triggerRenovateBtn(c) {
886
- if (!c.site.gitRepo?.trim()) return "";
887
- const url = `/api/sites/${escapeHtml(siteSlug(c.site.name))}/trigger-renovate`;
888
- return `<button class="trigger-renovate" data-trigger-url="${url}">Trigger Renovate</button>`;
889
- }
890
- function cockpitCard(c) {
891
- const base = card(c.site);
892
- const pill = `<span class="pill ${c.tier}">${PILL_LABEL[c.tier]}</span>`;
893
- const extra = `${pill}${chips(c)}${submBadge(c)}${triggerRenovateBtn(c)}`;
894
- const opening = `<article class="card" data-signals="${signalsAttr(c)}">`;
895
- return base.replace('<article class="card">', () => opening).replace("</article>", () => `${extra}</article>`);
896
- }
897
- var FILTER_SCRIPT = `<script>
1082
+ var AUDIT_SCRIPT = `<script>
898
1083
  (function(){
899
- var btns = document.querySelectorAll('.filters button');
900
- var cards = document.querySelectorAll('.cards .card');
901
- var details = document.querySelectorAll('details.tier');
902
- btns.forEach(function(b){
903
- b.addEventListener('click', function(){
904
- var f = b.getAttribute('data-filter');
905
- btns.forEach(function(x){ x.setAttribute('aria-pressed', x===b ? 'true':'false'); });
906
- // "pending" lives on the approve strip, not on tier cards \u2014 just jump to it,
907
- // never hide the triage cards (else the whole board blanks).
908
- if (f === 'pending') { var s = document.querySelector('.approve-strip'); if (s) s.scrollIntoView({behavior:'smooth'}); return; }
909
- if (f === 'submissions') { var ss = document.querySelector('[data-tier="submissions"]'); if (ss) ss.scrollIntoView({behavior:'smooth'}); return; }
910
- if (f !== 'all') details.forEach(function(d){ d.open = true; });
911
- cards.forEach(function(c){
912
- var sig = (c.getAttribute('data-signals')||'').split(' ');
913
- c.style.display = (f==='all' || sig.indexOf(f)!==-1) ? '' : 'none';
914
- });
915
- });
916
- });
917
- // approve buttons: mirror the per-site dashboard's inline POST.
918
- document.querySelectorAll('button.approve').forEach(function(b){
919
- b.addEventListener('click', async function(){
920
- b.disabled = true; b.textContent = 'Approving\u2026';
921
- try { var res = await fetch(b.dataset.approveUrl, { method: 'POST' });
922
- b.textContent = res.ok ? 'Approved \u2713' : 'Failed'; }
923
- catch(e){ b.textContent = 'Failed'; b.disabled = false; }
924
- });
925
- });
926
- // trigger-renovate buttons: fire the on-demand dispatch (async, fire-and-forget).
927
- document.querySelectorAll('button.trigger-renovate').forEach(function(b){
928
- b.addEventListener('click', async function(){
929
- b.disabled = true; b.textContent = 'Dispatching\u2026';
930
- try { var res = await fetch(b.dataset.triggerUrl, { method: 'POST' });
931
- b.textContent = res.ok ? 'Dispatched \u2713' : 'Failed';
932
- if (!res.ok) b.disabled = false; }
933
- catch(e){ b.textContent = 'Failed'; b.disabled = false; }
934
- });
935
- });
936
1084
  // fleet-refresh live status: dispatch, then poll the actual runs and follow them.
937
1085
  // Vanilla JS, string-concat only (no template literals) \u2014 this lives inside a TS
938
1086
  // template string, so backticks or interpolation syntax would break the server render.
@@ -990,7 +1138,7 @@ var FILTER_SCRIPT = `<script>
990
1138
  var p = rfPanel();
991
1139
  if (data && data.authFail){
992
1140
  if (p) p.innerHTML += '<div class="rf-row">Session expired \u2014 reload to sign in.</div>';
993
- if (rf){ rf.disabled = false; rf.textContent = '\u21BB Refresh fleet state'; }
1141
+ if (rf){ rf.disabled = false; rf.textContent = '\u21BB Audit fleet'; }
994
1142
  rfStop(); return;
995
1143
  }
996
1144
  if (data && data.status){
@@ -1001,13 +1149,13 @@ var FILTER_SCRIPT = `<script>
1001
1149
  rfStop(); setTimeout(function(){ location.reload(); }, 2000); return;
1002
1150
  }
1003
1151
  if (p) p.innerHTML += '<div class="rf-row"><button type="button" onclick="location.reload()">Reload</button></div>';
1004
- if (rf){ rf.disabled = false; rf.textContent = '\u21BB Refresh fleet state'; }
1152
+ if (rf){ rf.disabled = false; rf.textContent = '\u21BB Audit fleet'; }
1005
1153
  rfStop(); return;
1006
1154
  }
1007
1155
  }
1008
1156
  if (Date.now() - startedAt > RF_MAX_MS){
1009
1157
  if (p) p.innerHTML += '<div class="rf-row">Still running \u2014 reload later.</div>';
1010
- if (rf){ rf.disabled = false; rf.textContent = '\u21BB Refresh fleet state'; }
1158
+ if (rf){ rf.disabled = false; rf.textContent = '\u21BB Audit fleet'; }
1011
1159
  rfStop(); return;
1012
1160
  }
1013
1161
  setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);
@@ -1015,7 +1163,7 @@ var FILTER_SCRIPT = `<script>
1015
1163
  var p = rfPanel();
1016
1164
  if (Date.now() - startedAt > RF_MAX_MS){
1017
1165
  if (p) p.innerHTML += '<div class="rf-row">Still running \u2014 reload later.</div>';
1018
- if (rf){ rf.disabled = false; rf.textContent = '\u21BB Refresh fleet state'; }
1166
+ if (rf){ rf.disabled = false; rf.textContent = '\u21BB Audit fleet'; }
1019
1167
  rfStop(); return;
1020
1168
  }
1021
1169
  setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);
@@ -1029,12 +1177,12 @@ var FILTER_SCRIPT = `<script>
1029
1177
  var rf = document.querySelector('button.refresh-fleet');
1030
1178
  if (rf) rf.addEventListener('click', async function(){
1031
1179
  if (!confirm('Kick off the security + Lighthouse sweeps for the whole fleet? They take a few minutes.')) return;
1032
- rf.disabled = true; rf.textContent = 'Refreshing\u2026';
1180
+ rf.disabled = true; rf.textContent = 'Auditing\u2026';
1033
1181
  try {
1034
1182
  var res = await fetch(rf.dataset.refreshUrl, { method: 'POST' });
1035
1183
  if (res.ok){
1036
1184
  var data = await res.json();
1037
- rf.textContent = '\u21BB Refresh running\u2026';
1185
+ rf.textContent = '\u21BB Audit running\u2026';
1038
1186
  if (data && data.since) rfBegin(data.since, Date.now());
1039
1187
  } else { rf.textContent = 'Failed to start'; rf.disabled = false; }
1040
1188
  } catch(e){ rf.textContent = 'Failed to start'; rf.disabled = false; }
@@ -1043,7 +1191,7 @@ var FILTER_SCRIPT = `<script>
1043
1191
  try {
1044
1192
  var rfSaved = JSON.parse(localStorage.getItem(RF_KEY) || 'null');
1045
1193
  if (rfSaved && rfSaved.since && rfSaved.startedAt && (Date.now() - rfSaved.startedAt) < RF_MAX_MS){
1046
- if (rf){ rf.disabled = true; rf.textContent = '\u21BB Refresh running\u2026'; }
1194
+ if (rf){ rf.disabled = true; rf.textContent = '\u21BB Audit running\u2026'; }
1047
1195
  rfBegin(rfSaved.since, rfSaved.startedAt);
1048
1196
  } else if (rfSaved) { rfStop(); }
1049
1197
  } catch(e){}
@@ -1051,16 +1199,7 @@ var FILTER_SCRIPT = `<script>
1051
1199
  </script>`;
1052
1200
  function renderCockpitHtml(model) {
1053
1201
  const total = model.cards.length;
1054
- const tiers = ["attention", "watch", "healthy"];
1055
- const sections = tiers.map((tier) => {
1056
- const cards = model.cards.filter((c) => c.tier === tier);
1057
- const meta = TIER_META[tier];
1058
- const body = cards.length === 0 ? `<div class="empty">None.</div>` : `<div class="cards">${cards.map(cockpitCard).join("")}</div>`;
1059
- return `<details class="tier" data-tier="${tier}"${meta.open ? " open" : ""}>
1060
- <summary>${meta.emoji} ${meta.label} (${cards.length})</summary>
1061
- ${body}
1062
- </details>`;
1063
- }).join("");
1202
+ const feed = buildNeedsYouFeed(model);
1064
1203
  return `<!doctype html>
1065
1204
  <html lang="en">
1066
1205
  <head>
@@ -1073,13 +1212,13 @@ function renderCockpitHtml(model) {
1073
1212
  <body>
1074
1213
  <h1>Reddoor fleet cockpit</h1>
1075
1214
  <div class="meta">${total} site${total === 1 ? "" : "s"} on the Reddoor stack.</div>
1076
- ${summaryBar(model)}
1077
- ${allClearBanner(model)}
1078
- ${approveStrip(model)}
1079
- ${sections}
1080
- ${spamRollup(model)}
1081
- ${submissionsStrip(model)}
1082
- ${FILTER_SCRIPT}
1215
+ ${verdictBar(model, feed.length)}
1216
+ ${renderNeedsYouFeed(feed)}
1217
+ ${renderFleetBrowsePanel(model)}
1218
+ ${renderRecentlyLane(model)}
1219
+ ${renderInboxLane(model)}
1220
+ ${AUDIT_SCRIPT}
1221
+ ${FLEET_BROWSE_SCRIPT}
1083
1222
  </body>
1084
1223
  </html>`;
1085
1224
  }