@reddoorla/maintenance 0.60.0 → 0.61.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/index.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  selfCaretRange,
12
12
  selfPackageVersion
13
13
  } from "./chunk-ZYBOT5PM.js";
14
+ import "./chunk-VDBENQUA.js";
14
15
  import {
15
16
  findDueReports
16
17
  } from "./chunk-WVRPOXKH.js";
@@ -636,7 +637,86 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
636
637
  </html>`;
637
638
  }
638
639
 
639
- // src/dashboard/fleet-render.ts
640
+ // src/dashboard/fleet-cockpit.ts
641
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
642
+ var NEEDS_YOU_GROUP_RANK = { broken: 0, approval: 1, slipping: 2 };
643
+ function buildNeedsYouFeed(model) {
644
+ const bySlug = /* @__PURE__ */ new Map();
645
+ const get = (siteName) => {
646
+ const slug = siteSlug(siteName);
647
+ let a = bySlug.get(slug);
648
+ if (!a) {
649
+ a = {
650
+ slug,
651
+ siteName,
652
+ reasons: [],
653
+ hasCritical: false,
654
+ broken: false,
655
+ approval: false,
656
+ slipping: false
657
+ };
658
+ bySlug.set(slug, a);
659
+ }
660
+ return a;
661
+ };
662
+ for (const card2 of model.cards) {
663
+ if (card2.tier === "attention") {
664
+ for (const item of card2.items) {
665
+ if (item.kind === "vuln" && item.autoFixExhausted !== true) continue;
666
+ const a = get(card2.site.name);
667
+ a.reasons.push(item.title);
668
+ a.broken = true;
669
+ if (item.severity === "critical") a.hasCritical = true;
670
+ }
671
+ } else if (card2.tier === "watch" && card2.watchReasons.length > 0) {
672
+ const a = get(card2.site.name);
673
+ for (const r of card2.watchReasons) a.reasons.push(r);
674
+ a.slipping = true;
675
+ }
676
+ }
677
+ for (const p of model.pending) {
678
+ const a = get(p.siteName);
679
+ a.reasons.push(`${p.reportType} ${p.period} ready`);
680
+ a.approval = true;
681
+ }
682
+ const items = [];
683
+ for (const a of bySlug.values()) {
684
+ if (a.reasons.length === 0) continue;
685
+ const group = a.broken ? "broken" : a.approval ? "approval" : "slipping";
686
+ items.push({
687
+ group,
688
+ hasCritical: a.hasCritical,
689
+ slug: a.slug,
690
+ siteName: a.siteName,
691
+ reasons: a.reasons,
692
+ url: `/s/${a.slug}`
693
+ });
694
+ }
695
+ items.sort((x, y) => {
696
+ if (NEEDS_YOU_GROUP_RANK[x.group] !== NEEDS_YOU_GROUP_RANK[y.group])
697
+ return NEEDS_YOU_GROUP_RANK[x.group] - NEEDS_YOU_GROUP_RANK[y.group];
698
+ if (x.group === "broken" && x.hasCritical !== y.hasCritical) return x.hasCritical ? -1 : 1;
699
+ return x.siteName.toLowerCase().localeCompare(y.siteName.toLowerCase());
700
+ });
701
+ return items;
702
+ }
703
+ function fleetLastAuditedAt(cards) {
704
+ let latestIso = null;
705
+ let latestMs = -Infinity;
706
+ for (const c of cards) {
707
+ const iso = c.site.lastLighthouseAuditAt;
708
+ if (!iso) continue;
709
+ const ms = Date.parse(iso);
710
+ if (!Number.isFinite(ms)) continue;
711
+ if (ms > latestMs) {
712
+ latestMs = ms;
713
+ latestIso = iso;
714
+ }
715
+ }
716
+ return latestIso;
717
+ }
718
+
719
+ // src/dashboard/fleet-browse-render.ts
640
720
  var DASH2 = "\u2014";
641
721
  function scoreSpan(category, value) {
642
722
  const display = value === null ? DASH2 : String(value);
@@ -700,6 +780,96 @@ function card(site) {
700
780
  </div>
701
781
  </article>`;
702
782
  }
