@cronwatch/sdk 0.3.1 → 0.5.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 (136) hide show
  1. package/README.md +8 -2
  2. package/dist/anthropic.d.cts +1 -1
  3. package/dist/anthropic.d.ts +1 -1
  4. package/dist/bugsnag.cjs +108 -0
  5. package/dist/bugsnag.cjs.map +1 -0
  6. package/dist/bugsnag.d.cts +26 -0
  7. package/dist/bugsnag.d.ts +26 -0
  8. package/dist/bugsnag.js +106 -0
  9. package/dist/bugsnag.js.map +1 -0
  10. package/dist/client-B7bqnkI5.d.cts +450 -0
  11. package/dist/client-pmqH3apS.d.ts +450 -0
  12. package/dist/d1.cjs +214 -0
  13. package/dist/d1.cjs.map +1 -0
  14. package/dist/d1.d.cts +42 -0
  15. package/dist/d1.d.ts +42 -0
  16. package/dist/d1.js +212 -0
  17. package/dist/d1.js.map +1 -0
  18. package/dist/datadog.cjs +95 -0
  19. package/dist/datadog.cjs.map +1 -0
  20. package/dist/datadog.d.cts +24 -0
  21. package/dist/datadog.d.ts +24 -0
  22. package/dist/datadog.js +93 -0
  23. package/dist/datadog.js.map +1 -0
  24. package/dist/discord.cjs +2 -0
  25. package/dist/discord.cjs.map +1 -1
  26. package/dist/discord.d.cts +1 -1
  27. package/dist/discord.d.ts +1 -1
  28. package/dist/discord.js +2 -0
  29. package/dist/discord.js.map +1 -1
  30. package/dist/email-BJ-z_epD.d.cts +21 -0
  31. package/dist/email-_H0faCh3.d.ts +21 -0
  32. package/dist/honeybadger.cjs +101 -0
  33. package/dist/honeybadger.cjs.map +1 -0
  34. package/dist/honeybadger.d.cts +23 -0
  35. package/dist/honeybadger.d.ts +23 -0
  36. package/dist/honeybadger.js +99 -0
  37. package/dist/honeybadger.js.map +1 -0
  38. package/dist/index.cjs +1238 -179
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.cts +5 -260
  41. package/dist/index.d.ts +5 -260
  42. package/dist/index.js +1238 -179
  43. package/dist/index.js.map +1 -1
  44. package/dist/mailgun.cjs +115 -0
  45. package/dist/mailgun.cjs.map +1 -0
  46. package/dist/mailgun.d.cts +21 -0
  47. package/dist/mailgun.d.ts +21 -0
  48. package/dist/mailgun.js +113 -0
  49. package/dist/mailgun.js.map +1 -0
  50. package/dist/newrelic.cjs +88 -0
  51. package/dist/newrelic.cjs.map +1 -0
  52. package/dist/newrelic.d.cts +25 -0
  53. package/dist/newrelic.d.ts +25 -0
  54. package/dist/newrelic.js +86 -0
  55. package/dist/newrelic.js.map +1 -0
  56. package/dist/node.cjs +191 -0
  57. package/dist/node.cjs.map +1 -0
  58. package/dist/node.d.cts +97 -0
  59. package/dist/node.d.ts +97 -0
  60. package/dist/node.js +186 -0
  61. package/dist/node.js.map +1 -0
  62. package/dist/pg-cron.cjs +286 -0
  63. package/dist/pg-cron.cjs.map +1 -0
  64. package/dist/pg-cron.d.cts +93 -0
  65. package/dist/pg-cron.d.ts +93 -0
  66. package/dist/pg-cron.js +280 -0
  67. package/dist/pg-cron.js.map +1 -0
  68. package/dist/postgres.cjs +20 -0
  69. package/dist/postgres.cjs.map +1 -1
  70. package/dist/postgres.d.cts +1 -1
  71. package/dist/postgres.d.ts +1 -1
  72. package/dist/postgres.js +20 -0
  73. package/dist/postgres.js.map +1 -1
  74. package/dist/postmark.cjs +106 -0
  75. package/dist/postmark.cjs.map +1 -0
  76. package/dist/postmark.d.cts +18 -0
  77. package/dist/postmark.d.ts +18 -0
  78. package/dist/postmark.js +104 -0
  79. package/dist/postmark.js.map +1 -0
  80. package/dist/resend.cjs +114 -0
  81. package/dist/resend.cjs.map +1 -0
  82. package/dist/resend.d.cts +16 -0
  83. package/dist/resend.d.ts +16 -0
  84. package/dist/resend.js +112 -0
  85. package/dist/resend.js.map +1 -0
  86. package/dist/rollbar.cjs +108 -0
  87. package/dist/rollbar.cjs.map +1 -0
  88. package/dist/rollbar.d.cts +20 -0
  89. package/dist/rollbar.d.ts +20 -0
  90. package/dist/rollbar.js +106 -0
  91. package/dist/rollbar.js.map +1 -0
  92. package/dist/sendgrid.cjs +114 -0
  93. package/dist/sendgrid.cjs.map +1 -0
  94. package/dist/sendgrid.d.cts +19 -0
  95. package/dist/sendgrid.d.ts +19 -0
  96. package/dist/sendgrid.js +112 -0
  97. package/dist/sendgrid.js.map +1 -0
  98. package/dist/sentry.cjs +130 -0
  99. package/dist/sentry.cjs.map +1 -0
  100. package/dist/sentry.d.cts +28 -0
  101. package/dist/sentry.d.ts +28 -0
  102. package/dist/sentry.js +127 -0
  103. package/dist/sentry.js.map +1 -0
  104. package/dist/ses.cjs +170 -0
  105. package/dist/ses.cjs.map +1 -0
  106. package/dist/ses.d.cts +25 -0
  107. package/dist/ses.d.ts +25 -0
  108. package/dist/ses.js +168 -0
  109. package/dist/ses.js.map +1 -0
  110. package/dist/slack.cjs +2 -0
  111. package/dist/slack.cjs.map +1 -1
  112. package/dist/slack.d.cts +1 -1
  113. package/dist/slack.d.ts +1 -1
  114. package/dist/slack.js +2 -0
  115. package/dist/slack.js.map +1 -1
  116. package/dist/sqlite.cjs +18 -0
  117. package/dist/sqlite.cjs.map +1 -1
  118. package/dist/sqlite.d.cts +1 -1
  119. package/dist/sqlite.d.ts +1 -1
  120. package/dist/sqlite.js +18 -0
  121. package/dist/sqlite.js.map +1 -1
  122. package/dist/twilio.cjs +168 -0
  123. package/dist/twilio.cjs.map +1 -0
  124. package/dist/twilio.d.cts +52 -0
  125. package/dist/twilio.d.ts +52 -0
  126. package/dist/twilio.js +162 -0
  127. package/dist/twilio.js.map +1 -0
  128. package/dist/{types-DUt2apl0.d.cts → types-Ddq1MUPL.d.cts} +29 -2
  129. package/dist/{types-DUt2apl0.d.ts → types-Ddq1MUPL.d.ts} +29 -2
  130. package/dist/webhook.cjs +11 -5
  131. package/dist/webhook.cjs.map +1 -1
  132. package/dist/webhook.d.cts +6 -3
  133. package/dist/webhook.d.ts +6 -3
  134. package/dist/webhook.js +11 -6
  135. package/dist/webhook.js.map +1 -1
  136. package/package.json +208 -2
package/dist/index.cjs CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
3
  var croner = require('croner');
4
- var crypto$1 = require('crypto');
5
4
 
6
5
  // src/duration.ts
7
6
  var UNIT_MS = {
@@ -55,6 +54,16 @@ function formatRelative(at, now) {
55
54
  if (abs < 5e3) return text;
56
55
  return diff < 0 ? `${text} ago` : `in ${text}`;
57
56
  }
57
+
58
+ // src/env.ts
59
+ function readEnv(name) {
60
+ return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
61
+ }
62
+ function isDevelopment() {
63
+ if (typeof process === "undefined" || !process.env) return false;
64
+ const mode = process.env.NODE_ENV;
65
+ return mode === "development" || mode === "test";
66
+ }
58
67
  var cache = /* @__PURE__ */ new Map();
59
68
  var crons = /* @__PURE__ */ new WeakMap();
60
69
  var EARLY_SLACK_MS = 6e4;
@@ -95,6 +104,28 @@ function fireAfter(parsed, from) {
95
104
  }
96
105
  return null;
97
106
  }
