@reddoorla/maintenance 0.13.0 → 0.15.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
@@ -753,25 +753,27 @@ var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
753
753
  function timedSpawn(timeoutMs) {
754
754
  return (cmd, args, opts = {}) => defaultSpawn(cmd, args, { ...opts, timeoutMs: opts.timeoutMs ?? timeoutMs });
755
755
  }
756
+ async function runOneAudit(site, name) {
757
+ if (!(name in REGISTRY)) throw new Error(`unknown audit: ${name}`);
758
+ const spawn2 = timedSpawn(DEFAULT_AUDIT_TIMEOUT_MS);
759
+ const label = site.name ?? site.path;
760
+ try {
761
+ return await REGISTRY[name]({ site, spawn: spawn2 });
762
+ } catch (err) {
763
+ return {
764
+ audit: name,
765
+ site: label,
766
+ status: "fail",
767
+ summary: `${name}: unexpected error \u2014 ${String(err)}`
768
+ };
769
+ }
770
+ }
756
771
  async function runAudits(site, which) {
757
772
  const names = which ?? ALL_AUDIT_NAMES;
758
773
  for (const n of names) {
759
774
  if (!(n in REGISTRY)) throw new Error(`unknown audit: ${n}`);
760
775
  }
761
- const spawn2 = timedSpawn(DEFAULT_AUDIT_TIMEOUT_MS);
762
- const label = site.name ?? site.path;
763
- return Promise.all(
764
- names.map(
765
- (n) => REGISTRY[n]({ site, spawn: spawn2 }).catch(
766
- (err) => ({
767
- audit: n,
768
- site: label,
769
- status: "fail",
770
- summary: `${n}: unexpected error \u2014 ${String(err)}`
771
- })
772
- )
773
- )
774
- );
776
+ return Promise.all(names.map((n) => runOneAudit(site, n)));
775
777
  }
