@reddoorla/maintenance 0.58.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.
Files changed (72) hide show
  1. package/dist/airtable-YZ6VMSQZ.js +9 -0
  2. package/dist/{announce-JO67XZLZ.js → announce-Q2DXP5OO.js} +3 -3
  3. package/dist/{bump-deps-6D4VCAFQ.js → bump-deps-RYHQIKW4.js} +2 -2
  4. package/dist/{chunk-4PY4I5OR.js → chunk-5PHCZ6L5.js} +11 -2
  5. package/dist/chunk-5PHCZ6L5.js.map +1 -0
  6. package/dist/{chunk-IDRBAF2H.js → chunk-6CSCLBAA.js} +2 -2
  7. package/dist/{chunk-MKSZBODM.js → chunk-D3LFQNJK.js} +2 -2
  8. package/dist/{chunk-XO2RUYVM.js → chunk-HB7QPECR.js} +2 -2
  9. package/dist/{chunk-RYECSIEA.js → chunk-HPSGCDWY.js} +3 -3
  10. package/dist/chunk-HPSGCDWY.js.map +1 -0
  11. package/dist/{chunk-HPNGCRTK.js → chunk-KC4HU7BC.js} +2 -2
  12. package/dist/{chunk-PKIYAWA4.js → chunk-L4ULXHIN.js} +7 -2
  13. package/dist/chunk-L4ULXHIN.js.map +1 -0
  14. package/dist/{chunk-IJW4DS2S.js → chunk-LMXIZITU.js} +3 -3
  15. package/dist/chunk-LMXIZITU.js.map +1 -0
  16. package/dist/{chunk-5E3OW6YN.js → chunk-TLZ4V2BE.js} +2 -2
  17. package/dist/{chunk-S7WKPRID.js → chunk-TZN2G6FD.js} +2 -2
  18. package/dist/{chunk-J4ECCFRT.js → chunk-XB6T4ETK.js} +1 -1
  19. package/dist/chunk-XB6T4ETK.js.map +1 -0
  20. package/dist/cli/bin.js +13 -13
  21. package/dist/cli/commands/audit.js +6 -6
  22. package/dist/{convert-to-pnpm-JYUUUDDP.js → convert-to-pnpm-KNCXU6FM.js} +2 -2
  23. package/dist/{digest-RUQ42QXL.js → digest-RVIOHUXR.js} +2 -2
  24. package/dist/{github-signals-JXHRHDAG.js → github-signals-UL2BBS3Q.js} +5 -5
  25. package/dist/index.js +208 -17
  26. package/dist/index.js.map +1 -1
  27. package/dist/{init-6LJU72F2.js → init-KD56UENX.js} +4 -4
  28. package/dist/{launch-6ZB3QA2I.js → launch-UWCI3JRM.js} +15 -9
  29. package/dist/launch-UWCI3JRM.js.map +1 -0
  30. package/dist/{lighthouse-airtable-FSGOUVMQ.js → lighthouse-airtable-N3KCBPVA.js} +3 -3
  31. package/dist/{onboard-UM6UKX2V.js → onboard-SCZ3UKZ2.js} +2 -2
  32. package/dist/{orchestrate-GEHLKE64.js → orchestrate-D72PB6NG.js} +3 -3
  33. package/dist/{renovate-dispatch-L5HWCNRY.js → renovate-dispatch-NG5KUQKY.js} +3 -3
  34. package/dist/{report-3YVSMRFE.js → report-CN56TGMK.js} +5 -5
  35. package/dist/{self-updating-J7U7WIP3.js → self-updating-CFHXJSAX.js} +4 -4
  36. package/dist/{svelte-codemods-BW4M4CDF.js → svelte-codemods-HNITLWQM.js} +2 -2
  37. package/dist/{sync-configs-ZYKZCP4C.js → sync-configs-BZXBYKTI.js} +2 -2
  38. package/dist/{upgrade-UAXREMED.js → upgrade-6YDUELFA.js} +2 -2
  39. package/dist/{websites-EEZVZXQT.js → websites-IYP7IGLU.js} +6 -4
  40. package/dist/{write-audits-to-airtable-LUFY424L.js → write-audits-to-airtable-VDGHCZGQ.js} +4 -4
  41. package/package.json +1 -1
  42. package/dist/airtable-E5F5FLGD.js +0 -9
  43. package/dist/chunk-4PY4I5OR.js.map +0 -1
  44. package/dist/chunk-IJW4DS2S.js.map +0 -1
  45. package/dist/chunk-J4ECCFRT.js.map +0 -1
  46. package/dist/chunk-PKIYAWA4.js.map +0 -1
  47. package/dist/chunk-RYECSIEA.js.map +0 -1
  48. package/dist/launch-6ZB3QA2I.js.map +0 -1
  49. /package/dist/{airtable-E5F5FLGD.js.map → airtable-YZ6VMSQZ.js.map} +0 -0
  50. /package/dist/{announce-JO67XZLZ.js.map → announce-Q2DXP5OO.js.map} +0 -0
  51. /package/dist/{bump-deps-6D4VCAFQ.js.map → bump-deps-RYHQIKW4.js.map} +0 -0
  52. /package/dist/{chunk-IDRBAF2H.js.map → chunk-6CSCLBAA.js.map} +0 -0
  53. /package/dist/{chunk-MKSZBODM.js.map → chunk-D3LFQNJK.js.map} +0 -0
  54. /package/dist/{chunk-XO2RUYVM.js.map → chunk-HB7QPECR.js.map} +0 -0
  55. /package/dist/{chunk-HPNGCRTK.js.map → chunk-KC4HU7BC.js.map} +0 -0
  56. /package/dist/{chunk-5E3OW6YN.js.map → chunk-TLZ4V2BE.js.map} +0 -0
  57. /package/dist/{chunk-S7WKPRID.js.map → chunk-TZN2G6FD.js.map} +0 -0
  58. /package/dist/{convert-to-pnpm-JYUUUDDP.js.map → convert-to-pnpm-KNCXU6FM.js.map} +0 -0
  59. /package/dist/{digest-RUQ42QXL.js.map → digest-RVIOHUXR.js.map} +0 -0
  60. /package/dist/{github-signals-JXHRHDAG.js.map → github-signals-UL2BBS3Q.js.map} +0 -0
  61. /package/dist/{init-6LJU72F2.js.map → init-KD56UENX.js.map} +0 -0
  62. /package/dist/{lighthouse-airtable-FSGOUVMQ.js.map → lighthouse-airtable-N3KCBPVA.js.map} +0 -0
  63. /package/dist/{onboard-UM6UKX2V.js.map → onboard-SCZ3UKZ2.js.map} +0 -0
  64. /package/dist/{orchestrate-GEHLKE64.js.map → orchestrate-D72PB6NG.js.map} +0 -0
  65. /package/dist/{renovate-dispatch-L5HWCNRY.js.map → renovate-dispatch-NG5KUQKY.js.map} +0 -0
  66. /package/dist/{report-3YVSMRFE.js.map → report-CN56TGMK.js.map} +0 -0
  67. /package/dist/{self-updating-J7U7WIP3.js.map → self-updating-CFHXJSAX.js.map} +0 -0
  68. /package/dist/{svelte-codemods-BW4M4CDF.js.map → svelte-codemods-HNITLWQM.js.map} +0 -0
  69. /package/dist/{sync-configs-ZYKZCP4C.js.map → sync-configs-BZXBYKTI.js.map} +0 -0
  70. /package/dist/{upgrade-UAXREMED.js.map → upgrade-6YDUELFA.js.map} +0 -0
  71. /package/dist/{websites-EEZVZXQT.js.map → websites-IYP7IGLU.js.map} +0 -0
  72. /package/dist/{write-audits-to-airtable-LUFY424L.js.map → write-audits-to-airtable-VDGHCZGQ.js.map} +0 -0
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  DEFAULT_INIT_STEPS,
3
3
  a11yFixturesPage,