783
+ function submBadge(c) {
784
+ const n = c.newSubmissions ?? 0;
785
+ return n > 0 ? `<span class="chip">\u{1F4E5} ${n} new</span>` : "";
786
+ }
787
+ var PILL_LABEL = { attention: "failing", watch: "watch", healthy: "ok" };
788
+ function attentionBadge(status) {
789
+ if (status === "new") return `<span class="badge">NEW</span>`;
790
+ if (status === "worse") return `<span class="badge">WORSE</span>`;
791
+ return "";
792
+ }
793
+ function chips(c) {
794
+ const items = c.items.map((it) => {
795
+ const cls = it.autoFixExhausted ? "chip critical stuck" : it.severity === "critical" ? "chip critical" : "chip";
796
+ return `<span class="${cls}">${attentionBadge(it.status)}${escapeHtml(it.title)}</span>`;
797
+ });
798
+ for (const reason of c.watchReasons)
799
+ items.push(`<span class="chip">${escapeHtml(reason)}</span>`);
800
+ return items.length ? `<div class="chips">${items.join("")}</div>` : "";
801
+ }
802
+ function signalsAttr(c) {
803
+ const kinds = /* @__PURE__ */ new Set();
804
+ for (const it of c.items) {
805
+ kinds.add(it.kind === "vuln" ? "vulns" : it.kind === "renovate" ? "prs" : it.kind);
806
+ }
807
+ if (c.items.some((it) => it.autoFixExhausted)) kinds.add("auto-fix-failed");
808
+ for (const sig of c.watchSignals) kinds.add(sig);
809
+ return [...kinds].join(" ");
810
+ }
811
+ function triggerRenovateBtn(c) {
812
+ if (!c.site.gitRepo?.trim()) return "";
813
+ const url = `/api/sites/${escapeHtml(siteSlug(c.site.name))}/trigger-renovate`;
814
+ return `<button class="trigger-renovate" data-trigger-url="${url}">Trigger Renovate</button>`;
815
+ }
816
+ function cockpitCard(c) {
817
+ const base = card(c.site);
818
+ const pill = `<span class="pill ${c.tier}">${PILL_LABEL[c.tier]}</span>`;
819
+ const extra = `${pill}${chips(c)}${submBadge(c)}${triggerRenovateBtn(c)}`;
820
+ const opening = `<article class="card" data-signals="${signalsAttr(c)}">`;
821
+ return base.replace('<article class="card">', () => opening).replace("</article>", () => `${extra}</article>`);
822
+ }
823
+ var FLEET_FILTERS = [
824
+ "all",
825
+ "vulns",
826
+ "lighthouse",
827
+ "delivery",
828
+ "prs",
829
+ "ci",
830
+ "auto-fix-failed",
831
+ "stale",
832
+ "no-domain"
833
+ ];
834
+ function renderFleetBrowsePanel(model) {
835
+ const total = model.cards.length;
836
+ const chips2 = FLEET_FILTERS.map(
837
+ (f) => `<button type="button" data-filter="${f}" aria-pressed="${f === "all" ? "true" : "false"}">${f}</button>`
838
+ ).join("");
839
+ const body = total === 0 ? `<div class="empty">No sites on the fleet view yet.</div>` : `<div class="cards">${model.cards.map(cockpitCard).join("")}</div>`;
840
+ return `<details class="fleet-browse">
841
+ <summary>Fleet (${total})</summary>
842
+ <div class="filters">${chips2}</div>
843
+ ${body}
844
+ </details>`;
845
+ }
846
+ var FLEET_BROWSE_SCRIPT = `<script>
847
+ (function(){
848
+ var btns = document.querySelectorAll('.fleet-browse .filters button');
849
+ var cards = document.querySelectorAll('.fleet-browse .cards .card');
850
+ btns.forEach(function(b){
851
+ b.addEventListener('click', function(){
852
+ var f = b.getAttribute('data-filter');
853
+ btns.forEach(function(x){ x.setAttribute('aria-pressed', x===b ? 'true':'false'); });
854
+ cards.forEach(function(c){
855
+ var sig = (c.getAttribute('data-signals')||'').split(' ');
856
+ c.style.display = (f==='all' || sig.indexOf(f)!==-1) ? '' : 'none';
857
+ });
858
+ });
859
+ });
860
+ document.querySelectorAll('.fleet-browse button.trigger-renovate').forEach(function(b){
861
+ b.addEventListener('click', async function(){
862
+ b.disabled = true; b.textContent = 'Dispatching\u2026';
863
+ try { var res = await fetch(b.dataset.triggerUrl, { method: 'POST' });
864
+ b.textContent = res.ok ? 'Dispatched \u2713' : 'Failed';
865
+ if (!res.ok) b.disabled = false; }
866
+ catch(e){ b.textContent = 'Failed'; b.disabled = false; }
867
+ });
868
+ });
869
+ })();
870
+ </script>`;
871
+
872
+ // src/dashboard/fleet-render.ts
703
873
  var STYLES2 = `
704
874
  :root { color-scheme: light dark; }
705
875
  body { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
@@ -720,12 +890,8 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
720
890
  .cluster.lighthouse .score { display: inline-block; min-width: 2.25rem; text-align: right; }
721
891
  .metric-label { color: #999; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
722
892
  .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; }
726
893
  .spam-rollup { font-size:0.9rem; margin-bottom:1rem; }
727
894
  .muted { color:#999; }
728
- .subm-viewall { font-size:0.8rem; font-weight:normal; margin-left:0.4rem; white-space:nowrap; }
729
895
  .filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }
730
896
  .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
897
  .filters button[aria-pressed="true"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
@@ -737,13 +903,11 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
737
903
  .rf-status { margin-top:0.6rem; font-size:0.85rem; }
738
904
  .rf-row { padding:0.1rem 0; }
739
905
  .rf-row a { margin-left:0.3rem; }
906
+ .rf-sub { color:#999; font-size:0.8rem; margin-left:1.1rem; }
740
907
  .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
908
  @keyframes rf-spin { to { transform:rotate(360deg); } }
742
- details.tier { margin:0.75rem 0; }
743
- details.tier > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }
744
- .approve-strip { border:1px solid #ffe08a; background:#fff8e1; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }
745
- @media (prefers-color-scheme: dark) { .approve-strip { background:#241f00; border-color:#5a4d00; } }
746
- .approve-strip h2 { font-size:1rem; margin:0 0 0.5rem; }
909
+ details.fleet-browse, details.inbox { margin:0.75rem 0; }
910
+ details.fleet-browse > summary, details.inbox > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }
747
911
  .approve-row { display:flex; flex-wrap:wrap; gap:0.5rem 1rem; align-items:center; padding:0.25rem 0; }
748
912
  .pill { font-size:0.75rem; padding:0.1rem 0.5rem; border-radius:999px; font-weight:700; }
749
913
  .pill.attention { background:#fdecea; color:#b00; }
@@ -755,85 +919,80 @@ details.tier > summary { cursor:pointer; font-weight:700; font-size:1.05rem; pad
755
919
  .chip.critical { background:#fdecea; color:#b00; }
756
920
  .chip.stuck { border:1px solid #b00; font-weight:600; }
757
921
  .badge { font-weight:700; color:#C00; font-size:0.72rem; margin-right:0.25rem; }
758
- .all-clear { background:#e8f5e9; color:#1b7a2f; padding:0.6rem 1rem; border-radius:8px; margin-bottom:1.25rem; font-weight:600; }
759
- @media (prefers-color-scheme: dark) { .all-clear { background:#10240f; color:#7fce85; } }
922
+ .verdict { border-radius:8px; padding:0.9rem 1.1rem; margin-bottom:1.25rem; }
923
+ .verdict .verdict-line { font-weight:800; font-size:1.4rem; }
924
+ .verdict .verdict-meta { color:#666; font-size:0.9rem; margin-top:0.2rem; }
925
+ .verdict.ok { background:#e8f5e9; color:#1b7a2f; }
926
+ .verdict.ok .verdict-meta { color:#2e7d32; }
927
+ .verdict.warn { background:#fdecea; color:#b00; }
928
+ .verdict.warn .verdict-meta { color:#b00; opacity:0.85; }
929
+ @media (prefers-color-scheme: dark) { .verdict.ok { background:#10240f; color:#7fce85; } .verdict.warn { background:#2a0f0d; color:#ff8a80; } }
930
+ .verdict .fleet-actions { margin:0.6rem 0 0; }
931
+ .needs-you { border:1px solid #e5e5e5; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }
932
+ @media (prefers-color-scheme: dark) { .needs-you { border-color:#2a2a2a; background:#181818; } }
933
+ .needs-you h2 { font-size:1.05rem; margin:0 0 0.5rem; }
934
+ .feed-group-label { text-transform:uppercase; letter-spacing:0.04em; font-size:0.72rem; color:#999; margin:0.6rem 0 0.2rem; }
935
+ .feed-row { display:flex; gap:0.5rem; align-items:center; padding:0.3rem 0; border-bottom:1px dashed #eee; }
936
+ .feed-row:last-child { border-bottom:0; }
937
+ @media (prefers-color-scheme: dark) { .feed-row { border-bottom-color:#262626; } }
938
+ .feed-what { flex:1; }
939
+ .feed-open { white-space:nowrap; }
940
+ .dot { width:0.55rem; height:0.55rem; border-radius:50%; display:inline-block; flex:0 0 auto; }
941
+ .dot.broken { background:#dc2626; }
942
+ .dot.approval { background:#2563eb; }
943
+ .dot.slipping { background:#f59e0b; }
760
944
  `;