776
778
  async function runAuditsAcross(sites, which) {
777
779
  const all = await Promise.all(sites.map((s) => runAudits(s, which)));
@@ -2218,6 +2220,13 @@ function mapRow(rec) {
2218
2220
  bpScore: f["bpScore"] ?? null,
2219
2221
  seoScore: f["seoScore"] ?? null,
2220
2222
  lastLighthouseAuditAt: f["Last lighthouse audit at"] ?? null,
2223
+ a11yViolations: f["A11y Violations"] ?? null,
2224
+ depsDrifted: f["Deps Drifted"] ?? null,
2225
+ depsMajorBehind: f["Deps Major Behind"] ?? null,
2226
+ securityVulnsCritical: f["Security Vulns Critical"] ?? null,
2227
+ securityVulnsHigh: f["Security Vulns High"] ?? null,
2228
+ securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
2229
+ securityVulnsLow: f["Security Vulns Low"] ?? null,
2221
2230
  dashboardToken: (() => {
2222
2231
  const raw = f["Dashboard Token"];
2223
2232
  if (typeof raw !== "string") return null;
@@ -2627,11 +2636,11 @@ async function uploadAttachment(recordId, fieldName, body, filename, contentType
2627
2636
  }
2628
2637
 
2629
2638
  // src/reports/draft.ts
2630
- function scoresFromWebsite(siteRow2) {
2631
- const { pScore, rScore, bpScore, seoScore } = siteRow2;
2639
+ function scoresFromWebsite(siteRow) {
2640
+ const { pScore, rScore, bpScore, seoScore } = siteRow;
2632
2641
  if (pScore === null || rScore === null || bpScore === null || seoScore === null) {
2633
2642
  throw new Error(
2634
- `Site '${siteRow2.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`
2643
+ `Site '${siteRow.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`
2635
2644
  );
2636
2645
  }
2637
2646
  return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };
@@ -2641,18 +2650,18 @@ function daysAgo(today, n) {
2641
2650
  out.setDate(out.getDate() - n);
2642
2651
  return out;
2643
2652
  }
2644
- async function draftReportForSite(base, siteRow2, reportType, options = {}) {
2645
- const scores = scoresFromWebsite(siteRow2);
2653
+ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2654
+ const scores = scoresFromWebsite(siteRow);
2646
2655
  const today = /* @__PURE__ */ new Date();
2647
- const slug = siteSlug(siteRow2.name);
2648
- const periodStart = base !== null ? await derivePeriodStart(base, siteRow2, reportType, today) : daysAgo(today, 30);
2656
+ const slug = siteSlug(siteRow.name);
2657
+ const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
2649
2658
  const periodEnd = today;
2650
2659
  const completedOn = today;
2651
- const lastTestedDate = reportType === "Maintenance" && siteRow2.testingDay ? new Date(siteRow2.testingDay) : null;
2660
+ const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
2652
2661
  const cidName = `${slug}-header`;
2653
2662
  const { html } = await renderReportHtml({
2654
- siteName: siteRow2.name,
2655
- siteUrl: siteRow2.url,
2663
+ siteName: siteRow.name,
2664
+ siteUrl: siteRow.url,
2656
2665
  reportType,
2657
2666
  completedOn,
2658
2667
  lighthouse: scores,
@@ -2669,10 +2678,10 @@ async function draftReportForSite(base, siteRow2, reportType, options = {}) {
2669
2678
  return { reportRow: null, htmlPath: path, html };
2670
2679
  }
2671
2680
  if (base === null) throw new Error("base required when previewOnly=false");
2672
- const reportId = `${siteRow2.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
2681
+ const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
2673
2682
  const created = await createDraft(base, {
2674
2683
  reportId,
2675
- siteId: siteRow2.id,
2684
+ siteId: siteRow.id,
2676
2685
  reportType,
2677
2686
  periodStart,
2678
2687
  periodEnd,
@@ -2685,8 +2694,8 @@ async function draftReportForSite(base, siteRow2, reportType, options = {}) {
2685
2694
  await setDraftReady(base, created.id, true);
2686
2695
  return { reportRow: created, htmlPath: null, html };
2687
2696
  }
2688
- async function derivePeriodStart(base, siteRow2, reportType, today) {
2689
- const prior = await listReportsForSite(base, siteRow2.id);
2697
+ async function derivePeriodStart(base, siteRow, reportType, today) {
2698
+ const prior = await listReportsForSite(base, siteRow.id);
2690
2699
  const sameType = prior.filter((r) => r.reportType === reportType && r.periodEnd).map((r) => r.periodEnd).sort();
2691
2700
  const latest = sameType[sameType.length - 1];
2692
2701
  return latest ? new Date(latest) : daysAgo(today, 30);
@@ -3019,6 +3028,40 @@ function verifyDashboardToken(provided, expected) {
3019
3028
  return timingSafeEqual(Buffer.from(provided, "utf-8"), Buffer.from(expected, "utf-8"));
3020
3029
  }
3021
3030
 
3031
+ // src/dashboard/onboarding.ts
3032
+ function isNonEmpty(s) {
3033
+ return typeof s === "string" && s.trim().length > 0;
3034
+ }
3035
+ function onboardingStatus(row) {
3036
+ const checks = {
3037
+ firstAudit: isNonEmpty(row.lastLighthouseAuditAt),
3038
+ recipients: isNonEmpty(row.reportRecipientsTo),
3039
+ schedule: row.maintenanceFreq !== "None",
3040
+ poc: isNonEmpty(row.pointOfContact)
3041
+ };
3042
+ const score = Object.values(checks).filter(Boolean).length;
3043
+ return { score, total: 4, checks };
3044
+ }
3045
+
3046
+ // src/dashboard/relative-time.ts
3047
+ function relativeTimeFromNow(iso, now = /* @__PURE__ */ new Date()) {
3048
+ if (!iso) return "\u2014";
3049
+ const t = Date.parse(iso);
3050
+ if (Number.isNaN(t)) return "\u2014";
3051
+ const seconds = Math.max(0, Math.floor((now.getTime() - t) / 1e3));
3052
+ if (seconds < 60) return "just now";
3053
+ const minutes = Math.floor(seconds / 60);
3054
+ if (minutes < 60) return `${minutes}m ago`;
3055
+ const hours = Math.floor(minutes / 60);
3056
+ if (hours < 24) return `${hours}h ago`;
3057
+ const days = Math.floor(hours / 24);
3058
+ if (days < 7) return `${days}d ago`;
3059
+ const weeks = Math.floor(days / 7);
3060
+ if (weeks < 4) return `${weeks}w ago`;
3061
+ const months = Math.floor(days / 30);
3062
+ return `${months}mo ago`;
3063
+ }
3064
+
3022
3065
  // src/dashboard/fleet-render.ts
3023
3066
  function escapeHtml2(s) {
3024
3067
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -3031,54 +3074,88 @@ function safeUrl2(raw) {
3031
3074
  }
3032
3075
  return "#";
3033
3076
  }
3034
- function scoreCell(value) {
3035
- const display = value === null ? "\u2014" : String(value);
3036
- return `<td class="score">${escapeHtml2(display)}</td>`;
3077
+ var DASH = "\u2014";
3078
+ function scoreSpan(category, value) {
3079
+ const display = value === null ? DASH : String(value);
3080
+ return `<span class="score ${category}">${escapeHtml2(display)}</span>`;
3081
+ }
3082
+ function a11ySpan(value) {
3083
+ const display = value === null ? DASH : String(value);
3084
+ return `<span class="metric a11y">${escapeHtml2(display)}</span>`;
3037
3085
  }
3038
- function siteHrefCell(site) {
3086
+ function depsSpan(drifted, majorBehind) {
3087
+ if (drifted === null || majorBehind === null) {
3088
+ return `<span class="metric deps">${DASH}</span>`;
3089
+ }
3090
+ const display = drifted === 0 ? "0" : `${drifted} drifted (${majorBehind} major)`;
3091
+ return `<span class="metric deps">${escapeHtml2(display)}</span>`;
3092
+ }
3093
+ function securitySpan(critical, high, moderate, low) {
3094
+ if (critical === null || high === null || moderate === null || low === null) {
3095
+ return `<span class="metric sec">${DASH}</span>`;
3096
+ }
3097
+ const total = critical + high + moderate + low;
3098
+ const display = total === 0 ? "0" : `${critical}C/${high}H/${moderate}M/${low}L`;
3099
+ return `<span class="metric sec">${escapeHtml2(display)}</span>`;
3100
+ }
3101
+ function card(site) {
3039
3102
  const name = escapeHtml2(site.name);
3040
3103
  const token = site.dashboardToken ?? "";
3041
3104
  const href = `/s/${escapeHtml2(siteSlug(site.name))}?t=${escapeHtml2(token)}`;
3042
- return `<td class="site"><a href="${href}">${name}</a></td>`;
3043
- }
3044
- function siteRow(site) {
3045
- return `<tr>
3046
- ${siteHrefCell(site)}
3047
- <td><a href="${escapeHtml2(safeUrl2(site.url))}" class="url" target="_blank" rel="noopener">${escapeHtml2(site.url)}</a></td>
3048
- ${scoreCell(site.pScore)}
3049
- ${scoreCell(site.rScore)}
3050
- ${scoreCell(site.bpScore)}
3051
- ${scoreCell(site.seoScore)}
3052
- </tr>`;
3105
+ const onboarding = onboardingStatus(site);
3106
+ const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);
3107
+ const safeSiteUrl = escapeHtml2(safeUrl2(site.url));
3108
+ const visibleUrl = escapeHtml2(site.url);
3109
+ return `<article class="card">
3110
+ <header class="card-head">
3111
+ <a class="site" href="${href}">${name}</a>
3112
+ <a class="url" href="${safeSiteUrl}" target="_blank" rel="noopener">${visibleUrl}</a>
3113
+ <span class="setup">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>
3114
+ <span class="audited">Audited: <strong>${escapeHtml2(audited)}</strong></span>
3115
+ </header>
3116
+ <div class="card-metrics">
3117
+ <span class="cluster lighthouse">
3118
+ ${scoreSpan("perf", site.pScore)}
3119
+ ${scoreSpan("a11y-lh", site.rScore)}
3120
+ ${scoreSpan("bp", site.bpScore)}
3121
+ ${scoreSpan("seo", site.seoScore)}
3122
+ </span>
3123
+ <span class="cluster health">
3124
+ <span class="metric-label">a11y</span> ${a11ySpan(site.a11yViolations)}
3125
+ <span class="metric-label">deps</span> ${depsSpan(site.depsDrifted, site.depsMajorBehind)}
3126
+ <span class="metric-label">sec</span> ${securitySpan(
3127
+ site.securityVulnsCritical,
3128
+ site.securityVulnsHigh,
3129
+ site.securityVulnsModerate,
3130
+ site.securityVulnsLow
3131
+ )}
3132
+ </span>
3133
+ </div>
3134
+ </article>`;
3053
3135
  }
3054
3136
  var STYLES2 = `
3055
3137
  :root { color-scheme: light dark; }
3056
3138
  body { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
3057
3139
  @media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }
3058
3140
  h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
3059
- .meta { color: #666; margin-bottom: 2rem; }
3060
- table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
3061
- th { text-align: left; padding: 0.5rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: #666; border-bottom: 2px solid #ddd; }
3062
- @media (prefers-color-scheme: dark) { th { border-color: #333; } }
3063
- td { padding: 0.65rem 0.5rem; border-bottom: 1px solid #eee; }
3064
- @media (prefers-color-scheme: dark) { td { border-color: #2a2a2a; } }
3065
- td.site a { font-weight: 500; }
3066
- td.url { color: #666; font-size: 0.85rem; }
3067
- td.score { text-align: right; font-variant-numeric: tabular-nums; min-width: 3.5rem; }
3141
+ .meta { color: #666; margin-bottom: 1.5rem; }
3068
3142
  .empty { color: #999; padding: 2rem; text-align: center; border: 1px dashed #ccc; border-radius: 6px; }
3143
+ .cards { display: flex; flex-direction: column; gap: 0.75rem; }
3144
+ .card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 0.9rem 1.1rem; }
3145
+ @media (prefers-color-scheme: dark) { .card { border-color: #2a2a2a; background: #181818; } }
3146
+ .card-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: baseline; }
3147
+ .card-head .site { font-weight: 600; font-size: 1.05rem; }
3148
+ .card-head .url { color: #666; font-size: 0.85rem; }
3149
+ .card-head .setup, .card-head .audited { color: #666; font-size: 0.85rem; }
3150
+ .card-head .setup { margin-left: auto; }
3151
+ .card-metrics { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin-top: 0.5rem; font-variant-numeric: tabular-nums; }
3152
+ .cluster { display: inline-flex; gap: 0.5rem; align-items: baseline; }
3153
+ .cluster.lighthouse .score { display: inline-block; min-width: 2.25rem; text-align: right; }
3154
+ .metric-label { color: #999; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
3155
+ .metric { font-feature-settings: "tnum"; }
3069
3156
  `;
3070
3157
  function renderFleetHomeHtml(sites) {
3071
- const body = sites.length === 0 ? `<div class="empty">No sites to display.</div>` : `<table>
3072
- <thead><tr>
3073
- <th>Site</th>
3074
- <th>URL</th>
3075
- <th>Perf</th>
3076
- <th>A11y</th>
3077
- <th>BP</th>
3078
- <th>SEO</th>
3079
- </tr></thead>
3080
- <tbody>${sites.map(siteRow).join("")}</tbody>
3081
- </table>`;
3158
+ const body = sites.length === 0 ? `<div class="empty">No sites to display.</div>` : `<div class="cards">${sites.map(card).join("")}</div>`;
3082
3159
  return `<!doctype html>
3083
3160
  <html lang="en">
3084
3161
  <head>