107
+ function firesBetween(parsed, from, to, limit) {
108
+ const cron = crons.get(parsed);
109
+ if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
110
+ const out = [];
111
+ let probe = from;
112
+ let last = from;
113
+ for (let guard = 0; guard < 1e3; guard++) {
114
+ const batch = cron.nextRuns(Math.min(limit + 1 - out.length, 24), new Date(probe));
115
+ if (batch.length === 0) return out;
116
+ for (const date of batch) {
117
+ const t = date.getTime();
118
+ if (t <= last) continue;
119
+ if (t > to) return out;
120
+ out.push(t);
121
+ last = t;
122
+ if (out.length > limit) return null;
123
+ }
124
+ const end = batch[batch.length - 1].getTime();
125
+ probe = end > probe ? end : probe + 36e5;
126
+ }
127
+ return out;
128
+ }
98
129
  function nextFire(parsed, from, lastRunAt) {
99
130
  if (parsed.kind === "interval") {
100
131
  const base = lastRunAt ?? from;
@@ -307,7 +338,15 @@ function onRunFinish(def, run, state, history, now) {
307
338
  function onCheck(def, stored, lastRun, state, now) {
308
339
  const next = cloneState(state);
309
340
  const alerts = [];
310
- if (!def.schedule) return { state: next, alerts, nextExpectedAt: null, dueAt: null };
341
+ if (!def.schedule) {
342
+ const since = next.open.missed;
343
+ if (since !== void 0) {
344
+ delete next.open.missed;
345
+ next.pendingRecovery = (next.pendingRecovery ?? []).filter((c) => c !== "missed");
346
+ alerts.push({ type: "recovered", run: lastRun, details: { after: ["missed"], reason: "unscheduled", since } });
347
+ }
348
+ return { state: next, alerts, nextExpectedAt: null, dueAt: null };
349
+ }
311
350
  const parsed = parseSchedule(def.schedule, def.timezone);
312
351
  const grace = graceMs(def);
313
352
  const lastRunAt = lastRun?.startedAt ?? null;
@@ -457,6 +496,12 @@ ${out}`);
457
496
  break;
458
497
  }
459
498
  case "recovered": {
499
+ if (draft.details.reason === "unscheduled") {
500
+ title = `${name} is no longer scheduled`;
501
+ const since = draft.details.since;
502
+ lines.push(`${since === void 0 ? "" : `Missed since ${when(since, now)}. `}It has no schedule now, so nothing is due; the missed alert is closed.`);
503
+ break;
504
+ }
460
505
  title = `${name} recovered`;
461
506
  const after = draft.details.after.map((c) => c.replace("_", " ")).join(", ");
462
507
  lines.push(`A run ${run ? when(run.startedAt, now) : "just now"} succeeded${after ? ` after: ${after}` : ""}.`);
@@ -642,191 +687,796 @@ function checkExpectation(expect, output) {
642
687
  return ok ? null : "Output did not pass the expect() check";
643
688
  }
644
689
 
645
- // src/routes/html.ts
690
+ // src/routes/escape.ts
646
691
  function escapeHtml(value) {
647
692
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
648
693
  }
694
+
695
+ // src/routes/icons.ts
696
+ var ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="10" fill="#141417"/><rect x="0.5" y="0.5" width="39" height="39" rx="9.5" fill="none" stroke="#2a2a2f"/><circle cx="20" cy="20" r="10.5" fill="none" stroke="#f3f3f1" stroke-width="2"/><path d="M20 12.5V20h6" fill="none" stroke="#f3f3f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
697
+ var MASKABLE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" fill="#141417"/><circle cx="20" cy="20" r="10.5" fill="none" stroke="#f3f3f1" stroke-width="2"/><path d="M20 12.5V20h6" fill="none" stroke="#f3f3f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
698
+ var ICON_192_PNG = "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";
699
+ var ICON_512_PNG = "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";
700
+ var MASKABLE_512_PNG = "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";
701
+ var APPLE_TOUCH_ICON_PNG = "iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAMAAAAKE/YAAAAAM1BMVEUUFBciIiU+PkBaWlt2dnaRkZKfn5+tra27u7tMTE7X19bz8/FoaGnl5eMwMDKEhITJycgc8OlvAAAFSklEQVR42u2caZPjKAyGOZ042XXm///LSVXaB9fW7vYYfGTawiJO1+j91IcNj4UQAoMZI5FIJBKJRCKRSCQSiUQikUgkEolEIpG+izh+iZ9ijIVPvTd0baRclhic0+17QvOrqfzT/4pBP8K7QUulvrzGWvdG0A0328oJOtwxLISA7IXf+OzcB/ZXfzw01wJUhgwsHO0eqvbge0Rrj4SuTybrPt23h7nHLayZWVh3Cp0IIfRa9UyteIPnl/4YSzfD8on1w1/n8aF5iOuyQVx1PwBaXuZmdvbyDKT5UPMnFB/u5dDqNGPo3O+7pJDn2QW9fTG0rmYBwc1dt2bzzsblLNQM5qXQlZ6a/eeij3rPhFj+eWpcM7wwekyZzbJPaR4YC57NPaavJ71XcvcyS0+YQ7+sWNSfP7RLP5cnvtfWcq8/m96v5Xy/AvLyn8GmlUrmXwI9iRv9al/6+xfJeW0McUGluUhGHgB/zJS5y4patkt+Ocny0M0ludlnjg/OJ/VeGnBbQ29Ien94eJapVpzH7gh/cqilbzIZA7OZGfNdrFneykLXiQ/bHcyM+bSkuih00gm7nasCbbdaLD60igGu3z2zdjEcGlUOmsdmNJbtlo0mqHkxaDV6cTAMQWbMDL0qBd3E4btHWS0K0UGqphB07HkGaanImZXCUaETQ58Zks5ZpgZAx76i7ljQd5WTJAOgx6ZcTkjy9VMsiseEjgvPqGvNbZxBFoAeG1I4TGgn4LnbZmg+ltmhvo4I42iuODr0NdqGocqtVIEFbWICjwsd02mDDj0GacuQZRdVYEHHtaELNvRYoK+Rocem03ds6LuG+sdW6DGIPhi6HtD1DDC0x4f2haA5B8el7boua8GBHq+/40PfBTBpgkIbVkCmMLQvAe2B0AoIXQOXZp+swwzt6mpKIWhg4vFs+BSnIaykH4XcAyuXVtVqTl0GusLyY1Wv5DW40AW04/GPg2bvBj0gXbMPOsCq2rA3wrYr/KEM9MaUd/iK2qZPXwOhFRB6YyIWevHbjjZM3npJWxZ6c5xuARG91WXdQxSNBoWgdQloXRjaN/jMjS8EHV4xRwy40DEREwVd2rFC0CXniNjQcW0C3akbA+3lW6Hb8cIPbOixQNEiQ8f8QGFDK3AKtRl6bDqJ3BXj/lqNDh1DncSFlgwcTTdDhzFvO6OeI+DjWzkb0KHjxNqjmlr6r2bue6AdNKfepphLuwLQsZ/4Gx7zzWekYgDo6HIWbYBpLAMmS8BQ0MeLB6w3XCEW2RaxdOLLGqkvSs1yOgqk9sTUEuUdF4/Rcyi0dYIxG1e/UWYwOq7Vg4wAa+e4aydni+gi6YgTdtgWP1giYaOBT7vdOtm0qmHeBqw6eZWj1D6/rhMPs7CigClbmySmale6J9KSgKve4OTnnGyQ3bHZNNkgy1yXnRhu1IWPQxc/ZUe++pzaHHq6CJ5mynSrWJc3NO4sAx4DQro5XmVFPpUyZ2xazQhcPr1JcTA1r9IF1SHDw3KirU83dknlA9A10jpNzivgrCHCpdRc8ev2ntSE/cdcih2DepbzH3gMirnpjf68xUe4uk473YsPnB17tC877fG+CtM5iJbPj1c3rqrktDFEmz39yc8f3OKEmzy5H1ou5o+N1D/cIil0PH/Ktmvh5bbavsL42rX/xuKhlq3Qq16j9uwM/pZHsP/Aw+7/xTHodoLB7t0WvHvdNhgJGiEGaQ7/FsL/8eHFn8r4lh8lwVq2Dc5WVj1veGHkh8Xa4v6Hf2gnrjIU/6QRiUQikUgkEolEIpFIJBKJRCKRSCQSifR99A+QQLqujpJ2+QAAAABJRU5ErkJggg==";
702
+
703
+ // src/routes/pwa.ts
704
+ var BACKGROUND_COLOR = "#f4f4f5";
705
+ var THEME_COLOR = "#ffffff";
706
+ var THEME_COLOR_DARK = "#111113";
707
+ function manifest(base) {
708
+ const icon = (name, sizes, type, purpose) => ({ src: `${base}/icons/${name}`, sizes, type, purpose });
709
+ return JSON.stringify({
710
+ id: `${base}/`,
711
+ name: "CronWatch",
712
+ short_name: "CronWatch",
713
+ description: "The scheduled jobs of this app: their health, their last day and their runs.",
714
+ start_url: `${base}/`,
715
+ scope: `${base}/`,
716
+ display: "standalone",
717
+ background_color: BACKGROUND_COLOR,
718
+ theme_color: THEME_COLOR,
719
+ icons: [
720
+ icon("icon.svg", "any", "image/svg+xml", "any"),
721
+ icon("maskable.svg", "any", "image/svg+xml", "maskable"),
722
+ icon("icon-192.png", "192x192", "image/png", "any"),
723
+ icon("icon-512.png", "512x512", "image/png", "any"),
724
+ icon("maskable-512.png", "512x512", "image/png", "maskable")
725
+ ]
726
+ });
727
+ }
728
+ var APP_JS = `"use strict";
729
+ (function () {
730
+ var script = document.currentScript;
731
+ if (!script || !("serviceWorker" in navigator)) return;
732
+ var base = new URL("./", script.src);
733
+ navigator.serviceWorker.register(new URL("sw.js", base).href, { scope: base.pathname }).catch(function () {});
734
+ })();
735
+ `;
736
+ var SW_JS = `"use strict";
737
+ var VERSION = "cronwatch-shell-1";
738
+ var SCOPE = self.registration.scope;
739
+ var CACHE = VERSION + " " + SCOPE;
740
+ var SHELL = ["offline", "manifest.webmanifest", "app.js", "icons/icon.svg", "icons/maskable.svg", "icons/icon-192.png", "icons/icon-512.png", "icons/maskable-512.png", "icons/apple-touch-icon.png"].map(function (path) {
741
+ return new URL(path, SCOPE).href;
742
+ });
743
+ var OFFLINE = SHELL[0];
744
+
745
+ self.addEventListener("install", function (event) {
746
+ event.waitUntil(caches.open(CACHE).then(function (cache) {
747
+ return cache.addAll(SHELL.map(function (url) { return new Request(url, { credentials: "omit", cache: "reload" }); }));
748
+ }).then(function () { return self.skipWaiting(); }));
749
+ });
750
+
751
+ self.addEventListener("activate", function (event) {
752
+ event.waitUntil(caches.keys().then(function (keys) {
753
+ return Promise.all(keys.filter(function (key) {
754
+ return key !== CACHE && key.indexOf("cronwatch-shell-") === 0 && key.slice(key.indexOf(" ") + 1) === SCOPE;
755
+ }).map(function (key) { return caches.delete(key); }));
756
+ }).then(function () { return self.clients.claim(); }));
757
+ });
758
+
759
+ self.addEventListener("fetch", function (event) {
760
+ var request = event.request;
761
+ if (request.method !== "GET") return;
762
+ var url = new URL(request.url);
763
+ url.search = "";
764
+ if (SHELL.indexOf(url.href) !== -1 && url.href !== OFFLINE) {
765
+ event.respondWith(caches.open(CACHE).then(function (cache) {
766
+ return cache.match(url.href).then(function (hit) { return hit || fetch(request); });
767
+ }));
768
+ return;
769
+ }
770
+ if (request.mode !== "navigate") return;
771
+ event.respondWith(fetch(request).catch(function () {
772
+ return caches.open(CACHE).then(function (cache) { return cache.match(OFFLINE); }).then(function (page) {
773
+ return page || new Response("You are offline.", { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } });
774
+ });
775
+ }));
776
+ });
777
+ `;
778
+ var YEAR = "public, max-age=31536000, immutable";
779
+ var REVALIDATE = "no-cache";
780
+ function decode(base64) {
781
+ const bytes = atob(base64);
782
+ const out = new Uint8Array(bytes.length);
783
+ for (let i = 0; i < bytes.length; i++) out[i] = bytes.charCodeAt(i);
784
+ return out;
785
+ }
786
+ var ICONS = {
787
+ "/icons/icon.svg": () => ({ type: "image/svg+xml", body: ICON_SVG, cache: YEAR }),
788
+ "/icons/maskable.svg": () => ({ type: "image/svg+xml", body: MASKABLE_SVG, cache: YEAR }),
789
+ "/icons/icon-192.png": () => ({ type: "image/png", body: decode(ICON_192_PNG), cache: YEAR }),
790
+ "/icons/icon-512.png": () => ({ type: "image/png", body: decode(ICON_512_PNG), cache: YEAR }),
791
+ "/icons/maskable-512.png": () => ({ type: "image/png", body: decode(MASKABLE_512_PNG), cache: YEAR }),
792
+ "/icons/apple-touch-icon.png": () => ({ type: "image/png", body: decode(APPLE_TOUCH_ICON_PNG), cache: YEAR })
793
+ };
794
+ function staticAsset(path, base) {
795
+ if (path === "/manifest.webmanifest") return { type: "application/manifest+json", body: manifest(base), cache: REVALIDATE };
796
+ if (path === "/app.js") return { type: "text/javascript; charset=utf-8", body: APP_JS, cache: REVALIDATE };
797
+ if (path === "/sw.js") return { type: "text/javascript; charset=utf-8", body: SW_JS, cache: REVALIDATE, worker: true };
798
+ return Object.hasOwn(ICONS, path) ? ICONS[path]() : null;
799
+ }
800
+
801
+ // src/routes/timeline.ts
802
+ var HOUR = 36e5;
803
+ var DAY = 24 * HOUR;
804
+ var BOARD_BEHIND_MS = DAY;
805
+ var BOARD_AHEAD_MS = 3 * HOUR;
806
+ var BOARD_LANES = 30;
807
+ var BOARD_RUNS = 200;
808
+ var WEEK_DAYS = 7;
809
+ var W = 1e3;
810
+ var MAX_TICKS = 330;
811
+ var MAX_BOXES = 8;
812
+ var MIN_BOX = 10;
813
+ var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
814
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
815
+ function clock(t) {
816
+ return new Date(t).toISOString().slice(11, 16);
817
+ }
818
+ function dayLabel(t) {
819
+ const d = new Date(t);
820
+ return `${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
821
+ }
822
+ function when2(t, now) {
823
+ if (Math.floor(t / DAY) === Math.floor(now / DAY)) return clock(t);
824
+ const d = new Date(t);
825
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${clock(t)}`;
826
+ }
827
+ function parsedSchedule(job) {
828
+ if (!job.definition.schedule) return null;
829
+ try {
830
+ return parseSchedule(job.definition.schedule, job.definition.timezone);
831
+ } catch {
832
+ return null;
833
+ }
834
+ }
835
+ function safely(fn, fallback) {
836
+ try {
837
+ return fn();
838
+ } catch {
839
+ return fallback;
840
+ }
841
+ }
842
+ function dueTimes(job, parsed, runs, from, to) {
843
+ if (!parsed) return { times: [], dense: false };
844
+ if (parsed.kind === "interval") {
845
+ const every = parsed.everyMs;
846
+ if ((to - from) / every > MAX_TICKS) return { times: [], dense: true };
847
+ const starts = runs.map((r) => r.startedAt).sort((a, b) => a - b);
848
+ const times2 = /* @__PURE__ */ new Set();
849
+ for (const start of starts) {
850
+ const t2 = start + every;
851
+ if (t2 >= from && t2 <= to) times2.add(t2);
852
+ }
853
+ let t = starts.length ? starts[starts.length - 1] + every : job.nextExpectedAt;
854
+ if (t !== null) {
855
+ if (t < from) t += Math.ceil((from - t) / every) * every;
856
+ for (; t <= to; t += every) times2.add(t);
857
+ }
858
+ return { times: [...times2].sort((a, b) => a - b), dense: false };
859
+ }
860
+ const times = safely(() => firesBetween(parsed, from - 1, to, MAX_TICKS), []);
861
+ return times === null ? { times: [], dense: true } : { times, dense: false };
862
+ }
863
+ function missedAt(job, parsed, times, now) {
864
+ if (!parsed || !job.open.includes("missed")) return null;
865
+ const grace = safely(() => graceMs(job.definition), 0);
866
+ const last = job.lastRun?.startedAt ?? null;
867
+ if (last !== null) return safely(() => expectation(parsed, last, last, grace)?.dueAt ?? null, null);
868
+ const past = times.filter((t) => t + grace < now);
869
+ return past.length ? past[past.length - 1] : null;
870
+ }
871
+ function toneOf(run, job, now) {
872
+ if (run.status === "running") return safely(() => isStuck(job.definition, run, now), false) ? "stuck" : "running";
873
+ if (run.status === "failed") return "bad";
874
+ if (run.status === "timeout") return "timeout";
875
+ const latest = job.lastRun?.id === run.id;
876
+ return latest && (job.open.includes("over_budget") || job.open.includes("slow")) ? "warn" : "ok";
877
+ }
878
+ function timeoutText(job) {
879
+ return safely(() => formatDuration(timeoutMs(job.definition)), "configured");
880
+ }
881
+ function describeRun(run, tone, job, now) {
882
+ const at = `${when2(run.startedAt, now)} UTC`;
883
+ if (tone === "running") return `running since ${at}, ${formatDuration(now - run.startedAt)} so far`;
884
+ if (tone === "stuck") return `running since ${at}, past its ${timeoutText(job)} timeout`;
885
+ const took = run.durationMs !== null ? `, took ${formatDuration(run.durationMs)}` : "";
886
+ const extra = tone === "warn" ? job.open.includes("over_budget") ? ", over budget" : ", slow" : "";
887
+ return `${run.status} at ${at}${took}${extra}`;
888
+ }
889
+ function overCeilings(job) {
890
+ const metrics = job.lastRun?.metrics ?? {};
891
+ return Object.entries(job.definition.budget ?? {}).filter(([k, limit]) => (metrics[k] ?? -Infinity) > limit).map(([k]) => k);
892
+ }
893
+ function laneNote(job, missed, now) {
894
+ const last = job.lastRun;
895
+ if (job.silencedUntil !== null && job.silencedUntil > now) return `silenced until ${when2(job.silencedUntil, now)}`;
896
+ if (job.open.includes("missed")) return missed !== null ? `due ${when2(missed, now)}, nothing ran` : "overdue, nothing ran";
897
+ if (last?.status === "running") {
898
+ const stuck = safely(() => isStuck(job.definition, last, now), false);
899
+ return stuck ? `running since ${when2(last.startedAt, now)}, past its ${timeoutText(job)} timeout` : `running since ${when2(last.startedAt, now)}`;
900
+ }
901
+ if (last?.status === "failed") return `failed at ${when2(last.startedAt, now)}${job.consecutiveFailures > 1 ? `, ${job.consecutiveFailures} in a row` : ""}`;
902
+ if (last?.status === "timeout") return `timed out at ${when2(last.startedAt, now)}`;
903
+ if (job.open.includes("stuck")) return "stuck";
904
+ if (job.open.includes("over_budget") && last) {
905
+ const over = overCeilings(job);
906
+ return `went over budget${over.length ? ` on ${over.join(" and ")}` : ""} at ${when2(last.startedAt, now)}`;
907
+ }
908
+ if (job.open.includes("slow") && last?.durationMs != null) return `slow: took ${formatDuration(last.durationMs)}`;
909
+ if (job.open.includes("failed")) return "failing";
910
+ if (!last && job.nextExpectedAt !== null) return `no runs yet, first due ${when2(job.nextExpectedAt, now)}`;
911
+ return null;
912
+ }
913
+ var f = (n) => n.toFixed(1);
914
+ var delay = (x, base = 80, perUnit = 0.75) => `--d:${Math.round(base + Math.max(0, x) * perUnit)}ms`;
915
+ function lane(input, span, options) {
916
+ const { job } = input;
917
+ const { from, to, now } = span;
918
+ const x = (t) => Math.min(W, Math.max(0, (t - from) / (to - from) * W));
919
+ const parsed = parsedSchedule(job);
920
+ const due = dueTimes(job, parsed, input.runs, from, to);
921
+ const missed = missedAt(job, parsed, due.times, now);
922
+ const grace = safely(() => graceMs(job.definition), 0);
923
+ const name = options.label;
924
+ const busy = [];
925
+ let s = `<svg class="marks" viewBox="0 0 ${W} 24" preserveAspectRatio="none" aria-hidden="true" focusable="false">`;
926
+ if (options.nowInLane && now > from && now < to) s += `<rect class="ahead" x="${f(x(now))}" y="0" width="${f(W - x(now))}" height="24"/>`;
927
+ s += `<line class="base" x1="0" y1="12" x2="${W}" y2="12"/>`;
928
+ const inSpan = input.runs.filter((r) => r.startedAt <= to && (r.finishedAt ?? now) >= from).sort((a, b) => a.startedAt - b.startedAt);
929
+ if (!input.complete) {
930
+ const oldest = Math.min(...input.runs.map((r) => r.startedAt));
931
+ if (Number.isFinite(oldest) && oldest > from) {
932
+ s += `<rect class="unloaded" x="0" y="4" width="${f(x(oldest))}" height="16"><title>${escapeHtml(`${name}: runs before ${when2(oldest, now)} UTC are not loaded here`)}</title></rect>`;
933
+ }
934
+ }
935
+ if (due.dense) {
936
+ s += `<line class="cadence" x1="0" y1="12" x2="${W}" y2="12"><title>${escapeHtml(`${name}: due ${job.definition.schedule ?? ""}, too often to mark each time`)}</title></line>`;
937
+ }
938
+ for (const t of due.times) {
939
+ const tx = x(t);
940
+ s += `<line class="tick${t > now ? " ahead" : ""}" x1="${f(tx)}" y1="6" x2="${f(tx)}" y2="18" style="${delay(tx, 0, 0.45)}"/>`;
941
+ }
942
+ if (missed !== null && missed <= to) {
943
+ const slots = due.dense ? [] : due.times.filter((t) => t >= missed && t + grace < now);
944
+ if (!slots.includes(missed) && missed >= from) slots.unshift(missed);
945
+ const title = escapeHtml(`${name}: due ${when2(missed, now)} UTC, nothing started${slots.length > 1 ? ` (${slots.length} slots in this span)` : ""}`);
946
+ if (due.dense || slots.length > MAX_BOXES) {
947
+ const x1 = x(Math.max(missed, from)), x2 = Math.max(x(now), x1 + MIN_BOX);
948
+ s += `<rect class="missed" x="${f(x1)}" y="5" width="${f(x2 - x1)}" height="14" style="${delay(x1)}"><title>${title}</title></rect>`;
949
+ busy.push([x1, x2]);
950
+ } else {
951
+ for (const t of slots) {
952
+ if (t < from) continue;
953
+ const x1 = x(t), width = Math.max(x(t + grace) - x1, MIN_BOX);
954
+ s += `<rect class="missed" x="${f(x1)}" y="5" width="${f(width)}" height="14" style="${delay(x1)}"><title>${title}</title></rect>`;
955
+ busy.push([x1, x1 + width]);
956
+ }
957
+ }
958
+ }
959
+ for (const run of inSpan) {
960
+ const tone = toneOf(run, job, now);
961
+ const x1 = x(run.startedAt), x2 = Math.max(x(run.finishedAt ?? now), x1 + 0.5);
962
+ s += `<rect class="run ${tone}" x="${f(x1)}" y="5" width="${f(x2 - x1)}" height="14" style="${delay(x1)}"><title>${escapeHtml(`${name}: ${describeRun(run, tone, job, now)}`)}</title></rect>`;
963
+ busy.push([x1, x2]);
964
+ }
965
+ if (options.nowInLane && now > from && now < to) s += `<line class="nowline" x1="${f(x(now))}" y1="0" x2="${f(x(now))}" y2="24"/>`;
966
+ s += `</svg>`;
967
+ const text = laneNote(job, missed, now);
968
+ let note = "";
969
+ if (text) {
970
+ const nowX = x(now);
971
+ const lo = busy.length ? Math.min(...busy.map((b) => b[0])) : nowX;
972
+ const hi = busy.length ? Math.max(...busy.map((b) => b[1])) : nowX;
973
+ const right = W - hi >= lo;
974
+ const room = right ? W - hi : lo;
975
+ if (room > 90) {
976
+ const edge = right ? hi + 14 : lo - 14;
977
+ const place = right ? `left:${f(edge / 10)}%` : `right:${f(100 - edge / 10)}%`;
978
+ note = `<span class="note${right ? "" : " before"}" style="${place};max-width:${f((room - 18) / 10)}%">${escapeHtml(text)}</span>`;
979
+ }
980
+ }
981
+ return { svg: s, note, words: words(job, inSpan, due, missed, span, text) };
982
+ }
983
+ function words(job, runs, due, missed, span, note) {
984
+ const parts = [];
985
+ if (due.dense) parts.push(`due ${job.definition.schedule}`);
986
+ else if (job.definition.schedule) {
987
+ const n = due.times.filter((t) => t <= span.now).length;
988
+ parts.push(`due ${n === 0 ? "no times" : n === 1 ? "once" : `${n} times`} so far`);
989
+ }
990
+ const ok = runs.filter((r) => r.status === "ok").length;
991
+ parts.push(`${runs.length} ${runs.length === 1 ? "run" : "runs"} recorded${!runs.length ? "" : ok === runs.length ? ok === 1 ? ", ok" : ", all ok" : ok ? `, ${ok} ok` : ""}`);
992
+ for (const r of runs.filter((r2) => r2.status === "failed" || r2.status === "timeout").slice(-5)) {
993
+ parts.push(`${r.status} at ${when2(r.startedAt, span.now)} UTC after ${formatDuration(r.durationMs ?? 0)}`);
994
+ }
995
+ if (missed !== null) parts.push(`due at ${when2(missed, span.now)} UTC and nothing started`);
996
+ if (note && !/^(due |failed at|timed out)/.test(note)) parts.push(note);
997
+ return parts.join("; ");
998
+ }
999
+ function hours(span, step, nowLabel) {
1000
+ const x = (t) => (t - span.from) / (span.to - span.from) * W;
1001
+ const nowX = x(span.now);
1002
+ let lines = "", labels = "";
1003
+ for (let t = Math.ceil(span.from / step) * step; t <= span.to; t += step) {
1004
+ const gx = x(t);
1005
+ lines += `<i class="gl" style="left:${f(gx / 10)}%"></i>`;
1006
+ const nearNow = nowLabel && Math.abs(gx - nowX) < 70;
1007
+ if (gx < 25 || gx > W - 25 || nearNow) continue;
1008
+ const minor = Math.round(t / HOUR) % 6 !== 0;
1009
+ const near = nowLabel && Math.abs(gx - nowX) < 170;
1010
+ const cls = [minor && "minor", near && "near"].filter(Boolean).join(" ");
1011
+ labels += `<span class="${cls}" style="left:${f(gx / 10)}%">${clock(t)}</span>`;
1012
+ }
1013
+ if (nowLabel && span.now >= span.from && span.now <= span.to) labels += `<span class="nowlabel" style="left:${f(nowX / 10)}%">now ${clock(span.now)}</span>`;
1014
+ return { lines, labels };
1015
+ }
1016
+ function legend() {
1017
+ const key = (inner) => `<svg class="key" viewBox="0 0 16 12" aria-hidden="true" focusable="false">${inner}</svg>`;
1018
+ const box = (cls) => key(`<rect class="${cls}" x="2" y="1" width="12" height="10"/>`);
1019
+ const items = [
1020
+ [key(`<line class="tick" x1="8" y1="1" x2="8" y2="11"/>`), "due"],
1021
+ [box("run ok"), "ran"],
1022
+ [box("run bad"), "failed"],
1023
+ [box("run timeout"), "timed out"],
1024
+ [box("run warn"), "over budget or slow"],
1025
+ [box("run running"), "running"],
1026
+ [box("missed"), "missed"]
1027
+ ];
1028
+ return `<p class="legend" aria-hidden="true">${items.map(([k, label]) => `<span>${k}${label}</span>`).join("")}</p>`;
1029
+ }
1030
+ function stateClass(job) {
1031
+ return { healthy: "ok", late: "warn", failing: "bad", stuck: "bad", silenced: "muted", never_ran: "muted" }[job.health];
1032
+ }
1033
+ function dayTimeline(lanes, span, base, total) {
1034
+ const grid = hours(span, 3 * HOUR, true);
1035
+ const nowX = (span.now - span.from) / (span.to - span.from) * 100;
1036
+ const rows = lanes.map((input) => {
1037
+ const { job } = input;
1038
+ const parts = lane(input, span, { nowInLane: false, label: job.name });
1039
+ return {
1040
+ html: `<li class="lane"><div class="who"><i class="sq ${stateClass(job)}" aria-hidden="true"></i><a class="name" href="${escapeHtml(base)}/jobs/${encodeURIComponent(job.name)}">${escapeHtml(job.name)}</a><span class="sched">${escapeHtml(job.definition.schedule ?? "no schedule")}</span></div><div class="track">${parts.svg}${parts.note}</div></li>`,
1041
+ words: `<li>${escapeHtml(`${job.name} (${job.definition.schedule ?? "no schedule"}): ${parts.words}.`)}</li>`
1042
+ };
1043
+ });
1044
+ const more = total > lanes.length ? `<p class="more">Showing the first ${lanes.length} of ${total} jobs here; the table below lists them all.</p>` : "";
1045
+ return `<figure class="timeline day">
1046
+ <div class="axis" aria-hidden="true"><span></span><div class="hours">${grid.labels}</div></div>
1047
+ <div class="field">
1048
+ <div class="under" aria-hidden="true"><span></span><div>${grid.lines}<i class="future" style="left:${f(nowX)}%"></i></div></div>
1049
+ <ol class="lanes">${rows.map((r) => r.html).join("")}</ol>
1050
+ <div class="over" aria-hidden="true"><span></span><div><i class="now" style="left:${f(nowX)}%"></i></div></div>
1051
+ </div>
1052
+ ${legend()}${more}
1053
+ <ul class="vh">${rows.map((r) => r.words).join("")}</ul>
1054
+ </figure>`;
1055
+ }
1056
+ function weekTimeline(job, runs, complete, now) {
1057
+ const today = Math.floor(now / DAY) * DAY;
1058
+ const oldest = runs.length ? Math.min(...runs.map((r) => r.startedAt)) : null;
1059
+ const first = { from: today, to: today + DAY, now };
1060
+ const grid = hours(first, 3 * HOUR, false);
1061
+ const rows = [];
1062
+ for (let i = 0; i < WEEK_DAYS; i++) {
1063
+ const from = today - i * DAY;
1064
+ const span = { from, to: from + DAY, now };
1065
+ const dayRuns = runs.filter((r) => r.startedAt < span.to && (r.finishedAt ?? now) >= from);
1066
+ const known = complete || oldest !== null && oldest <= from;
1067
+ const label = i === 0 ? "today" : dayLabel(from);
1068
+ const parts = lane({ job, runs, complete: known }, span, { nowInLane: i === 0, label: `${job.name}, ${label}` });
1069
+ const count = `${dayRuns.length} ${dayRuns.length === 1 ? "run" : "runs"}`;
1070
+ rows.push({
1071
+ html: `<li class="lane${i === 0 ? " today" : ""}"><div class="who"><span class="name">${escapeHtml(i === 0 ? `Today, ${dayLabel(from).slice(4)}` : dayLabel(from))}</span><span class="sched">${escapeHtml(count)}</span></div><div class="track">${parts.svg}${i === 0 ? parts.note : ""}</div></li>`,
1072
+ words: `<li>${escapeHtml(`${i === 0 ? "Today" : dayLabel(from)}: ${parts.words}.`)}</li>`
1073
+ });
1074
+ }
1075
+ return `<figure class="timeline week">
1076
+ <div class="axis" aria-hidden="true"><span></span><div class="hours">${grid.labels}</div></div>
1077
+ <div class="field">
1078
+ <div class="under" aria-hidden="true"><span></span><div>${grid.lines}</div></div>
1079
+ <ol class="lanes">${rows.map((r) => r.html).join("")}</ol>
1080
+ </div>
1081
+ ${legend()}
1082
+ <ul class="vh">${rows.map((r) => r.words).join("")}</ul>
1083
+ </figure>`;
1084
+ }
1085
+ function weekRunsLimit(job, now) {
1086
+ const parsed = parsedSchedule(job);
1087
+ if (!parsed) return 50;
1088
+ const from = Math.floor(now / DAY) * DAY - (WEEK_DAYS - 1) * DAY;
1089
+ const span = now + DAY - from;
1090
+ const expected = parsed.kind === "interval" ? span / parsed.everyMs : (() => {
1091
+ const due = dueTimes(job, parsed, [], now - DAY, now);
1092
+ return due.dense ? Infinity : due.times.length * span / DAY;
1093
+ })();
1094
+ return Math.min(500, Math.max(50, Math.ceil(expected * 1.2) + 10));
1095
+ }
1096
+
1097
+ // src/routes/html.ts
649
1098
  var h = escapeHtml;
650
1099
  var CSS = `
651
- :root{--bg:#fbfbf9;--fg:#1b1b18;--muted:#6b6b64;--line:#e6e5df;--card:#fff;--ok:#1f8a4c;--warn:#b7791f;--bad:#c62828;--info:#2b5fb3;--pill:#f1f0ea;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
652
- @media(prefers-color-scheme:dark){:root{--bg:#121311;--fg:#ecece6;--muted:#9a9a91;--line:#2a2b27;--card:#1a1b18;--pill:#24251f}}
1100
+ :root{color-scheme:light dark;--paper:#f4f4f5;--sheet:#fff;--sunk:#fafafa;--rule:#e4e4e7;--rule-2:#d4d4d8;--tick:#909098;--ink:#000;--body:#18181b;--muted:#71717a;--ok:#15803d;--warn:#a16207;--bad:#b91c1c;--serif:"Newsreader",ui-serif,Georgia,Cambria,"Times New Roman",serif;--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--who:200px}
1101
+ @media(prefers-color-scheme:dark){:root{--paper:#09090b;--sheet:#111113;--sunk:#18181b;--rule:#27272a;--rule-2:#3f3f46;--tick:#66666f;--ink:#fff;--body:#e4e4e7;--muted:#a1a1aa;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171}}
653
1102
  *{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
654
- body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 var(--sans)}
655
- a{color:inherit}main{max-width:1080px;margin:0 auto;padding:24px 16px 64px}
656
- header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:20px}
657
- header h1{font-size:18px;margin:0;letter-spacing:-.01em}header h1 a{text-decoration:none}
658
- header .meta{color:var(--muted);font-size:13px}
659
- .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
660
- table{width:100%;border-collapse:collapse;font-size:14px}
661
- th{text-align:left;font-weight:600;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em;padding:10px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
662
- td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
663
- tr:last-child td{border-bottom:0}
664
- .name{font-weight:600;white-space:nowrap}.name a{text-decoration:none}.name a:hover{text-decoration:underline}
665
- .mono{font-family:var(--mono);font-size:13px}.muted{color:var(--muted)}.nowrap{white-space:nowrap}
666
- .pill{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border-radius:999px;background:var(--pill);font-size:12px;font-weight:600;white-space:nowrap}
667
- .pill::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
668
- .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.info{color:var(--info)}.mutedpill{color:var(--muted)}
669
- .spark{display:block}
670
- form.inline{display:inline}
671
- button,select{font:inherit;font-size:13px;padding:5px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);cursor:pointer}
672
- button:hover{border-color:var(--muted)}
673
- .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
674
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px}
675
- .stat{padding:12px 14px}.stat .k{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.stat .v{font-size:20px;font-weight:600;margin-top:2px}
676
- pre{margin:0;padding:10px 12px;background:var(--pill);border-radius:8px;font:12.5px/1.45 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:320px;overflow:auto}
677
- details summary{cursor:pointer;color:var(--muted);font-size:13px}details{margin-top:6px}
678
- details.confirm{margin:0}details.confirm summary{list-style:none;display:inline-block;font-size:13px;padding:5px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg)}
679
- details.confirm summary::-webkit-details-marker{display:none}details.confirm[open] summary{border-color:var(--muted)}details.confirm form{margin-left:8px;font-size:13px}
680
- .empty{padding:40px 16px;text-align:center;color:var(--muted)}
681
- dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:0;padding:14px 16px;font-size:14px}dt{color:var(--muted)}dd{margin:0}
682
- footer{margin-top:28px;color:var(--muted);font-size:12px}
683
- @media(max-width:720px){.hide-sm{display:none}main{padding:16px 16px 48px}}
1103
+ body{margin:0;background:var(--paper);color:var(--ink);font:400 16px/1.55 var(--serif);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
1104
+ a{color:inherit;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:.16em;text-decoration-color:var(--rule-2)}a:hover{text-decoration-color:currentColor}
1105
+ :focus-visible{outline:2px solid var(--ink);outline-offset:2px}
1106
+ code,pre,.mono{font-family:var(--mono)}
1107
+ .vh{position:absolute!important;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
1108
+ .sheet{max-width:1180px;min-height:100vh;margin:0 auto;background:var(--sheet);border-inline:1px solid var(--rule);padding:0 clamp(16px,4vw,48px)}
1109
+ .top{display:flex;align-items:center;justify-content:space-between;gap:12px 20px;flex-wrap:wrap;padding:18px 0 17px;border-bottom:1px solid var(--rule)}
1110
+ .brand{display:flex;align-items:center;gap:10px;margin:0;font:600 18px/1.2 var(--serif);letter-spacing:-.01em;min-width:0}
1111
+ .brand a{display:inline-flex;align-items:center;gap:10px;text-decoration:none}.brand svg{width:24px;height:24px;flex:none;color:var(--ink)}
1112
+ .brand .crumb{font:500 15px/1.2 var(--mono);color:var(--body);overflow-wrap:anywhere}.brand .slash{color:var(--rule-2);font-weight:400}
1113
+ .actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
1114
+ .meta{font:400 12px/1.4 var(--mono);color:var(--muted)}
1115
+ button,select,details.confirm>summary{font:500 12.5px/1 var(--mono);color:var(--ink);background:var(--sheet);border:1px solid var(--rule-2);border-radius:3px;padding:8px 11px;cursor:pointer}
1116
+ select{padding:7px 8px}
1117
+ button:hover,select:hover,details.confirm>summary:hover{border-color:var(--muted)}
1118
+ button.primary{background:var(--ink);border-color:var(--ink);color:var(--sheet)}button.primary:hover{opacity:.86}
1119
+ form.inline{display:inline-flex;align-items:center;gap:6px;margin:0}
1120
+ details.confirm{display:inline-flex;align-items:center;gap:8px;margin:0}details.confirm>summary{list-style:none;display:inline-block}
1121
+ details.confirm>summary::-webkit-details-marker{display:none}details.confirm[open]>summary{border-color:var(--muted)}
1122
+ details.confirm form{margin-left:8px;font-size:14px;color:var(--muted)}
1123
+ .sec{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px 40px;padding:30px 0;border-top:1px solid var(--rule)}
1124
+ .top+main>.sec:first-child{border-top:0}
1125
+ .sec>h2{margin:0;font:500 11px/1.5 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-top:5px}
1126
+ .sec>.wide{grid-column:1/-1;min-width:0}
1127
+ .lede{margin:0;color:var(--muted);font-size:16px;max-width:64ch;text-wrap:pretty}
1128
+ .headline{margin:0;font:400 clamp(24px,3.2vw,32px)/1.2 var(--serif);letter-spacing:-.01em;text-wrap:balance}
1129
+ .headline b{font-weight:600}
1130
+ .state{font:500 12.5px/1.4 var(--mono);white-space:nowrap}.state+.state::before{content:" \\00b7 ";color:var(--muted);font-weight:400}
1131
+ .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.muted{color:var(--muted)}.info{color:var(--ink)}
1132
+ .sq{display:inline-block;width:8px;height:8px;border-radius:1.5px;background:currentColor;margin-right:7px;vertical-align:1px;flex:none}
1133
+ .sq.muted{background:none;box-shadow:inset 0 0 0 1.5px var(--tick)}
1134
+ .figures{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:0;margin:22px 0 0;border-top:1px solid var(--rule)}
1135
+ .figures>div{display:flex;flex-direction:column-reverse;justify-content:flex-end;gap:4px;padding:14px 16px 2px 0}
1136
+ .figures dt{font:500 11px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center}
1137
+ .figures dd{margin:0;font:400 30px/1.1 var(--serif);font-variant-numeric:tabular-nums;color:var(--ink)}
1138
+ .figures dd small{font-size:17px;color:var(--muted)}
1139
+ .figures .zero dd{color:var(--rule-2)}
1140
+ .figures .bad dd{color:var(--bad)}.figures .warn dd{color:var(--warn)}
1141
+ .stateline{margin:12px 0 0;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px}
1142
+ .stateline .why{font-style:italic;color:var(--muted)}
1143
+ .jobname{margin:0;font:500 clamp(22px,3vw,28px)/1.2 var(--mono);letter-spacing:-.01em;overflow-wrap:anywhere}
1144
+ .desc{margin:6px 0 0;color:var(--body);max-width:64ch}
1145
+ .intro .actions{margin-top:18px}
1146
+ table{width:100%;border-collapse:collapse}
1147
+ th{text-align:left;font:500 10.5px/1.2 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:0 14px 10px 0;border-bottom:1px solid var(--rule);white-space:nowrap}
1148
+ td{padding:12px 14px 12px 0;border-bottom:1px solid var(--rule);vertical-align:top;font:400 12.5px/1.5 var(--mono);color:var(--body)}
1149
+ tbody tr:last-child td{border-bottom:0}
1150
+ td.job{font-family:var(--serif);font-size:15px;min-width:180px}
1151
+ td.job .name{font:500 13.5px/1.5 var(--mono);color:var(--ink)}
1152
+ td.job .desc{display:block;margin:2px 0 0;font-size:14px;color:var(--muted);line-height:1.4}
1153
+ td .tz,td .sub{display:block;color:var(--muted);font-size:11.5px}
1154
+ .nowrap{white-space:nowrap}
1155
+ .spark{display:block;overflow:visible}.spark rect{fill:var(--tick)}.spark rect.bad{fill:var(--bad)}.spark rect.warn{fill:var(--warn)}.spark rect.running{fill:none;stroke:var(--ink);stroke-width:1}
1156
+ .runs td{padding-top:11px;padding-bottom:11px}.runs tr.has-detail td{border-bottom:0;padding-bottom:4px}.runs tr.detail td{padding-top:0}
1157
+ .metrics{display:flex;flex-wrap:wrap;gap:2px 14px}.metrics .k{color:var(--muted)}
1158
+ pre{margin:6px 0 0;padding:12px 14px;background:var(--sunk);border:1px solid var(--rule);border-radius:3px;font:400 12.5px/1.55 var(--mono);color:var(--body);white-space:pre-wrap;overflow-wrap:anywhere;max-height:340px;overflow:auto}
1159
+ details.out{margin-top:4px}details.out>summary{cursor:pointer;font:400 12px/1.6 var(--mono);color:var(--muted)}details.out>summary:hover{color:var(--ink)}
1160
+ details.out.error>summary{color:var(--bad)}
1161
+ dl.def{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 28px;margin:0}
1162
+ dl.def dt{font:500 11px/1.9 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
1163
+ dl.def dd{margin:0;font:400 13.5px/1.7 var(--mono);color:var(--body);overflow-wrap:anywhere}dl.def dd.prose{font:400 16px/1.55 var(--serif)}
1164
+ .empty{padding:28px 0 8px;color:var(--muted);max-width:60ch}
1165
+ .empty code{font-size:.86em;color:var(--ink)}
1166
+ .message{padding:clamp(56px,12vh,120px) 0;text-align:center}
1167
+ .message h1{margin:0;font:400 clamp(28px,4vw,40px)/1.15 var(--serif);letter-spacing:-.015em}
1168
+ .message p{margin:14px auto 0;max-width:52ch;color:var(--muted);text-wrap:pretty}
1169
+ .signin{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px;margin:28px auto 0;max-width:420px}
1170
+ .signin label{font:500 11px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
1171
+ .signin input{flex:1 1 180px;min-width:0;font:400 16px/1.2 var(--mono);color:var(--ink);background:var(--sheet);border:1px solid var(--rule-2);border-radius:3px;padding:8px 10px}
1172
+ footer{display:flex;flex-wrap:wrap;gap:6px 18px;padding:20px 0 40px;border-top:1px solid var(--rule);font:400 12px/1.5 var(--mono);color:var(--muted)}
1173
+ .timeline{margin:18px 0 0}
1174
+ .timeline .axis,.timeline .under,.timeline .over,.timeline .lane{display:grid;grid-template-columns:var(--who) minmax(0,1fr)}
1175
+ .timeline .hours{position:relative;height:22px;font:400 11px/1 var(--mono);color:var(--muted);letter-spacing:.04em}
1176
+ .timeline .hours span{position:absolute;top:2px;transform:translateX(-50%);white-space:nowrap}
1177
+ .timeline .hours .nowlabel{color:var(--ink);font-weight:500;animation:cw-fade .5s 1s both}
1178
+ .timeline .field{position:relative}
1179
+ .timeline .under,.timeline .over{position:absolute;inset:0;pointer-events:none}
1180
+ .timeline .under>div,.timeline .over>div{position:relative}
1181
+ .timeline .gl{position:absolute;top:0;bottom:0;width:1px;background:var(--rule)}
1182
+ .timeline .future{position:absolute;top:0;bottom:0;right:0;background:var(--sunk)}
1183
+ .timeline .now{position:absolute;top:-6px;bottom:0;width:1.5px;margin-left:-.75px;background:var(--ink);transform-origin:top;animation:cw-drop .6s .85s cubic-bezier(.2,.8,.2,1) both}
1184
+ .timeline .lanes{position:relative;list-style:none;margin:0;padding:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
1185
+ .timeline .lane{align-items:center;min-height:40px}
1186
+ .timeline .who{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;column-gap:0;padding:6px 14px 6px 0;min-width:0}
1187
+ .timeline .who .sched{grid-column:2}
1188
+ .timeline.week .who{display:flex;align-items:baseline;gap:10px}
1189
+ .timeline .who .name{font:500 13px/1.35 var(--mono);color:var(--ink);overflow-wrap:anywhere}
1190
+ .timeline .who .sched{font:400 11px/1.35 var(--mono);color:var(--muted);white-space:nowrap}
1191
+ .timeline .track{position:relative;height:24px}
1192
+ .timeline .marks{display:block;width:100%;height:24px;overflow:visible}
1193
+ .timeline .note{position:absolute;top:50%;transform:translateY(-50%);font:italic 400 14px/1.2 var(--serif);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:0 3px;text-shadow:0 0 3px var(--sheet),0 0 3px var(--sheet),0 0 6px var(--sheet);animation:cw-fade .6s 1.1s both}
1194
+ .timeline .note.before{text-align:right}
1195
+ .timeline .legend{display:flex;flex-wrap:wrap;gap:6px 18px;margin:14px 0 0;padding-left:var(--who);font:400 11.5px/1.4 var(--mono);color:var(--muted)}
1196
+ .timeline .legend span{display:inline-flex;align-items:center;gap:7px}
1197
+ .timeline .more{margin:10px 0 0;padding-left:var(--who);font-size:14px;color:var(--muted);font-style:italic}
1198
+ .key{width:16px;height:12px;overflow:visible}
1199
+ .marks *{vector-effect:non-scaling-stroke}
1200
+ svg .base{stroke:var(--rule);stroke-width:1}
1201
+ svg .tick{stroke:var(--tick);stroke-width:1.5}svg .tick.ahead{stroke-dasharray:2 2;opacity:.75}
1202
+ svg .cadence{stroke:var(--tick);stroke-width:2;stroke-dasharray:1 3}
1203
+ svg .run{stroke-width:2;stroke-linejoin:round}
1204
+ svg .run.ok{fill:var(--ok);stroke:var(--ok)}
1205
+ svg .run.bad{fill:var(--bad);stroke:var(--bad)}
1206
+ svg .run.timeout{fill:var(--bad);fill-opacity:.28;stroke:var(--bad);stroke-width:1.5}
1207
+ svg .run.warn{fill:var(--warn);stroke:var(--warn)}
1208
+ svg .run.running{fill:none;stroke:var(--ink);stroke-width:1.5}
1209
+ svg .run.stuck{fill:var(--bad);fill-opacity:.12;stroke:var(--bad);stroke-width:1.5}
1210
+ svg .missed{fill:none;stroke:var(--bad);stroke-width:1.5;stroke-dasharray:3 2.5}
1211
+ svg .unloaded{fill:var(--sunk)}
1212
+ svg .ahead{fill:var(--sunk)}
1213
+ svg .nowline{stroke:var(--ink);stroke-width:1.5}
1214
+ .marks .tick{animation:cw-fade .4s var(--d,0ms) both}
1215
+ .marks .run,.marks .missed{transform-box:fill-box;transform-origin:0 50%;animation:cw-grow .55s cubic-bezier(.2,.8,.2,1) var(--d,0ms) both}
1216
+ .marks .missed,.marks .run.running,.marks .run.stuck{animation:cw-grow .55s cubic-bezier(.2,.8,.2,1) var(--d,0ms) both,cw-breathe 2.6s ease-in-out calc(var(--d,0ms) + .6s) infinite alternate}
1217
+ .figures>div{animation:cw-rise .5s cubic-bezier(.2,.8,.2,1) both}
1218
+ .figures>div:nth-child(2){animation-delay:40ms}.figures>div:nth-child(3){animation-delay:80ms}.figures>div:nth-child(4){animation-delay:120ms}.figures>div:nth-child(5){animation-delay:160ms}.figures>div:nth-child(6){animation-delay:200ms}
1219
+ @keyframes cw-fade{from{opacity:0}}
1220
+ @keyframes cw-grow{from{opacity:0;transform:scaleX(0)}}
1221
+ @keyframes cw-drop{from{opacity:0;transform:scaleY(0)}}
1222
+ @keyframes cw-rise{from{opacity:0;transform:translateY(4px)}}
1223
+ @keyframes cw-breathe{to{opacity:.38}}
1224
+ body{padding:0 env(safe-area-inset-right) 0 env(safe-area-inset-left)}
1225
+ footer{padding-bottom:calc(40px + env(safe-area-inset-bottom))}
1226
+ @media(display-mode:standalone){
1227
+ .top{position:sticky;top:0;z-index:2;background:var(--sheet);padding-top:calc(14px + env(safe-area-inset-top));padding-bottom:13px;-webkit-user-select:none;user-select:none}
1228
+ .message{padding-top:clamp(40px,8vh,80px)}
1229
+ }
1230
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}
1231
+ @media(max-width:760px){
1232
+ .sec{grid-template-columns:minmax(0,1fr);gap:10px;padding:24px 0}
1233
+ .hide-sm{display:none}
1234
+ :root{--who:0px}
1235
+ .timeline .lane{grid-template-columns:minmax(0,1fr);padding:6px 0 8px}
1236
+ .timeline .who{padding:0 0 4px}
1237
+ .timeline .who .name{background:var(--sheet);padding-right:4px}
1238
+ .timeline .hours .minor,.timeline .hours .near{display:none}
1239
+ .timeline .note{font-size:13px}
1240
+ td.job{min-width:0}
1241
+ table.board thead{display:none}
1242
+ table.board tr{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:14px;padding:12px 0;border-bottom:1px solid var(--rule)}
1243
+ table.board tbody tr:last-child{border-bottom:0}
1244
+ table.board td{border:0;padding:0}
1245
+ table.board td.last{grid-column:1/-1;margin-top:4px;white-space:normal}
1246
+ table.board td.last .sub{display:inline;margin-left:8px}
1247
+ .runs td.nowrap{white-space:normal}
1248
+ dl.def{grid-template-columns:minmax(0,1fr);gap:0}dl.def dd{margin-bottom:10px}
1249
+ }
684
1250
  `;
685
- function layout(title, body, options = {}) {
1251
+ var MARK = `<svg viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect x="1" y="1" width="38" height="38" rx="9.5" fill="none" stroke="currentColor" stroke-opacity=".22" stroke-width="1.5"/><circle cx="20" cy="20" r="10.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M20 12.5V20h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
1252
+ function layout(title, body, base, options = {}) {
1253
+ const b = h(base);
686
1254
  return `<!doctype html>
687
1255
  <html lang="en">
688
1256
  <head>
689
1257
  <meta charset="utf-8">
690
- <meta name="viewport" content="width=device-width,initial-scale=1">
1258
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
691
1259
  <meta name="robots" content="noindex,nofollow">
1260
+ <meta name="color-scheme" content="light dark">
692
1261
  ${options.refresh ? `<meta http-equiv="refresh" content="${options.refresh}">` : ""}
693
1262
  <title>${h(title)}</title>
1263
+ <meta name="theme-color" content="${THEME_COLOR}" media="(prefers-color-scheme: light)">
1264
+ <meta name="theme-color" content="${THEME_COLOR_DARK}" media="(prefers-color-scheme: dark)">
1265
+ <meta name="mobile-web-app-capable" content="yes">
1266
+ <meta name="apple-mobile-web-app-capable" content="yes">
1267
+ <meta name="apple-mobile-web-app-title" content="CronWatch">
1268
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
1269
+ <link rel="manifest" href="${b}/manifest.webmanifest">
1270
+ <link rel="icon" href="${b}/icons/icon.svg" type="image/svg+xml">
1271
+ <link rel="apple-touch-icon" href="${b}/icons/apple-touch-icon.png">
1272
+ <script src="${b}/app.js" defer></script>
694
1273
  <style>${CSS}</style>
695
1274
  </head>
696
- <body><main>${body}</main></body>
1275
+ <body><div class="sheet">${body}</div></body>
697
1276
  </html>`;
698
1277
  }
699
- function healthPill(job) {
700
- const map = {
701
- healthy: ["ok", "healthy"],
702
- late: ["warn", "late"],
703
- failing: ["bad", "failing"],
704
- stuck: ["bad", "stuck"],
705
- silenced: ["mutedpill", "silenced"],
706
- never_ran: ["info", "never ran"]
707
- };
708
- const [cls, label] = map[job.health];
709
- const extras = job.open.filter((c) => !["missed", "failed", "stuck"].includes(c)).map((c) => c.replace("_", " "));
710
- return `<span class="pill ${cls}">${label}</span>${extras.length ? ` <span class="pill warn">${h(extras.join(", "))}</span>` : ""}`;
1278
+ function brand(base, crumb) {
1279
+ const home = `<a href="${h(base)}/">${MARK}<span>CronWatch</span></a>`;
1280
+ return crumb === void 0 ? `<p class="brand">${home}</p>` : `<p class="brand">${home}<span class="slash" aria-hidden="true">/</span><span class="crumb">${h(crumb)}</span></p>`;
1281
+ }
1282
+ var HEALTH = {
1283
+ failing: ["bad", "failing"],
1284
+ stuck: ["bad", "stuck"],
1285
+ late: ["warn", "late"],
1286
+ healthy: ["ok", "healthy"],
1287
+ silenced: ["muted", "silenced"],
1288
+ never_ran: ["muted", "never ran"]
1289
+ };
1290
+ function healthState(job) {
1291
+ const [cls, label] = HEALTH[job.health];
1292
+ const extras = job.open.filter((c) => !["missed", "failed", "stuck"].includes(c)).map((c) => `<span class="state warn">${h(c.replace("_", " "))}</span>`).join("");
1293
+ return `<span class="state ${cls}"><i class="sq ${cls}" aria-hidden="true"></i>${label}</span>${extras}`;
711
1294
  }
712
- function runPill(run) {
1295
+ function runState(run) {
713
1296
  const cls = run.status === "ok" ? "ok" : run.status === "running" ? "info" : "bad";
714
- return `<span class="pill ${cls}">${h(run.status)}</span>`;
1297
+ return `<span class="state ${cls}">${h(run.status)}</span>`;
715
1298
  }
716
1299
  function sparkline(runs) {
717
- const points = [...runs].reverse().filter((r) => r.durationMs !== null).slice(-20);
1300
+ const points = [...runs].slice(0, 20).reverse();
718
1301
  if (points.length < 2) return "";
719
- const w = 96, hgt = 22;
720
- const max = Math.max(...points.map((r) => r.durationMs), 1);
721
- const step = w / (points.length - 1);
722
- const path = points.map((r, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(hgt - 2 - r.durationMs / max * (hgt - 4)).toFixed(1)}`).join(" ");
723
- const dots = points.map((r, i) => r.status === "ok" ? "" : `<circle cx="${(i * step).toFixed(1)}" cy="${(hgt - 2 - r.durationMs / max * (hgt - 4)).toFixed(1)}" r="2.2" fill="var(--bad)"/>`).join("");
724
- return `<svg class="spark" width="${w}" height="${hgt}" viewBox="0 0 ${w} ${hgt}" aria-hidden="true"><path d="${path}" fill="none" stroke="var(--muted)" stroke-width="1.5"/>${dots}</svg>`;
1302
+ const bar = 4, gap = 1.5, hgt = 22;
1303
+ const max = Math.max(...points.map((r) => r.durationMs ?? 0), 1);
1304
+ const bars = points.map((r, i) => {
1305
+ const x = (i * (bar + gap)).toFixed(1);
1306
+ if (r.status === "running") return `<rect class="running" x="${x}" y="${hgt - 6.5}" width="${bar - 1}" height="6"/>`;
1307
+ const tall = Math.max(r.status === "ok" ? 2 : 6, (r.durationMs ?? 0) / max * hgt);
1308
+ const cls = r.status === "ok" ? "" : ` class="bad"`;
1309
+ return `<rect${cls} x="${x}" y="${(hgt - tall).toFixed(1)}" width="${bar}" height="${tall.toFixed(1)}" rx=".5"/>`;
1310
+ }).join("");
1311
+ const w = points.length * (bar + gap) - gap;
1312
+ return `<svg class="spark" width="${w.toFixed(1)}" height="${hgt}" viewBox="0 0 ${w.toFixed(1)} ${hgt}" aria-hidden="true" focusable="false">${bars}</svg>`;
725
1313
  }
726
1314
  function stamp(at, now) {
727
1315
  if (at === null) return `<span class="muted">never</span>`;
728
- const iso = new Date(at).toISOString().replace("T", " ").slice(0, 19);
729
- return `<span class="nowrap" title="${iso} UTC">${h(formatRelative(at, now))}</span>`;
730
- }
731
- function dashboardPage(jobs, runsByJob, now, base, checkedAt) {
1316
+ const iso = new Date(at).toISOString();
1317
+ return `<time class="nowrap" datetime="${iso}" title="${iso.replace("T", " ").slice(0, 19)} UTC">${h(formatRelative(at, now))}</time>`;
1318
+ }
1319
+ function healthFigures(jobs) {
1320
+ return `<dl class="figures">${Object.keys(HEALTH).map((health) => {
1321
+ const [cls, label] = HEALTH[health];
1322
+ const n = jobs.filter((j) => j.health === health).length;
1323
+ return `<div class="${n === 0 ? "zero" : cls}"><dt><i class="sq ${cls}" aria-hidden="true"></i>${label}</dt><dd>${n}</dd></div>`;
1324
+ }).join("")}</dl>`;
1325
+ }
1326
+ function dashboardPage(jobs, runsByJob, now, base, checkedAt, lanes = jobs.slice(0, BOARD_LANES).map((job) => ({ job, runs: runsByJob.get(job.name) ?? [], complete: true }))) {
1327
+ const attention = jobs.filter((j) => j.health !== "healthy").length;
1328
+ const headline = jobs.length === 0 ? "No jobs yet." : attention === 0 ? `${jobs.length === 1 ? "The one job is" : `All ${jobs.length} jobs are`} healthy.` : `${jobs.length} job${jobs.length === 1 ? "" : "s"}, <b>${attention} needing attention</b>.`;
732
1329
  const rows = jobs.map((job) => {
733
1330
  const last = job.lastRun;
1331
+ const d = job.definition;
734
1332
  return `<tr>
735
- <td class="name"><a href="${h(base)}/jobs/${encodeURIComponent(job.name)}">${h(job.name)}</a>${job.definition.description ? `<div class="muted" style="font-weight:400;font-size:13px;white-space:normal">${h(job.definition.description)}</div>` : ""}</td>
736
- <td>${healthPill(job)}</td>
737
- <td class="mono nowrap">${h(job.definition.schedule ?? "")}<span class="muted">${job.definition.schedule ? "" : "no schedule"}</span></td>
738
- <td class="nowrap">${last ? `${runPill(last)} ${stamp(last.startedAt, now)}${last.durationMs !== null ? ` <span class="muted">${h(formatDuration(last.durationMs))}</span>` : ""}` : `<span class="muted">never</span>`}</td>
739
- <td class="nowrap hide-sm">${stamp(job.nextExpectedAt, now)}</td>
1333
+ <td class="job"><a class="name" href="${h(base)}/jobs/${encodeURIComponent(job.name)}">${h(job.name)}</a>${d.description ? `<span class="desc">${h(d.description)}</span>` : ""}</td>
1334
+ <td class="health">${healthState(job)}</td>
1335
+ <td class="nowrap hide-sm">${d.schedule ? `${h(d.schedule)}${d.timezone ? `<span class="tz">${h(d.timezone)}</span>` : ""}` : `<span class="muted">no schedule</span>`}</td>
1336
+ <td class="nowrap last">${last ? `${runState(last)} ${stamp(last.startedAt, now)}${last.durationMs !== null ? `<span class="sub">took ${h(formatDuration(last.durationMs))}</span>` : ""}` : `<span class="muted">never</span>`}</td>
1337
+ <td class="nowrap hide-sm">${job.nextExpectedAt !== null ? `${job.nextExpectedAt < now ? `<span class="state warn">overdue</span> ` : ""}${stamp(job.nextExpectedAt, now)}<span class="sub">${h(when2(job.nextExpectedAt, now))} UTC</span>` : `<span class="muted">not scheduled</span>`}</td>
740
1338
  <td class="hide-sm">${sparkline(runsByJob.get(job.name) ?? [])}</td>
741
1339
  </tr>`;
742
1340
  }).join("\n");
1341
+ const span = { from: now - BOARD_BEHIND_MS, to: now + BOARD_AHEAD_MS, now };
743
1342
  const body = `
744
- <header>
745
- <h1><a href="${h(base)}/">CronWatch</a></h1>
1343
+ <header class="top">
1344
+ ${brand(base)}
746
1345
  <div class="actions">
747
- <span class="meta">${jobs.length} job${jobs.length === 1 ? "" : "s"}${""}</span>
748
- <form class="inline" method="post" action="${h(base)}/check"><button type="submit">Run check now</button></form>
1346
+ <span class="meta">${h(clock(now))} UTC${""}</span>
1347
+ <form class="inline" method="post" action="${h(base)}/check"><button class="primary" type="submit">Run check now</button></form>
749
1348
  </div>
750
1349
  </header>
751
- <div class="card">
752
- ${jobs.length === 0 ? `<div class="empty">No jobs yet. Declare one with <code class="mono">cw.job("name", { schedule: "0 2 * * *" })</code> and run it once.</div>` : `<table>
753
- <thead><tr><th>Job</th><th>Health</th><th>Schedule</th><th>Last run</th><th class="hide-sm">Next due</th><th class="hide-sm">Durations</th></tr></thead>
754
- <tbody>${rows}</tbody></table>`}
755
- </div>
756
- <footer>Refreshes every minute. <a href="${h(base)}/api/jobs">JSON</a></footer>`;
757
- return layout("CronWatch", body, { refresh: 60 });
758
- }
759
- function jobPage(job, runs, now, base) {
1350
+ <main>
1351
+ <section class="sec" aria-label="Health">
1352
+ <h2>Health</h2>
1353
+ <div>
1354
+ <p class="headline">${headline}</p>
1355
+ ${jobs.length ? healthFigures(jobs) : `<p class="empty">Declare one with <code>cw.job("name", { schedule: "0 2 * * *" })</code> and run it once, and it shows up here.</p>`}
1356
+ </div>
1357
+ </section>
1358
+ ${jobs.length ? `<section class="sec" aria-label="Last 24 hours">
1359
+ <h2>Last 24 hours</h2>
1360
+ <p class="lede">One lane per job. Faint ticks mark when it was due, bars the runs it recorded, as long as they took. A dashed box is a slot nothing ran in. Times are UTC.</p>
1361
+ <div class="wide">${dayTimeline(lanes, span, base, jobs.length)}</div>
1362
+ </section>
1363
+ <section class="sec" aria-label="Jobs">
1364
+ <h2>Jobs</h2>
1365
+ <p class="lede">Every job in the store. Open one for its week, its runs and their output.</p>
1366
+ <div class="wide"><table class="board">
1367
+ <thead><tr><th>Job</th><th>Health</th><th class="hide-sm">Schedule</th><th>Last run</th><th class="hide-sm">Next due</th><th class="hide-sm">Recent runs</th></tr></thead>
1368
+ <tbody>${rows}</tbody></table></div>
1369
+ </section>` : ""}
1370
+ </main>
1371
+ <footer><span>Refreshes every minute. Times are UTC.</span><a href="${h(base)}/api/jobs">JSON</a></footer>`;
1372
+ return layout("CronWatch", body, base, { refresh: 60 });
1373
+ }
1374
+ function jobPage(job, runs, now, base, complete = true) {
760
1375
  const d = job.definition;
761
1376
  const okRate = `${Math.round(job.stats.okRate * 100)}%`;
762
- const runRows = runs.map((run) => {
1377
+ const listed = runs.slice(0, 50);
1378
+ const runRows = listed.map((run) => {
763
1379
  const detail = [
764
- run.error ? `<details open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
765
- run.output ? `<details${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
1380
+ run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
1381
+ run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
766
1382
  ].join("");
767
- const metrics = Object.entries(run.metrics).map(([k, v]) => `<span class="pill mutedpill">${h(k)} ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join(" ");
768
- return `<tr>
769
- <td class="nowrap">${runPill(run)}</td>
770
- <td class="nowrap">${stamp(run.startedAt, now)}</td>
1383
+ const metrics = Object.entries(run.metrics).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
1384
+ return `<tr${detail ? ` class="has-detail"` : ""}>
1385
+ <td class="nowrap">${runState(run)}</td>
1386
+ <td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
771
1387
  <td class="nowrap">${run.durationMs !== null ? h(formatDuration(run.durationMs)) : `<span class="muted">running</span>`}</td>
772
- <td class="hide-sm">${metrics}</td>
773
- <td class="mono hide-sm muted">${h(run.trigger)}</td>
774
- </tr>${detail ? `<tr><td colspan="5" style="padding-top:0">${detail}</td></tr>` : ""}`;
1388
+ <td class="hide-sm">${metrics ? `<span class="metrics">${metrics}</span>` : ""}</td>
1389
+ <td class="hide-sm muted">${h(run.trigger)}</td>
1390
+ </tr>${detail ? `<tr class="detail"><td colspan="5">${detail}</td></tr>` : ""}`;
775
1391
  }).join("\n");
776
1392
  const silenced = job.silencedUntil !== null && job.silencedUntil > now;
1393
+ const path = `${h(base)}/jobs/${encodeURIComponent(job.name)}`;
1394
+ const parsed = parsedSchedule(job);
1395
+ const why = laneNote(job, missedAt(job, parsed, [], now), now);
777
1396
  const body = `
778
- <header>
779
- <h1><a href="${h(base)}/">CronWatch</a> <span class="muted">/</span> ${h(job.name)}</h1>
780
- <div class="actions">
781
- ${healthPill(job)}
782
- ${silenced ? `<form class="inline" method="post" action="${h(base)}/jobs/${encodeURIComponent(job.name)}/unsilence"><button type="submit">Unsilence (until ${h(formatRelative(job.silencedUntil, now))})</button></form>` : `<form class="inline" method="post" action="${h(base)}/jobs/${encodeURIComponent(job.name)}/silence"><select name="for"><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="1d">1 day</option><option value="7d">1 week</option></select> <button type="submit">Silence</button></form>`}
783
- <details class="confirm"><summary>Forget</summary><form class="inline" method="post" action="${h(base)}/jobs/${encodeURIComponent(job.name)}/forget"><span class="muted">Remove this job and its runs from the store?</span> <button type="submit">Forget</button></form></details>
784
- </div>
1397
+ <header class="top">
1398
+ ${brand(base, job.name)}
1399
+ <div class="actions"><span class="meta">${h(clock(now))} UTC</span></div>
785
1400
  </header>
786
- <div class="grid">
787
- <div class="card stat"><div class="k">Last run</div><div class="v">${job.lastRun ? h(formatRelative(job.lastRun.startedAt, now)) : "never"}</div></div>
788
- <div class="card stat"><div class="k">Next due</div><div class="v">${job.nextExpectedAt ? h(formatRelative(job.nextExpectedAt, now)) : "no schedule"}</div></div>
789
- <div class="card stat"><div class="k">Success, last ${h(job.stats.runs)}</div><div class="v">${h(okRate)}</div></div>
790
- <div class="card stat"><div class="k">p50 / p95</div><div class="v">${job.stats.p50Ms !== null ? h(formatDuration(job.stats.p50Ms)) : "?"} <span class="muted">/</span> ${job.stats.p95Ms !== null ? h(formatDuration(job.stats.p95Ms)) : "?"}</div></div>
791
- </div>
792
- <div class="card" style="margin-bottom:20px">
793
- <dl>
794
- <dt>Schedule</dt><dd class="mono">${d.schedule ? h(d.schedule) + (d.timezone ? ` <span class="muted">${h(d.timezone)}</span>` : "") : `<span class="muted">none</span>`}</dd>
795
- <dt>Grace</dt><dd class="mono">${h(d.grace ?? "10m")}</dd>
796
- <dt>Timeout</dt><dd class="mono">${h(d.timeout ?? "1h")}</dd>
797
- ${d.maxDuration ? `<dt>Max duration</dt><dd class="mono">${h(d.maxDuration)}</dd>` : ""}
798
- ${d.budget ? `<dt>Budget</dt><dd class="mono">${h(Object.entries(d.budget).map(([k, v]) => `${k} \u2264 ${v}`).join(", "))}</dd>` : ""}
799
- ${d.expect ? `<dt>Expect</dt><dd class="mono">${h(d.expect)}</dd>` : ""}
1401
+ <main>
1402
+ <section class="sec intro" aria-label="Job">
1403
+ <h2>Job</h2>
1404
+ <div>
1405
+ <h1 class="jobname">${h(job.name)}</h1>
1406
+ ${d.description ? `<p class="desc">${h(d.description)}</p>` : ""}
1407
+ <p class="stateline">${healthState(job)}${why ? `<span class="why">${h(why)}</span>` : ""}</p>
1408
+ <div class="actions">
1409
+ ${silenced ? `<form class="inline" method="post" action="${path}/unsilence"><button type="submit">Unsilence (until ${h(formatRelative(job.silencedUntil, now))})</button></form>` : `<form class="inline" method="post" action="${path}/silence"><select name="for" aria-label="Silence for"><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="1d">1 day</option><option value="7d">1 week</option></select><button type="submit">Silence</button></form>`}
1410
+ <details class="confirm"><summary>Forget</summary><form class="inline" method="post" action="${path}/forget"><span>Remove this job and its runs from the store?</span> <button type="submit">Forget</button></form></details>
1411
+ </div>
1412
+ <dl class="figures">
1413
+ <div><dt>Last run</dt><dd>${job.lastRun ? h(formatRelative(job.lastRun.startedAt, now)) : "never"}</dd></div>
1414
+ <div><dt>Next due</dt><dd>${job.nextExpectedAt !== null ? h(formatRelative(job.nextExpectedAt, now)) : `<small>no schedule</small>`}</dd></div>
1415
+ <div><dt>Success, last ${h(job.stats.runs)}</dt><dd>${h(okRate)}</dd></div>
1416
+ <div><dt>p50 / p95</dt><dd>${job.stats.p50Ms !== null ? h(formatDuration(job.stats.p50Ms)) : "?"} <small>/ ${job.stats.p95Ms !== null ? h(formatDuration(job.stats.p95Ms)) : "?"}</small></dd></div>
1417
+ </dl>
1418
+ </div>
1419
+ </section>
1420
+ <section class="sec" aria-label="Last 7 days">
1421
+ <h2>Last 7 days</h2>
1422
+ <p class="lede">A lane per UTC day, today first. Faint ticks mark when the job was due, bars its runs, as long as they took.</p>
1423
+ <div class="wide">${weekTimeline(job, runs, complete, now)}</div>
1424
+ </section>
1425
+ <section class="sec" aria-label="Runs">
1426
+ <h2>Runs</h2>
1427
+ ${listed.length === 0 ? `<p class="lede">No runs yet.</p>` : `<p class="lede">The newest ${listed.length === 1 ? "run" : `${listed.length} runs`}, with any error and output.</p>
1428
+ <div class="wide"><table class="runs">
1429
+ <thead><tr><th>Status</th><th>Started</th><th>Took</th><th class="hide-sm">Metrics</th><th class="hide-sm">Trigger</th></tr></thead>
1430
+ <tbody>${runRows}</tbody></table></div>`}
1431
+ </section>
1432
+ <section class="sec" aria-label="Definition">
1433
+ <h2>Definition</h2>
1434
+ <dl class="def">
1435
+ <dt>Schedule</dt><dd>${d.schedule ? h(d.schedule) + (d.timezone ? ` <span class="muted">${h(d.timezone)}</span>` : "") : `<span class="muted">none</span>`}</dd>
1436
+ <dt>Grace</dt><dd>${h(d.grace ?? "10m")}</dd>
1437
+ <dt>Timeout</dt><dd>${h(d.timeout ?? "1h")}</dd>
1438
+ ${d.maxDuration ? `<dt>Max duration</dt><dd>${h(d.maxDuration)}</dd>` : ""}
1439
+ ${d.budget ? `<dt>Budget</dt><dd>${h(Object.entries(d.budget).map(([k, v]) => `${k} \u2264 ${v}`).join(", "))}</dd>` : ""}
1440
+ ${d.expect ? `<dt>Expect</dt><dd>${h(d.expect)}</dd>` : ""}
800
1441
  ${d.failuresBeforeAlert && d.failuresBeforeAlert > 1 ? `<dt>Alert after</dt><dd>${h(d.failuresBeforeAlert)} consecutive failures</dd>` : ""}
801
- ${d.description ? `<dt>Description</dt><dd>${h(d.description)}</dd>` : ""}
802
- ${d.tags?.length ? `<dt>Tags</dt><dd>${d.tags.map((t) => `<span class="pill mutedpill">${h(t)}</span>`).join(" ")}</dd>` : ""}
803
- ${job.open.length ? `<dt>Open</dt><dd>${job.open.map((c) => `<span class="pill warn">${h(c.replace("_", " "))}</span>`).join(" ")}</dd>` : ""}
804
- ${job.consecutiveFailures > 0 ? `<dt>Consecutive failures</dt><dd>${h(job.consecutiveFailures)}</dd>` : ""}
805
- </dl>
806
- </div>
807
- <div class="card">
808
- ${runs.length === 0 ? `<div class="empty">No runs yet.</div>` : `<table>
809
- <thead><tr><th>Status</th><th>Started</th><th>Duration</th><th class="hide-sm">Metrics</th><th class="hide-sm">Trigger</th></tr></thead>
810
- <tbody>${runRows}</tbody></table>`}
811
- </div>
812
- <footer><a href="${h(base)}/api/jobs/${encodeURIComponent(job.name)}">JSON</a></footer>`;
813
- return layout(`${job.name}: CronWatch`, body, { refresh: 60 });
814
- }
815
- function messagePage(title, message, base) {
816
- return layout(title, `<header><h1><a href="${h(base)}/">CronWatch</a></h1></header><div class="card"><div class="empty"><strong>${h(title)}</strong><br>${h(message)}</div></div>`);
1442
+ ${d.tags?.length ? `<dt>Tags</dt><dd>${d.tags.map((t) => h(t)).join(", ")}</dd>` : ""}
1443
+ ${job.open.length ? `<dt>Open</dt><dd>${job.open.map((c) => `<span class="state ${c === "failed" || c === "stuck" ? "bad" : "warn"}">${h(c.replace("_", " "))}</span>`).join("")}</dd>` : ""}
1444
+ ${job.consecutiveFailures > 0 ? `<dt>Failures in a row</dt><dd>${h(job.consecutiveFailures)}</dd>` : ""}
1445
+ </dl>
1446
+ </section>
1447
+ </main>
1448
+ <footer><span>Refreshes every minute. Times are UTC.</span><a href="${h(base)}/api/jobs/${encodeURIComponent(job.name)}">JSON</a></footer>`;
1449
+ return layout(`${job.name}: CronWatch`, body, base, { refresh: 60 });
1450
+ }
1451
+ function messagePage(title, message, base, signIn = false) {
1452
+ const form = signIn ? `<form class="signin" method="get" action="${h(base)}/"><label for="token">Token</label><input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required><button class="primary" type="submit">Sign in</button></form>` : "";
1453
+ return layout(title, `<header class="top">${brand(base)}</header><main class="message"><h1>${h(title)}</h1><p>${h(message)}</p>${form}</main>`, base);
817
1454
  }
818
1455
 
819
1456
  // src/routes/index.ts
820
1457
  var COOKIE = "cronwatch_token";
821
- function cookieValue(token) {
822
- return crypto$1.createHash("sha256").update(`cronwatch-cookie:${token}`).digest("hex");
1458
+ async function cookieValue(token) {
1459
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`cronwatch-cookie:${token}`));
1460
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
823
1461
  }
824
1462
  var DEFAULT_RUNS = 20;
825
1463
  var MAX_RUNS = 500;
826
- var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
1464
+ var BOARD_PAGE_RUNS = 20;
1465
+ async function boardLanes(cw, entries, now) {
1466
+ const from = now - BOARD_BEHIND_MS;
1467
+ return Promise.all(entries.slice(0, BOARD_LANES).map(async ({ job, runs }) => {
1468
+ const short = runs.length >= BOARD_PAGE_RUNS && runs[runs.length - 1].startedAt > from;
1469
+ if (!short) return { job, runs, complete: true };
1470
+ const deeper = await cw.runs(job.name, BOARD_RUNS);
1471
+ return { job, runs: deeper, complete: deeper.length < BOARD_RUNS };
1472
+ }));
1473
+ }
1474
+ var CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; worker-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
1475
+ var ASSET_CSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'";
827
1476
  var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
828
1477
  function developmentToken() {
829
- return crypto$1.randomBytes(32).toString("base64url");
1478
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
1479
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
830
1480
  }
831
1481
  function developmentSignInLine(origin, base, token) {
832
1482
  return `[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: ${origin}${base}/?token=${token}`;
@@ -847,6 +1497,34 @@ function readCookie(request, name) {
847
1497
  }
848
1498
  return null;
849
1499
  }
1500
+ function firstValue(value) {
1501
+ const first = value?.split(",")[0]?.trim();
1502
+ return first ? first : null;
1503
+ }
1504
+ function configuredOrigin(value) {
1505
+ if (value === void 0 || value === "") return null;
1506
+ let url;
1507
+ try {
1508
+ url = new URL(value);
1509
+ } catch {
1510
+ throw new Error(`routes: origin must be an absolute URL such as "https://app.example.com", got ${JSON.stringify(value)}`);
1511
+ }
1512
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`routes: origin must be http or https, got ${JSON.stringify(value)}`);
1513
+ return url.origin;
1514
+ }
1515
+ function forwardedOrigin(request, url) {
1516
+ const proto = firstValue(request.headers.get("x-forwarded-proto"))?.toLowerCase() ?? null;
1517
+ const host = firstValue(request.headers.get("x-forwarded-host"));
1518
+ if (proto === null && host === null) return url.origin;
1519
+ if (proto !== null && proto !== "http" && proto !== "https") return url.origin;
1520
+ try {
1521
+ const built = new URL(`${proto ?? url.protocol.slice(0, -1)}://${host ?? url.host}`);
1522
+ if (built.pathname !== "/" || built.username || built.password || built.search || built.hash) return url.origin;
1523
+ return built.origin;
1524
+ } catch {
1525
+ return url.origin;
1526
+ }
1527
+ }
850
1528
  function stripBase(pathname, base) {
851
1529
  let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
852
1530
  if (path === "") path = "/";
@@ -871,9 +1549,9 @@ async function readBody(request) {
871
1549
  }
872
1550
  return {};
873
1551
  }
874
- function crossSite(request, url) {
1552
+ function crossSite(request, publicOrigin) {
875
1553
  const origin = request.headers.get("origin");
876
- if (origin !== null && origin !== url.origin) return true;
1554
+ if (origin !== null && origin !== publicOrigin) return true;
877
1555
  const site = request.headers.get("sec-fetch-site");
878
1556
  return site !== null && site !== "same-origin" && site !== "none";
879
1557
  }
@@ -890,48 +1568,61 @@ function runsLimit(value) {
890
1568
  }
891
1569
  function createRoutes(cw, options = {}) {
892
1570
  const optedOut = options.token === null;
893
- const configured = optedOut ? null : options.token || process.env.CRONWATCH_TOKEN || null;
1571
+ const configured = optedOut ? null : options.token || readEnv("CRONWATCH_TOKEN") || null;
894
1572
  const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
895
- const developing = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
1573
+ const developing = isDevelopment();
1574
+ const fixedOrigin = configuredOrigin(options.origin);
1575
+ const trustProxy = options.trustProxy === true;
1576
+ const originOf = (request, url) => fixedOrigin ?? (trustProxy ? forwardedOrigin(request, url) : url.origin);
896
1577
  const generated = !configured && !optedOut && developing;
897
1578
  const token = generated ? developmentToken() : configured;
898
1579
  let announced = false;
1580
+ let cookie = null;
1581
+ const expectedCookie = (value) => cookie ??= cookieValue(value);
899
1582
  const serve = async (request, url, path, wantsHtml) => {
900
1583
  const method = request.method.toUpperCase();
1584
+ const publicOrigin = originOf(request, url);
901
1585
  if (generated && !announced) {
902
1586
  announced = true;
903
- console.info(developmentSignInLine(url.origin, base, token));
1587
+ console.info(developmentSignInLine(publicOrigin, base, token));
1588
+ }
1589
+ if (method === "GET" || method === "HEAD") {
1590
+ if (path === "/offline") {
1591
+ return html(messagePage("You are offline", "CronWatch shows live data from your app, so it needs a connection.", base), 200, "no-cache");
1592
+ }
1593
+ const asset = staticAsset(path, base);
1594
+ if (asset) return shell(asset, base);
904
1595
  }
905
1596
  if (!token && !optedOut) {
906
1597
  return wantsHtml ? html(messagePage("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token to cw.routes()), or pass token: null to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503);
907
1598
  }
908
- if (method !== "GET" && method !== "HEAD" && crossSite(request, url)) {
1599
+ if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
909
1600
  return wantsHtml ? html(messagePage("Cross-site request refused", "Changes can only be made from the dashboard itself.", base), 403) : api({ ok: false, error: "Cross-site request refused" }, 403);
910
1601
  }
911
1602
  const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
912
1603
  if (token) {
913
1604
  const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
914
- const cookie = readCookie(request, COOKIE);
1605
+ const sent = readCookie(request, COOKIE);
915
1606
  const isCheck = path === "/api/check";
916
1607
  const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
917
- const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : cookie !== null && constantTimeEqual(cookie, cookieValue(token));
1608
+ const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
918
1609
  if (!cronSecretOk && !tokenOk) {
919
1610
  if (generated) {
920
- return wantsHtml ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401);
1611
+ return wantsHtml ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base, true), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401);
921
1612
  }
922
- return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base), 401) : api({ ok: false, error: "Unauthorized" }, 401);
1613
+ return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base, true), 401) : api({ ok: false, error: "Unauthorized" }, 401);
923
1614
  }
924
1615
  if (query !== null) {
925
1616
  url.searchParams.delete("token");
926
- const secure = url.protocol === "https:" ? "; Secure" : "";
1617
+ const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
927
1618
  return redirect(url.pathname + (url.search || ""), {
928
- "set-cookie": `${COOKIE}=${cookieValue(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
1619
+ "set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
929
1620
  });
930
1621
  }
931
1622
  }
932
1623
  const redirectBack = () => {
933
1624
  const referer = request.headers.get("referer") ?? "";
934
- return redirect(referer.startsWith(url.origin + "/") ? referer : `${base}/`);
1625
+ return redirect(referer.startsWith(publicOrigin + "/") ? referer : `${base}/`);
935
1626
  };
936
1627
  const decoded = path.split("/").filter(Boolean).map(safeDecode);
937
1628
  if (decoded.some((part) => part === null)) {
@@ -939,14 +1630,18 @@ function createRoutes(cw, options = {}) {
939
1630
  }
940
1631
  const parts = decoded;
941
1632
  if (method === "GET" && path === "/") {
942
- const entries = await cw.jobsWithRuns(20);
1633
+ const entries = await cw.jobsWithRuns(BOARD_PAGE_RUNS);
1634
+ const now = cw.now();
943
1635
  const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
944
- return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, cw.now(), base));
1636
+ return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, now, base, null, await boardLanes(cw, entries, now)));
945
1637
  }
946
1638
  if (method === "GET" && parts[0] === "jobs" && parts.length === 2) {
947
1639
  const job = await cw.jobSummary(parts[1]);
948
1640
  if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
949
- return html(jobPage(job, await cw.runs(job.name, 50), cw.now(), base));
1641
+ const now = cw.now();
1642
+ const limit = weekRunsLimit(job, now);
1643
+ const runs = await cw.runs(job.name, limit);
1644
+ return html(jobPage(job, runs, now, base, runs.length < limit));
950
1645
  }
951
1646
  if (method === "POST" && path === "/check") {
952
1647
  await cw.check();
@@ -1044,12 +1739,18 @@ function api(body, status = 200, headers = {}) {
1044
1739
  function redirect(location, headers = {}) {
1045
1740
  return new Response(null, { status: 303, headers: { location, "cache-control": "no-store", ...SECURITY_HEADERS, ...headers } });
1046
1741
  }
1047
- function html(body, status = 200) {
1742
+ function shell(asset, base) {
1743
+ const headers = { "content-type": asset.type, "cache-control": asset.cache, ...SECURITY_HEADERS };
1744
+ if (asset.type === "image/svg+xml") headers["content-security-policy"] = ASSET_CSP;
1745
+ if (asset.worker) headers["service-worker-allowed"] = `${base}/`;
1746
+ return new Response(asset.body, { status: 200, headers });
1747
+ }
1748
+ function html(body, status = 200, cache2 = "no-store") {
1048
1749
  return new Response(body, {
1049
1750
  status,
1050
1751
  headers: {
1051
1752
  "content-type": "text/html; charset=utf-8",
1052
- "cache-control": "no-store",
1753
+ "cache-control": cache2,
1053
1754
  "content-security-policy": CSP,
1054
1755
  "x-frame-options": "DENY",
1055
1756
  ...SECURITY_HEADERS
@@ -1092,6 +1793,7 @@ function memory() {
1092
1793
  }
1093
1794
  },
1094
1795
  async insertRun(run) {
1796
+ if (runs.has(run.id)) throw new Error(`run ${run.id} already exists`);
1095
1797
  runs.set(run.id, clone(run));
1096
1798
  order.set(run.id, ++seq);
1097
1799
  },
@@ -1101,6 +1803,13 @@ function memory() {
1101
1803
  const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1102
1804
  runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1103
1805
  },
1806
+ async updateRunIf(run, fromStatuses) {
1807
+ const existing = runs.get(run.id);
1808
+ if (!existing || !fromStatuses.includes(existing.status)) return false;
1809
+ const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1810
+ runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1811
+ return true;
1812
+ },
1104
1813
  async getRun(id) {
1105
1814
  const r = runs.get(id);
1106
1815
  return r ? clone(r) : null;
@@ -1196,6 +1905,7 @@ var CronWatch = class {
1196
1905
  store;
1197
1906
  alerts;
1198
1907
  triage;
1908
+ sources;
1199
1909
  /** The secret handler() requests must carry, or null when none is set. */
1200
1910
  cronSecret;
1201
1911
  retentionMs;
@@ -1211,6 +1921,8 @@ var CronWatch = class {
1211
1921
  synced = /* @__PURE__ */ new Set();
1212
1922
  /** The tail of each job's queue of state updates. See serial(). */
1213
1923
  queues = /* @__PURE__ */ new Map();
1924
+ /** start() calls with an id still in flight, so two at once in this process record one run. */
1925
+ starting = /* @__PURE__ */ new Map();
1214
1926
  ready = null;
1215
1927
  checking = null;
1216
1928
  lastPruneAt = 0;
@@ -1223,7 +1935,8 @@ var CronWatch = class {
1223
1935
  this.store = options.store ?? (this.usingDefaultStore = true, memory());
1224
1936
  this.alerts = options.alerts ?? [consoleChannel()];
1225
1937
  this.triage = options.triage;
1226
- const secret = options.cronSecret === void 0 ? process.env.CRON_SECRET : options.cronSecret;
1938
+ this.sources = options.sources ?? [];
1939
+ const secret = options.cronSecret === void 0 ? readEnv("CRON_SECRET") : options.cronSecret;
1227
1940
  this.cronSecret = secret ? secret : null;
1228
1941
  this.secretOptOut = options.cronSecret === null;
1229
1942
  this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
@@ -1267,6 +1980,12 @@ var CronWatch = class {
1267
1980
  definedJobs() {
1268
1981
  return [...this.definitions.values()];
1269
1982
  }
1983
+ /** A handle on a run started elsewhere, as job(name).resume(runId). The job must be declared in this process. */
1984
+ resumeRun(name, runId) {
1985
+ const definition = this.definitions.get(name);
1986
+ if (!definition) return Promise.reject(new Error(`resumeRun: job "${name}" is not declared; call job() first`));
1987
+ return this.resumeHandle(definition, runId);
1988
+ }
1270
1989
  handle(definition) {
1271
1990
  const self = this;
1272
1991
  return {
@@ -1305,6 +2024,12 @@ var CronWatch = class {
1305
2024
  };
1306
2025
  return json(body, outcome.run.status === "ok" ? 200 : 500);
1307
2026
  };
2027
+ },
2028
+ start(options) {
2029
+ return self.startRun(definition, options ?? {});
2030
+ },
2031
+ resume(runId) {
2032
+ return self.resumeHandle(definition, runId);
1308
2033
  }
1309
2034
  };
1310
2035
  }
@@ -1324,7 +2049,7 @@ var CronWatch = class {
1324
2049
  if (!this.ready) {
1325
2050
  this.ready = (async () => {
1326
2051
  if (this.store.init) await this.store.init();
1327
- if (this.usingDefaultStore && process.env.NODE_ENV === "production") {
2052
+ if (this.usingDefaultStore && readEnv("NODE_ENV") === "production") {
1328
2053
  console.warn("[cronwatch] using the in-memory store: runs and state are lost on restart. Pass a store from @cronwatch/sdk/sqlite or @cronwatch/sdk/postgres.");
1329
2054
  }
1330
2055
  })().catch((error) => {
@@ -1441,6 +2166,21 @@ var CronWatch = class {
1441
2166
  run.durationMs = Math.max(0, finishedAt - startedAt);
1442
2167
  run.metrics = recorder.metrics();
1443
2168
  run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2169
+ this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
2170
+ await started;
2171
+ try {
2172
+ const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
2173
+ if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
2174
+ } catch (e) {
2175
+ this.onError(e, `recording ${name}`);
2176
+ }
2177
+ return { run, result, error, threw };
2178
+ }
2179
+ /**
2180
+ * Sets a finished run's status and error from how it ended, then redacts
2181
+ * its output and error. Shared by execute() and RunHandle.finish().
2182
+ */
2183
+ conclude(definition, run, result, error, threw, expectText) {
1444
2184
  if (threw) {
1445
2185
  run.status = "failed";
1446
2186
  run.error = errorMessage(error);
@@ -1448,7 +2188,6 @@ var CronWatch = class {
1448
2188
  run.status = "failed";
1449
2189
  run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
1450
2190
  } else {
1451
- const expectText = recorder.expectText() ?? (typeof result === "string" ? result : null);
1452
2191
  const unmet = checkExpectation(definition.expect, expectText);
1453
2192
  if (unmet) {
1454
2193
  run.status = "failed";
@@ -1459,44 +2198,336 @@ var CronWatch = class {
1459
2198
  }
1460
2199
  if (run.output !== null) run.output = stripNul(this.redact(run.output));
1461
2200
  if (run.error !== null) run.error = stripNul(this.redact(run.error));
1462
- await started;
2201
+ }
2202
+ /**
2203
+ * Writes a finished run and evaluates it. `recorded` says whether its
2204
+ * start was written; if not, it is inserted now. Returns why nothing was
2205
+ * recorded (another process finished the run first, say), or null. Throws
2206
+ * when the store does, so a handle can be finished again. Shared by
2207
+ * execute() and RunHandle.finish().
2208
+ */
2209
+ async recordFinish(definition, run, recorded, finishedAt) {
1463
2210
  if (!recorded) {
2211
+ await this.sync(definition);
1464
2212
  try {
1465
- await this.sync(definition);
1466
2213
  await this.store.insertRun(run);
1467
- recorded = true;
2214
+ await this.finishRun(toStored(definition), run, finishedAt);
2215
+ return null;
1468
2216
  } catch (e) {
1469
- this.onError(e, `recording ${name}`);
2217
+ const stored = await this.store.getRun(run.id).catch(() => null);
2218
+ if (!stored) throw e;
2219
+ if (stored.job !== run.job) return `belongs to job "${stored.job}"`;
1470
2220
  }
1471
- if (recorded) await this.finishRun(toStored(definition), run, finishedAt, false);
1472
- } else if (await this.markedTimedOut(run)) {
2221
+ }
2222
+ const claim = await this.claimFinish(run);
2223
+ if ("ignored" in claim) return claim.ignored;
2224
+ if (!claim.lateAfterTimeout || run.status === "ok") await this.finishRun(toStored(definition), run, finishedAt);
2225
+ return null;
2226
+ }
2227
+ /** A conditional write (Store.updateRunIf), or for a store without one, a read then a plain write. */
2228
+ async writeRunIf(run, fromStatuses) {
2229
+ if (typeof this.store.updateRunIf === "function") return this.store.updateRunIf(run, fromStatuses);
2230
+ const stored = await this.store.getRun(run.id);
2231
+ if (!stored || !fromStatuses.includes(stored.status)) return false;
2232
+ await this.store.updateRun(run);
2233
+ return true;
2234
+ }
2235
+ /**
2236
+ * Writes a finished run over its stored row, only while that row is still
2237
+ * running, or else still marked timeout by a check. Only the process whose
2238
+ * write lands goes on to evaluate the run; for the others it returns why
2239
+ * nothing was written. `lateAfterTimeout` means a check already counted
2240
+ * the run as a stuck failure: a late failure must not count twice, while
2241
+ * a late success still closes stuck and recovers. Throws when the store does.
2242
+ */
2243
+ async claimFinish(run) {
2244
+ if (await this.writeRunIf(run, ["running"])) return { lateAfterTimeout: false };
2245
+ if (await this.writeRunIf(run, ["timeout"])) return { lateAfterTimeout: true };
2246
+ const stored = await this.store.getRun(run.id);
2247
+ return { ignored: stored ? `was already finished as ${stored.status}` : "was not found" };
2248
+ }
2249
+ /** job.start(): records a running run and returns a handle to finish it. See JobHandle.start. */
2250
+ async startRun(definition, options) {
2251
+ const id = options.id;
2252
+ if (id === void 0) return this.recordStart(definition, options.trigger);
2253
+ checkRunId(definition.name, id, "start");
2254
+ const key = `${definition.name}
2255
+ ${id}`;
2256
+ const inFlight = this.starting.get(key);
2257
+ if (inFlight) return inFlight;
2258
+ const started = this.recordStart(definition, options.trigger, id).finally(() => {
2259
+ if (this.starting.get(key) === started) this.starting.delete(key);
2260
+ });
2261
+ this.starting.set(key, started);
2262
+ return started;
2263
+ }
2264
+ /**
2265
+ * The start of execute() without the function: the run is inserted and
2266
+ * missed and stuck close (onRunStart). A store that fails is reported and
2267
+ * the handle inserts the finished run instead, as execute() does.
2268
+ */
2269
+ async recordStart(definition, trigger = "start", id) {
2270
+ const name = definition.name;
2271
+ if (id !== void 0) {
2272
+ let stored = null;
1473
2273
  try {
1474
- await this.store.updateRun(run);
2274
+ await this.ensureReady();
2275
+ stored = await this.store.getRun(id);
1475
2276
  } catch (e) {
1476
2277
  this.onError(e, `recording ${name}`);
1477
2278
  }
1478
- } else {
1479
- await this.finishRun(toStored(definition), run, finishedAt, true);
2279
+ if (stored) return this.existingHandle(definition, stored);
1480
2280
  }
1481
- return { run, result, error, threw };
2281
+ const run = {
2282
+ id: id ?? crypto.randomUUID(),
2283
+ job: name,
2284
+ status: "running",
2285
+ startedAt: this.now(),
2286
+ finishedAt: null,
2287
+ durationMs: null,
2288
+ error: null,
2289
+ output: null,
2290
+ metrics: {},
2291
+ trigger
2292
+ };
2293
+ let recorded = false;
2294
+ try {
2295
+ await this.sync(definition);
2296
+ await this.store.insertRun({ ...run });
2297
+ recorded = true;
2298
+ } catch (e) {
2299
+ const stored = id === void 0 ? null : await this.store.getRun(id).catch(() => null);
2300
+ if (stored) return this.existingHandle(definition, stored);
2301
+ this.onError(e, `recording ${name}`);
2302
+ }
2303
+ if (recorded) {
2304
+ await this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
2305
+ }
2306
+ return this.runHandle(definition, run.id, run, recorded, null);
1482
2307
  }
1483
- /** Whether a check already marked this run as timed out, for a failure that finished late. */
1484
- async markedTimedOut(run) {
1485
- if (run.status === "ok") return false;
2308
+ /** job.resume() and cw.resumeRun(). A store that cannot be read is reported, and finish() reads it again. */
2309
+ async resumeHandle(definition, runId) {
2310
+ checkRunId(definition.name, runId, "resume");
2311
+ let stored;
1486
2312
  try {
1487
- return (await this.store.getRun(run.id))?.status === "timeout";
1488
- } catch {
1489
- return false;
2313
+ await this.ensureReady();
2314
+ stored = await this.store.getRun(runId);
2315
+ } catch (e) {
2316
+ this.onError(e, `resuming ${definition.name}`);
2317
+ return this.runHandle(definition, runId, null, true, null);
2318
+ }
2319
+ if (!stored) return this.runHandle(definition, runId, null, true, "was not found");
2320
+ return this.existingHandle(definition, stored);
2321
+ }
2322
+ /** A handle on a stored run. One still running, or marked timeout by a check, can be finished. */
2323
+ existingHandle(definition, stored) {
2324
+ if (stored.job !== definition.name) {
2325
+ throw new Error(`run "${stored.id}" belongs to job "${stored.job}", not "${definition.name}"`);
2326
+ }
2327
+ const finished = stored.status === "ok" || stored.status === "failed";
2328
+ return this.runHandle(definition, stored.id, stored, true, finished ? `already finished as ${stored.status}` : null);
2329
+ }
2330
+ /**
2331
+ * The handle itself. `base` is the run as last known here, `recorded`
2332
+ * whether its start is in the store, and `inactive` why finish() has
2333
+ * nothing to do, or null. Lines and metrics wait in the handle until
2334
+ * flush() or finish() merges them onto a fresh read of the stored run.
2335
+ */
2336
+ runHandle(definition, id, base, recorded, inactive) {
2337
+ const self = this;
2338
+ const name = definition.name;
2339
+ const fresh = () => createRecorder({ id, job: name, startedAt: base?.startedAt ?? 0 });
2340
+ let recorder = fresh();
2341
+ let finished = inactive !== null;
2342
+ let finishCalled = false;
2343
+ let head = null;
2344
+ let queue = Promise.resolve();
2345
+ const inTurn = (fn) => {
2346
+ const result = queue.then(fn);
2347
+ queue = result.catch(() => {
2348
+ });
2349
+ return result;
2350
+ };
2351
+ const ignored = (why) => self.report(new Error(`run ${id} of ${name} ${why}; ignored`), `finishing ${name}`);
2352
+ const finish = (outcome) => {
2353
+ if (finishCalled) {
2354
+ ignored("was already finished by this handle");
2355
+ return Promise.resolve(null);
2356
+ }
2357
+ finishCalled = true;
2358
+ const wasInactive = finished;
2359
+ finished = true;
2360
+ const retryable = (error) => {
2361
+ finishCalled = false;
2362
+ finished = false;
2363
+ self.report(error, `finishing ${name}`);
2364
+ return null;
2365
+ };
2366
+ return inTurn(async () => {
2367
+ if (wasInactive) {
2368
+ ignored(inactive);
2369
+ return null;
2370
+ }
2371
+ let from = base;
2372
+ if (recorded) {
2373
+ try {
2374
+ from = await self.store.getRun(id) ?? base;
2375
+ } catch (e) {
2376
+ return retryable(e);
2377
+ }
2378
+ }
2379
+ if (!from) {
2380
+ ignored("was not found");
2381
+ return null;
2382
+ }
2383
+ if (from.job !== name) {
2384
+ ignored(`belongs to job "${from.job}"`);
2385
+ return null;
2386
+ }
2387
+ if (from.status === "ok" || from.status === "failed") {
2388
+ ignored(`was already finished as ${from.status}`);
2389
+ return null;
2390
+ }
2391
+ const failed = typeof outcome === "object" && outcome !== null && "error" in outcome;
2392
+ const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
2393
+ const error = failed ? outcome.error : void 0;
2394
+ const finishedAt = self.now();
2395
+ const added = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2396
+ const run = {
2397
+ ...from,
2398
+ status: "running",
2399
+ finishedAt,
2400
+ durationMs: Math.max(0, finishedAt - from.startedAt),
2401
+ error: null,
2402
+ output: joinOutput(from.output, added),
2403
+ metrics: { ...from.metrics, ...recorder.metrics() }
2404
+ };
2405
+ const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
2406
+ self.conclude(definition, run, result, error, failed, expectText);
2407
+ let why;
2408
+ try {
2409
+ why = await self.recordFinish(definition, run, recorded, finishedAt);
2410
+ } catch (e) {
2411
+ return retryable(e);
2412
+ }
2413
+ if (why) {
2414
+ ignored(why);
2415
+ return null;
2416
+ }
2417
+ return run;
2418
+ });
2419
+ };
2420
+ return {
2421
+ id,
2422
+ job: name,
2423
+ startedAt: base?.startedAt ?? null,
2424
+ get active() {
2425
+ return !finished;
2426
+ },
2427
+ log: (...parts) => recorder.context.log(...parts),
2428
+ metric: (metric, value) => recorder.context.metric(metric, value),
2429
+ metrics: (values) => recorder.context.metrics(values),
2430
+ flush: () => inTurn(async () => {
2431
+ if (finished || !recorded) return;
2432
+ const lines = recorder.output();
2433
+ const metrics = recorder.metrics();
2434
+ if (lines === null && Object.keys(metrics).length === 0) return;
2435
+ const taken = recorder;
2436
+ recorder = fresh();
2437
+ const putBack = () => {
2438
+ const later = recorder;
2439
+ recorder = fresh();
2440
+ for (const text of [taken.expectText(), later.expectText()]) if (text !== null) recorder.context.log(text);
2441
+ recorder.context.metrics({ ...taken.metrics(), ...later.metrics() });
2442
+ };
2443
+ try {
2444
+ const stored = await self.store.getRun(id);
2445
+ if (!stored || stored.status !== "running") return putBack();
2446
+ if (stored.job !== name) {
2447
+ putBack();
2448
+ self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
2449
+ return;
2450
+ }
2451
+ const output = lines === null ? stored.output : joinOutput(stored.output, stripNul(self.redact(lines)));
2452
+ if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
2453
+ const text = taken.expectText();
2454
+ if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
2455
+ } catch (e) {
2456
+ putBack();
2457
+ self.report(e, `flushing ${name}`);
2458
+ }
2459
+ }),
2460
+ finish,
2461
+ fail: (error) => finish({ error })
2462
+ };
2463
+ }
2464
+ /**
2465
+ * Record a run that happened outside this process, for a Source. Its job
2466
+ * must be declared with job() first. Runs are keyed by id: a new one is
2467
+ * inserted, a stored one still running (or marked timeout by a check) is
2468
+ * finished when this one is not running, and anything else is left alone,
2469
+ * so recording the same run twice changes nothing. Finishing is
2470
+ * conditional (see Store.updateRunIf): when two processes record the same
2471
+ * finish, only the one whose write lands evaluates it, and the other
2472
+ * reports it as already finished. A stored run of another job is left
2473
+ * alone and reported. A finished run is judged as if it had been wrapped
2474
+ * here (expect, failures, duration, budgets) and its output and error are
2475
+ * redacted the same way; one finishing after a check marked it timeout is
2476
+ * judged only when it succeeded, as RunHandle.finish() does. Returns the
2477
+ * alerts it sent.
2478
+ */
2479
+ async recordRun(input, options = {}) {
2480
+ const declared = this.definitions.get(input.job);
2481
+ if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
2482
+ await this.sync(declared);
2483
+ const run = { ...input, metrics: { ...input.metrics } };
2484
+ if (run.status === "ok") {
2485
+ const unmet = checkExpectation(declared.expect, run.output);
2486
+ if (unmet) {
2487
+ run.status = "failed";
2488
+ run.error = unmet;
2489
+ }
2490
+ }
2491
+ if (run.output !== null) run.output = stripNul(this.redact(capOutput(run.output)));
2492
+ if (run.error !== null) run.error = stripNul(this.redact(capOutput(run.error)));
2493
+ const evaluate = options.evaluate !== false;
2494
+ const definition = toStored(declared);
2495
+ const stored = await this.store.getRun(run.id);
2496
+ if (stored) return this.recordOver(definition, stored, run, evaluate);
2497
+ try {
2498
+ await this.store.insertRun(run);
2499
+ } catch (e) {
2500
+ const again = await this.store.getRun(run.id).catch(() => null);
2501
+ if (again) return this.recordOver(definition, again, run, evaluate);
2502
+ throw e;
2503
+ }
2504
+ if (!evaluate) return [];
2505
+ await this.updateState(run.job, (before) => ({ state: onRunStart(before), result: void 0 }));
2506
+ if (run.status === "running") return [];
2507
+ return this.finishRun(definition, run, this.now());
2508
+ }
2509
+ /** recordRun() for a run already stored. */
2510
+ async recordOver(definition, stored, run, evaluate) {
2511
+ if (stored.job !== run.job) {
2512
+ this.report(new Error(`run ${run.id} of ${run.job} belongs to job "${stored.job}"; ignored`), `recording ${run.job}`);
2513
+ return [];
2514
+ }
2515
+ if (stored.status !== "running" && stored.status !== "timeout" || run.status === "running") return [];
2516
+ const claim = await this.claimFinish(run);
2517
+ if ("ignored" in claim) {
2518
+ this.report(new Error(`run ${run.id} of ${run.job} ${claim.ignored}; ignored`), `recording ${run.job}`);
2519
+ return [];
1490
2520
  }
2521
+ if (!evaluate || claim.lateAfterTimeout && run.status !== "ok") return [];
2522
+ return this.finishRun(definition, run, this.now());
1491
2523
  }
1492
2524
  /**
1493
- * Record a finished run (ok, failed, or timed out by a check), evaluate it
1494
- * against the job's state and send what that produces. Never throws.
2525
+ * Evaluate a finished run (ok, failed, or timed out by a check), already
2526
+ * written, against the job's state and send what that produces. Never throws.
1495
2527
  */
1496
- async finishRun(definition, run, now, write) {
2528
+ async finishRun(definition, run, now) {
1497
2529
  let drafts;
1498
2530
  try {
1499
- if (write) await this.store.updateRun(run);
1500
2531
  let history = null;
1501
2532
  ({ result: drafts } = await this.updateState(run.job, async (previous) => {
1502
2533
  history ??= await this.history(run);
@@ -1537,9 +2568,16 @@ var CronWatch = class {
1537
2568
  }
1538
2569
  async runCheck() {
1539
2570
  await this.ensureReady();
2571
+ const alerts = [];
2572
+ for (const source of this.sources) {
2573
+ try {
2574
+ alerts.push(...await source.sync(this) ?? []);
2575
+ } catch (e) {
2576
+ this.onError(e, `source ${source.name}`);
2577
+ }
2578
+ }
1540
2579
  for (const definition of this.definitions.values()) await this.sync(definition);
1541
2580
  const now = this.now();
1542
- const alerts = [];
1543
2581
  for (const run of await this.store.runningRuns()) {
1544
2582
  try {
1545
2583
  const declared = this.definitions.get(run.job);
@@ -1550,7 +2588,8 @@ var CronWatch = class {
1550
2588
  run.finishedAt = now;
1551
2589
  run.durationMs = now - run.startedAt;
1552
2590
  run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
1553
- alerts.push(...await this.finishRun(definition, run, now, true));
2591
+ if (!await this.writeRunIf(run, ["running"])) continue;
2592
+ alerts.push(...await this.finishRun(definition, run, now));
1554
2593
  } catch (e) {
1555
2594
  this.onError(e, `checking ${run.job}`);
1556
2595
  }
@@ -1668,7 +2707,11 @@ var CronWatch = class {
1668
2707
  routes(options) {
1669
2708
  return createRoutes(this, options);
1670
2709
  }
1671
- /** Check on an interval, for long-running servers. Default every minute. */
2710
+ /**
2711
+ * Check on an interval, for long-running servers. Default every minute.
2712
+ * Not for serverless functions or Cloudflare Workers, where nothing runs
2713
+ * between requests: call check() from a cron there instead.
2714
+ */
1672
2715
  start(every = "1m") {
1673
2716
  if (this.timer) return;
1674
2717
  const ms = Math.max(5e3, parseDuration(every, "check interval"));
@@ -1777,7 +2820,7 @@ var CronWatch = class {
1777
2820
  const results = await Promise.all(
1778
2821
  this.alerts.map(async (channel) => {
1779
2822
  try {
1780
- await withTimeout(channel.send(alert), CHANNEL_TIMEOUT_MS);
2823
+ await withTimeout(channel.send(alert, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
1781
2824
  return true;
1782
2825
  } catch (e) {
1783
2826
  this.onError(e, `alert channel ${channel.name}`);
@@ -1801,8 +2844,24 @@ var CronWatch = class {
1801
2844
  }
1802
2845
  }
1803
2846
  };
1804
- function isDevelopment() {
1805
- return process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
2847
+ var RESERVED_RUN_ID_PREFIX = "pgcron:";
2848
+ function checkRunId(job, id, method) {
2849
+ if (typeof id !== "string" || id.length === 0 || id.length > 200) {
2850
+ throw new Error(`job "${job}": ${method}() needs a run id of 1 to 200 characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
2851
+ }
2852
+ if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
2853
+ throw new Error(`job "${job}": ${method}() cannot take a run id starting with "${RESERVED_RUN_ID_PREFIX}", which the pg_cron source uses for its runs`);
2854
+ }
2855
+ }
2856
+ function joinLines(before, after) {
2857
+ if (before === null || before === "") return after;
2858
+ if (after === null) return before;
2859
+ return `${before}
2860
+ ${after}`;
2861
+ }
2862
+ function joinOutput(before, after) {
2863
+ const joined = joinLines(before, after);
2864
+ return joined === null ? null : capOutput(joined);
1806
2865
  }
1807
2866
  function clampLimit(limit, fallback, min) {
1808
2867
  const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;