761
- var TIER_META = {
762
- attention: { emoji: "\u{1F534}", label: "Needs attention", open: true },
763
- watch: { emoji: "\u{1F7E1}", label: "Watch", open: false },
764
- healthy: { emoji: "\u{1F7E2}", label: "Healthy", open: false }
765
- };
766
- var FILTERS = [
767
- "all",
768
- "vulns",
769
- "lighthouse",
770
- "delivery",
771
- "prs",
772
- "ci",
773
- "auto-fix-failed",
774
- "stale",
775
- "no-domain",
776
- "pending",
777
- "submissions"
778
- ];
779
- function summaryBar(model) {
780
- const s = model.summary;
781
- const heads = [
782
- `${s.criticalHighVulns} critical/high vuln${s.criticalHighVulns === 1 ? "" : "s"}`,
783
- `${s.lighthouseBelowFloor} Lighthouse<75`,
784
- `${s.deliveryFailures} delivery`,
785
- `${s.renovateFailing} PRs failing`,
786
- `${s.ciRed} CI red`,
787
- `${s.autoFixStuck} auto-fix stuck`,
788
- `${s.pending} pending`,
789
- `${s.newSubmissions ?? 0} new`
790
- ].join(" \xB7 ");
791
- const chips2 = FILTERS.map(
792
- (f) => `<button type="button" data-filter="${f}" aria-pressed="${f === "all" ? "true" : "false"}">${f}</button>`
793
- ).join("");
794
- return `<div class="summary">
795
- <span class="tier">\u{1F534} ${s.attention} needs attention</span>
796
- <span class="tier">\u{1F7E1} ${s.watch} watch</span>
797
- <span class="tier">\u{1F7E2} ${s.healthy} healthy</span>
798
- </div>
799
- <div class="summary heads">${escapeHtml(heads)}</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>
945
+ function verdictBar(model, feedCount) {
946
+ const auditedIso = fleetLastAuditedAt(model.cards);
947
+ const audited = auditedIso ? ` \xB7 fleet last audited ${escapeHtml(relativeTimeFromNow(auditedIso))}` : "";
948
+ const sites = model.cards.length;
949
+ const sitesWord = `${sites} site${sites === 1 ? "" : "s"}`;
950
+ const actions = `<div class="fleet-actions">
951
+ <button type="button" class="refresh-fleet" data-refresh-url="/api/fleet/refresh">\u21BB Audit fleet</button>
803
952
  <div id="rf-status" class="rf-status" aria-live="polite"></div>
804
953
  </div>`;
954
+ if (feedCount === 0) {
955
+ return `<div class="verdict ok">
956
+ <div class="verdict-line">\u2713 All clear</div>
957
+ <div class="verdict-meta">${sitesWord} healthy${audited}</div>
958
+ ${actions}
959
+ </div>`;
960
+ }
961
+ const noun = feedCount === 1 ? "site needs" : "sites need";
962
+ return `<div class="verdict warn">
963
+ <div class="verdict-line">\u26A0 ${feedCount} ${noun} you</div>
964
+ <div class="verdict-meta">${sitesWord}${audited}</div>
965
+ ${actions}
966
+ </div>`;
805
967
  }
806
- function spamRollup(model) {
807
- const s = model.spam;
808
- if (!s || s.caught === 0 && s.through === 0) return "";
809
- return `<div class="spam-rollup muted">\u{1F6E1} Spam (30d) \u2014 caught ${s.caught} \xB7 through ${s.through}</div>`;
810
- }
811
- function allClearBanner(model) {
812
- if (model.summary.attention > 0) return "";
813
- const msg = model.cards.length === 0 ? "No sites on the fleet view yet." : "All clear \u2014 nothing needs your attention.";
814
- return `<div class="all-clear">\u2713 ${escapeHtml(msg)}</div>`;
815
- }
816
- function approveStrip(model) {
817
- if (model.pending.length === 0) return "";
818
- const rows = model.pending.map((p) => {
819
- const href = `/s/${escapeHtml(p.slug)}`;
820
- const url = `/api/reports/${encodeURIComponent(p.reportId)}/approve`;
821
- return `<div class="approve-row" data-signal="pending">
822
- <strong>${escapeHtml(p.siteName)}</strong>
823
- <span class="muted">${escapeHtml(p.reportType)} ${escapeHtml(p.period)}</span>
824
- <button class="approve" data-report-id="${escapeHtml(p.reportId)}" data-approve-url="${escapeHtml(url)}">Approve</button>
825
- <a href="${href}">open \u25B8</a>
826
- </div>`;
968
+ var NEEDS_YOU_GROUP_LABEL = {
969
+ broken: "Broken",
970
+ approval: "Waiting on your yes",
971
+ slipping: "Slipping"
972
+ };
973
+ function renderNeedsYouFeed(feed) {
974
+ if (feed.length === 0) return "";
975
+ const groups = ["broken", "approval", "slipping"];
976
+ const blocks = groups.map((g) => {
977
+ const rows = feed.filter((i) => i.group === g);
978
+ if (rows.length === 0) return "";
979
+ const lis = rows.map(
980
+ (i) => `<div class="feed-row" data-group="${g}">
981
+ <span class="dot ${g}"></span>
982
+ <span class="feed-what"><strong>${escapeHtml(i.siteName)}</strong> \u2014 ${escapeHtml(i.reasons.join(" \xB7 "))}</span>
983
+ <a class="feed-open" href="${escapeHtml(i.url)}">Open \u25B8</a>
984
+ </div>`
985
+ ).join("");
986
+ return `<div class="feed-group"><div class="feed-group-label">${NEEDS_YOU_GROUP_LABEL[g]}</div>${lis}</div>`;
827
987
  }).join("");
828
- return `<section class="approve-strip" data-tier="pending">
829
- <h2>Approve (${model.pending.length}) \u2014 your daily yes</h2>
830
- ${rows}
831
- </section>`;
988
+ return `<section class="needs-you"><h2>Needs you (${feed.length})</h2>${blocks}</section>`;
832
989
  }
833
990
  var SUBMISSIONS_STRIP_CAP = 10;
834
- function submissionsStrip(model) {
991
+ function renderInboxLane(model) {
835
992
  const subs = model.submissions ?? [];
836
- if (subs.length === 0) return "";
993
+ const spam = model.spam;
994
+ const hasSpam = !!spam && (spam.caught > 0 || spam.through > 0);
995
+ if (subs.length === 0 && !hasSpam) return "";
837
996
  const shown = [...subs].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_STRIP_CAP);
838
997
  const rows = shown.map((sub) => {
839
998
  const href = `/s/${escapeHtml(sub.slug)}`;
@@ -847,91 +1006,17 @@ function submissionsStrip(model) {
847
1006
  </div>`;
848
1007
  }).join("");