4
4
  init
5
- } from "./chunk-XO2RUYVM.js";
5
+ } from "./chunk-HB7QPECR.js";
6
6
  import {
7
7
  svelteCodemods
8
8
  } from "./chunk-FUD4I2ZW.js";
@@ -16,11 +16,11 @@ import {
16
16
  } from "./chunk-WVRPOXKH.js";
17
17
  import {
18
18
  draftReportForSite
19
- } from "./chunk-S7WKPRID.js";
19
+ } from "./chunk-TZN2G6FD.js";
20
20
  import "./chunk-6LIWDIXU.js";
21
21
  import {
22
22
  sendApprovedReports
23
- } from "./chunk-HPNGCRTK.js";
23
+ } from "./chunk-KC4HU7BC.js";
24
24
  import "./chunk-OGGRFBL4.js";
25
25
  import {
26
26
  BLURRED_CID,
@@ -60,7 +60,7 @@ import {
60
60
  runAudits,
61
61
  runAuditsAcross,
62
62
  securityAudit
63
- } from "./chunk-4PY4I5OR.js";
63
+ } from "./chunk-5PHCZ6L5.js";
64
64
  import "./chunk-LKGVSM2O.js";
65
65
  import {
66
66
  fromJsonFile,
@@ -71,12 +71,12 @@ import "./chunk-XXTZBPUY.js";
71
71
  import "./chunk-XTK5VIQB.js";
72
72
  import {
73
73
  fromAirtableBase
74
- } from "./chunk-5E3OW6YN.js";
74
+ } from "./chunk-TLZ4V2BE.js";
75
75
  import "./chunk-U5SZ4FOP.js";