849
1008
  const overflow = subs.length - shown.length;
850
- const more = overflow > 0 ? `<div class="approve-row subm-more muted"><a href="/submissions">+${overflow} more \u2014 view all submissions</a></div>` : "";
851
- return `<section class="approve-strip subm-strip" data-tier="submissions">
852
- <h2>\u{1F4E5} New submissions (${subs.length}) <a class="subm-viewall" href="/submissions">View all \u2192</a></h2>
853
- ${rows}${more}
854
- </section>`;
855
- }
856
- function submBadge(c) {
857
- const n = c.newSubmissions ?? 0;
858
- return n > 0 ? `<span class="chip">\u{1F4E5} ${n} new</span>` : "";
859
- }
860
- var PILL_LABEL = { attention: "failing", watch: "watch", healthy: "ok" };
861
- function attentionBadge(status) {
862
- if (status === "new") return `<span class="badge">NEW</span>`;
863
- if (status === "worse") return `<span class="badge">WORSE</span>`;
864
- return "";
1009
+ 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>`;
1010
+ const spamLine = hasSpam ? `<div class="spam-rollup muted">\u{1F6E1} Spam (30d) \u2014 caught ${spam.caught} \xB7 through ${spam.through}</div>` : "";
1011
+ const spamInSummary = hasSpam ? " \xB7 \u{1F6E1} Spam (30d)" : "";
1012
+ return `<details class="inbox">
1013
+ <summary>\u{1F4E5} Submissions (${subs.length} new)${spamInSummary}</summary>
1014
+ ${rows}${subs.length > 0 ? more : ""}
1015
+ ${spamLine}
1016
+ </details>`;
865
1017
  }
866
- function chips(c) {
867
- const items = c.items.map((it) => {
868
- const cls = it.autoFixExhausted ? "chip critical stuck" : it.severity === "critical" ? "chip critical" : "chip";
869
- return `<span class="${cls}">${attentionBadge(it.status)}${escapeHtml(it.title)}</span>`;
870
- });
871
- for (const reason of c.watchReasons)
872
- items.push(`<span class="chip">${escapeHtml(reason)}</span>`);
873
- return items.length ? `<div class="chips">${items.join("")}</div>` : "";
874
- }
875
- function signalsAttr(c) {
876
- const kinds = /* @__PURE__ */ new Set();
877
- for (const it of c.items) {
878
- kinds.add(it.kind === "vuln" ? "vulns" : it.kind === "renovate" ? "prs" : it.kind);
879
- }
880
- if (c.items.some((it) => it.autoFixExhausted)) kinds.add("auto-fix-failed");
881
- for (const sig of c.watchSignals) kinds.add(sig);
882
- return [...kinds].join(" ");
883
- }
884
- function triggerRenovateBtn(c) {
885
- if (!c.site.gitRepo?.trim()) return "";
886
- const url = `/api/sites/${escapeHtml(siteSlug(c.site.name))}/trigger-renovate`;
887
- return `<button class="trigger-renovate" data-trigger-url="${url}">Trigger Renovate</button>`;
888
- }
889
- function cockpitCard(c) {
890
- const base = card(c.site);
891
- const pill = `<span class="pill ${c.tier}">${PILL_LABEL[c.tier]}</span>`;
892
- const extra = `${pill}${chips(c)}${submBadge(c)}${triggerRenovateBtn(c)}`;
893
- const opening = `<article class="card" data-signals="${signalsAttr(c)}">`;
894
- return base.replace('<article class="card">', () => opening).replace("</article>", () => `${extra}</article>`);
895
- }
896
- var FILTER_SCRIPT = `<script>
1018
+ var AUDIT_SCRIPT = `<script>
897
1019
  (function(){
898
- var btns = document.querySelectorAll('.filters button');
899
- var cards = document.querySelectorAll('.cards .card');
900
- var details = document.querySelectorAll('details.tier');
901
- btns.forEach(function(b){
902
- b.addEventListener('click', function(){
903
- var f = b.getAttribute('data-filter');
904
- btns.forEach(function(x){ x.setAttribute('aria-pressed', x===b ? 'true':'false'); });
905
- // "pending" lives on the approve strip, not on tier cards \u2014 just jump to it,
906
- // never hide the triage cards (else the whole board blanks).
907
- if (f === 'pending') { var s = document.querySelector('.approve-strip'); if (s) s.scrollIntoView({behavior:'smooth'}); return; }
908
- if (f === 'submissions') { var ss = document.querySelector('[data-tier="submissions"]'); if (ss) ss.scrollIntoView({behavior:'smooth'}); return; }
909
- if (f !== 'all') details.forEach(function(d){ d.open = true; });
910
- cards.forEach(function(c){
911
- var sig = (c.getAttribute('data-signals')||'').split(' ');
912
- c.style.display = (f==='all' || sig.indexOf(f)!==-1) ? '' : 'none';
913
- });
914
- });
915
- });
916
- // approve buttons: mirror the per-site dashboard's inline POST.
917
- document.querySelectorAll('button.approve').forEach(function(b){
918
- b.addEventListener('click', async function(){
919
- b.disabled = true; b.textContent = 'Approving\u2026';
920
- try { var res = await fetch(b.dataset.approveUrl, { method: 'POST' });
921
- b.textContent = res.ok ? 'Approved \u2713' : 'Failed'; }
922
- catch(e){ b.textContent = 'Failed'; b.disabled = false; }
923
- });
924
- });
925
- // trigger-renovate buttons: fire the on-demand dispatch (async, fire-and-forget).
926
- document.querySelectorAll('button.trigger-renovate').forEach(function(b){
927
- b.addEventListener('click', async function(){
928
- b.disabled = true; b.textContent = 'Dispatching\u2026';
929
- try { var res = await fetch(b.dataset.triggerUrl, { method: 'POST' });
930
- b.textContent = res.ok ? 'Dispatched \u2713' : 'Failed';
931
- if (!res.ok) b.disabled = false; }
932
- catch(e){ b.textContent = 'Failed'; b.disabled = false; }
933
- });
934
- });
935
1020
  // fleet-refresh live status: dispatch, then poll the actual runs and follow them.
936
1021
  // Vanilla JS, string-concat only (no template literals) \u2014 this lives inside a TS
937
1022
  // template string, so backticks or interpolation syntax would break the server render.
@@ -940,16 +1025,45 @@ var FILTER_SCRIPT = `<script>
940
1025
  var RF_MAX_MS = 90 * 60 * 1000; // safety ceiling; a full fleet Lighthouse run was ~48 min (2026-06-24)
941
1026
  function rfPanel(){ return document.getElementById('rf-status'); }
942
1027
  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){
1028
+ // Per-workflow ETA hint (the lighthouse fleet sweep runs ~48 min; security ~1-2 min).
1029
+ function rfEta(label){ return label === 'lighthouse' ? '~48m' : label === 'security' ? '~2m' : ''; }
1030
+ // Map a raw GitHub step name to a coarse human phase. Order matters (the audit step
1031
+ // name also contains 'browser'; 'playwright install' also contains 'install').
1032
+ function rfPhase(step){
1033
+ if (!step) return '';
1034
+ var s = step.toLowerCase();
1035
+ if (s.indexOf('audit') !== -1 || s.indexOf('lighthouse') !== -1) return 'auditing the fleet\u2026';
1036
+ if (s.indexOf('build') !== -1) return 'building\u2026';
1037
+ if (s.indexOf('playwright') !== -1 || s.indexOf('browser') !== -1) return 'installing browsers\u2026';
1038
+ if (s.indexOf('install') !== -1 || s.indexOf('depend') !== -1) return 'installing dependencies\u2026';
1039
+ if (s.indexOf('set up') !== -1 || s.indexOf('checkout') !== -1) return 'setting up\u2026';
1040
+ return step;
1041
+ }
1042
+ // Safe to build raw HTML: workflow/state/step are server-fixed (enums + GitHub step
1043
+ // names) and url is GitHub's own html_url for our central repo \u2014 none are
1044
+ // user-supplied. Don't interpolate untrusted fields here without escaping.
1045
+ function rfRender(status, startedAt){
947
1046
  var failed = function(s){ return s === 'failure' || s === 'cancelled' || s === 'timed_out'; };
1047
+ var mins = Math.floor((Date.now() - startedAt) / 60000);
1048
+ var elapsed = mins < 1 ? '<1m' : mins + 'm';
948
1049
  return status.perWorkflow.map(function(w){
949
1050
  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>';
1051
+ var done = w.state === 'success';
1052
+ var isFailed = failed(w.state);
1053
+ var icon = done ? '\u2713' : isFailed ? '\u2717' : '<span class="rf-spin"></span>';
1054
+ var line = icon + ' ' + label + ' \u2014 ' + w.state.replace('_',' ');
1055
+ if (!done && !isFailed){
1056
+ var phase = rfPhase(w.step);
1057
+ var eta = rfEta(label);
1058
+ var detail = [];
1059
+ if (phase) detail.push(phase);
1060
+ detail.push(elapsed + (eta ? ' / ' + eta : ''));
1061
+ var link = w.url ? ' \xB7 <a href="'+w.url+'" target="_blank" rel="noopener">view run \u2197</a>' : '';
1062
+ line += '<div class="rf-sub">' + detail.join(' \xB7 ') + link + '</div>';
1063
+ } else if (isFailed && w.url){
1064
+ line += ' <a href="'+w.url+'" target="_blank" rel="noopener">run</a>';
1065
+ }
1066
+ return '<div class="rf-row">' + line + '</div>';
953
1067
  }).join('');
954
1068
  }
955
1069
  function rfPoll(since, startedAt){
@@ -960,24 +1074,24 @@ var FILTER_SCRIPT = `<script>
960
1074
  var p = rfPanel();
961
1075
  if (data && data.authFail){
962
1076
  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'; }
1077
+ if (rf){ rf.disabled = false; rf.textContent = '\u21BB Audit fleet'; }
964
1078
  rfStop(); return;
965
1079
  }
966
1080
  if (data && data.status){
967
- if (p) p.innerHTML = rfRender(data.status);
1081
+ if (p) p.innerHTML = rfRender(data.status, startedAt);
968
1082
  if (data.status.allDone){
969
1083
  if (!data.status.anyFailure){
970
1084
  if (p) p.innerHTML += '<div class="rf-row">\u2713 Done \u2014 reloading\u2026</div>';
971
1085
  rfStop(); setTimeout(function(){ location.reload(); }, 2000); return;
972
1086
  }
973
1087
  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'; }
1088
+ if (rf){ rf.disabled = false; rf.textContent = '\u21BB Audit fleet'; }
975
1089
  rfStop(); return;
976
1090
  }
977
1091
  }
978
1092
  if (Date.now() - startedAt > RF_MAX_MS){
979
1093
  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'; }
1094
+ if (rf){ rf.disabled = false; rf.textContent = '\u21BB Audit fleet'; }
981
1095
  rfStop(); return;
982
1096
  }
983
1097
  setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);
@@ -985,7 +1099,7 @@ var FILTER_SCRIPT = `<script>
985
1099
  var p = rfPanel();
986
1100
  if (Date.now() - startedAt > RF_MAX_MS){
987
1101
  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'; }
1102
+ if (rf){ rf.disabled = false; rf.textContent = '\u21BB Audit fleet'; }
989
1103
  rfStop(); return;
990
1104
  }
991
1105
  setTimeout(function(){ rfPoll(since, startedAt); }, RF_POLL_MS);
@@ -999,12 +1113,12 @@ var FILTER_SCRIPT = `<script>
999
1113
  var rf = document.querySelector('button.refresh-fleet');
1000
1114
  if (rf) rf.addEventListener('click', async function(){
1001
1115
  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';
1116
+ rf.disabled = true; rf.textContent = 'Auditing\u2026';
1003
1117
  try {
1004
1118
  var res = await fetch(rf.dataset.refreshUrl, { method: 'POST' });
1005
1119
  if (res.ok){
1006
1120
  var data = await res.json();
1007
- rf.textContent = '\u21BB Refresh running\u2026';
1121
+ rf.textContent = '\u21BB Audit running\u2026';
1008
1122
  if (data && data.since) rfBegin(data.since, Date.now());
1009
1123
  } else { rf.textContent = 'Failed to start'; rf.disabled = false; }
1010
1124
  } catch(e){ rf.textContent = 'Failed to start'; rf.disabled = false; }
@@ -1013,7 +1127,7 @@ var FILTER_SCRIPT = `<script>
1013
1127
  try {
1014
1128
  var rfSaved = JSON.parse(localStorage.getItem(RF_KEY) || 'null');
1015
1129
  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'; }
1130
+ if (rf){ rf.disabled = true; rf.textContent = '\u21BB Audit running\u2026'; }
1017
1131
  rfBegin(rfSaved.since, rfSaved.startedAt);
1018
1132
  } else if (rfSaved) { rfStop(); }
1019
1133
  } catch(e){}
@@ -1021,16 +1135,7 @@ var FILTER_SCRIPT = `<script>
1021
1135
  </script>`;
1022
1136
  function renderCockpitHtml(model) {
1023
1137
  const total = model.cards.length;
1024
- const tiers = ["attention", "watch", "healthy"];
1025
- const sections = tiers.map((tier) => {
1026
- const cards = model.cards.filter((c) => c.tier === tier);
1027
- const meta = TIER_META[tier];
1028
- const body = cards.length === 0 ? `<div class="empty">None.</div>` : `<div class="cards">${cards.map(cockpitCard).join("")}</div>`;
1029
- return `<details class="tier" data-tier="${tier}"${meta.open ? " open" : ""}>
1030
- <summary>${meta.emoji} ${meta.label} (${cards.length})</summary>
1031
- ${body}
1032
- </details>`;
1033
- }).join("");
1138
+ const feed = buildNeedsYouFeed(model);
1034
1139
  return `<!doctype html>
1035
1140
  <html lang="en">
1036
1141
  <head>
@@ -1043,13 +1148,12 @@ function renderCockpitHtml(model) {
1043
1148
  <body>
1044
1149
  <h1>Reddoor fleet cockpit</h1>
1045
1150
  <div class="meta">${total} site${total === 1 ? "" : "s"} on the Reddoor stack.</div>
1046
- ${summaryBar(model)}
1047
- ${allClearBanner(model)}
1048
- ${approveStrip(model)}
1049
- ${sections}
1050
- ${spamRollup(model)}
1051
- ${submissionsStrip(model)}
1052
- ${FILTER_SCRIPT}
1151
+ ${verdictBar(model, feed.length)}
1152
+ ${renderNeedsYouFeed(feed)}
1153
+ ${renderFleetBrowsePanel(model)}
1154
+ ${renderInboxLane(model)}
1155
+ ${AUDIT_SCRIPT}
1156
+ ${FLEET_BROWSE_SCRIPT}
1053
1157
  </body>
1054
1158
  </html>`;
1055
1159
  }