76
76
  import {
77
77
  SEVERITY_RANK,
78
78
  siteSlug
79
- } from "./chunk-PKIYAWA4.js";
79
+ } from "./chunk-L4ULXHIN.js";
80
80
  import "./chunk-HN6I2XWI.js";
81
81
  import "./chunk-WEKL2HYJ.js";
82
82
  import "./chunk-6VAL7XJT.js";
@@ -232,6 +232,17 @@ var SUBMISSION_STATUS_SCRIPT = `document.querySelectorAll("button.subm-status").
232
232
  });
233
233
  });`;
234
234
 
235
+ // src/dashboard/site-details.ts
236
+ var SITE_STATUS_OPTIONS = [
237
+ "in development",
238
+ "launch period",
239
+ "maintenance",
240
+ "hosting",
241
+ "probably not our problem",
242
+ "deprecated"
243
+ ];
244
+ var FREQ_OPTIONS = ["None", "Monthly", "Quarterly", "Yearly"];
245
+
235
246
  // src/dashboard/render.ts
236
247
  var DASH = "\u2014";
237
248
  function scoreTile(label, value) {
@@ -380,21 +391,44 @@ function detailRow(label, value) {
380
391
  const display = typeof value === "string" && value.trim().length > 0 ? escapeHtml(value.trim()) : DASH;
381
392
  return `<div class="detail"><dt>${escapeHtml(label)}</dt><dd>${display}</dd></div>`;
382
393
  }
394
+ function savedSpan(field) {
395
+ return `<span class="detail-saved" data-for="${field}"></span>`;
396
+ }
397
+ function selectRow(label, field, options, current, url) {
398
+ const inList = current !== null && options.includes(current);
399
+ const opts = options.map(
400
+ (o) => `<option value="${escapeHtml(o)}"${o === current ? " selected" : ""}>${escapeHtml(o)}</option>`
401
+ ).join("");
402
+ const placeholder = inList ? "" : `<option value="" disabled selected hidden>\u2014 select \u2014</option>`;
403
+ 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>`;
404
+ }
405
+ function inputRow(label, field, value, url) {
406
+ 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>`;
407
+ }
408
+ function textareaRow(label, field, value, url) {
409
+ 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>`;
410
+ }
383
411
  function siteDetailsSection(site) {
412
+ const url = `/api/sites/${escapeHtml(siteSlug(site.name))}/details`;
384
413
  const lastCommit = site.lastCommitAt ? `${relativeTimeFromNow(site.lastCommitAt)}` : null;
385
414
  const rows = [
386
- detailRow("Maintenance cadence", site.maintenanceFreq),
387
- detailRow("Testing cadence", site.testingFreq),
388
- detailRow("Report recipients (To)", site.reportRecipientsTo),
389
- detailRow("Report recipients (CC)", site.reportRecipientsCc),
390
- detailRow("Point of contact", site.pointOfContact),
391
- detailRow("GA4 property", site.ga4PropertyId),
392
- detailRow("Search query", site.searchQuery),
393
- detailRow("Git repo", site.gitRepo),
415
+ selectRow("Status", "status", SITE_STATUS_OPTIONS, site.status, url),
416
+ selectRow("Maintenance cadence", "maintenanceFreq", FREQ_OPTIONS, site.maintenanceFreq, url),
417
+ selectRow("Testing cadence", "testingFreq", FREQ_OPTIONS, site.testingFreq, url),
418
+ inputRow("Report recipients (To)", "reportRecipientsTo", site.reportRecipientsTo, url),
419
+ inputRow("Report recipients (CC)", "reportRecipientsCc", site.reportRecipientsCc, url),
420
+ inputRow("Point of contact", "pointOfContact", site.pointOfContact, url),
421
+ inputRow("GA4 property", "ga4PropertyId", site.ga4PropertyId, url),
422
+ inputRow("Search query", "searchQuery", site.searchQuery, url),
423
+ inputRow("Git repo", "gitRepo", site.gitRepo, url),
424
+ textareaRow("Copy \u2014 Intro", "copyIntro", site.copyIntro, url),
425
+ textareaRow("Copy \u2014 Contact", "copyContact", site.copyContact, url),
426
+ textareaRow("Copy \u2014 Footer", "copyFooter", site.copyFooter, url),
394
427
  detailRow("Last commit", lastCommit)
395
428
  ].join("");
429
+ const triggerBtn = site.gitRepo?.trim() ? `<button class="trigger-renovate" data-trigger-url="/api/sites/${escapeHtml(siteSlug(site.name))}/trigger-renovate">Trigger Renovate</button>` : "";
396
430
  return `<div class="section site-details">
397
- <h2>Site details</h2>
431
+ <h2>Site details ${triggerBtn}</h2>
398
432
  <dl class="details">${rows}</dl>
399
433
  </div>`;
400
434
  }
@@ -445,8 +479,13 @@ button.approve:disabled { opacity: 0.6; cursor: default; }
445
479
  .setup-missing { color: #a65a00; }
446
480
  .details { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.5rem 1.5rem; margin: 0; }
447
481
  .detail { display: flex; flex-direction: column; }
482
+ .detail.wide { grid-column: 1 / -1; }
448
483
  .detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }
449
484
  .detail dd { margin: 0; }
485
+ .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; }
486
+ .detail dd textarea { min-height: 3.5rem; resize: vertical; }
487
+ .detail-saved { font-size: 0.8rem; color: #2a8; }
488
+ button.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; }
450
489
  `;
451
490
  function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = null, now = /* @__PURE__ */ new Date()) {
452
491
  const name = escapeHtml(site.name);
@@ -524,6 +563,45 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
524
563
  }
525
564
  });
526
565
  });
566
+ // Trigger-renovate button: async on-demand dispatch (mirrors the cockpit).
567
+ document.querySelectorAll("button.trigger-renovate").forEach((b) => {
568
+ b.addEventListener("click", async () => {
569
+ b.disabled = true;
570
+ b.textContent = "Dispatching\u2026";
571
+ try {
572
+ const res = await fetch(b.dataset.triggerUrl, { method: "POST" });
573
+ b.textContent = res.ok ? "Dispatched \u2713" : "Failed";
574
+ if (!res.ok) b.disabled = false;
575
+ } catch {
576
+ b.textContent = "Failed";
577
+ b.disabled = false;
578
+ }
579
+ });
580
+ });
581
+ // Site-details editor: save on change (selects) / blur (inputs+textareas, only
582
+ // when the value actually changed). The per-field span shows \u2713 / \u2717.
583
+ function saveDetail(el) {
584
+ const span = document.querySelector('.detail-saved[data-for="' + el.dataset.detailField + '"]');
585
+ fetch(el.dataset.detailsUrl, {
586
+ method: "POST",
587
+ headers: { "content-type": "application/json" },
588
+ body: JSON.stringify({ field: el.dataset.detailField, value: el.value }),
589
+ })
590
+ .then((r) => {
591
+ if (span) span.textContent = r.ok ? " \u2713" : " \u2717";
592
+ })
593
+ .catch(() => {
594
+ if (span) span.textContent = " \u2717";
595
+ });
596
+ }
597
+ document.querySelectorAll("select[data-detail-field]").forEach((s) => {
598
+ s.addEventListener("change", () => saveDetail(s));
599
+ });
600
+ document.querySelectorAll("input[data-detail-field], textarea[data-detail-field]").forEach((i) => {
601
+ i.addEventListener("blur", () => {
602
+ if (i.value !== i.defaultValue) saveDetail(i);
603
+ });
604
+ });
527
605
  ${SUBMISSION_STATUS_SCRIPT}
528
606
  // Checklist gate: ticking a box POSTs the one field; the response { complete }
529
607
  // decides whether THIS report's Approve button is enabled. Scoped per report by
@@ -652,6 +730,15 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
652
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; }
653
731
  .filters button[aria-pressed="true"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
654
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); } }
655
742
  details.tier { margin:0.75rem 0; }
656
743
  details.tier > summary { cursor:pointer; font-weight:700; font-size:1.05rem; padding:0.35rem 0; list-style:none; }
657
744
  .approve-strip { border:1px solid #ffe08a; background:#fff8e1; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.25rem; }
@@ -710,7 +797,11 @@ function summaryBar(model) {
710
797
  <span class="tier">\u{1F7E2} ${s.healthy} healthy</span>
711
798
  </div>
712
799
  <div class="summary heads">${escapeHtml(heads)}</div>
713
- <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>`;
714
805
  }
715
806
  function spamRollup(model) {
716
807
  const s = model.spam;
@@ -790,10 +881,15 @@ function signalsAttr(c) {
790
881
  for (const sig of c.watchSignals) kinds.add(sig);
791
882
  return [...kinds].join(" ");
792
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
+ }
793
889
  function cockpitCard(c) {
794
890
  const base = card(c.site);
795
891
  const pill = `<span class="pill ${c.tier}">${PILL_LABEL[c.tier]}</span>`;
796
- const extra = `${pill}${chips(c)}${submBadge(c)}`;
892
+ const extra = `${pill}${chips(c)}${submBadge(c)}${triggerRenovateBtn(c)}`;
797
893
  const opening = `<article class="card" data-signals="${signalsAttr(c)}">`;
798
894
  return base.replace('<article class="card">', () => opening).replace("</article>", () => `${extra}</article>`);
799
895
  }
@@ -826,6 +922,101 @@ var FILTER_SCRIPT = `<script>
826
922
  catch(e){ b.textContent = 'Failed'; b.disabled = false; }
827
923
  });
828
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
+ // 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){}
829
1020
  })();
830
1021
  </script>`;
831
1022
  function renderCockpitHtml(model) {