@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.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Cron } from 'croner';
2
- import { randomBytes, createHash } from 'crypto';
3
2
 
4
3
  // src/duration.ts
5
4
  var UNIT_MS = {
@@ -53,6 +52,16 @@ function formatRelative(at, now) {
53
52
  if (abs < 5e3) return text;
54
53
  return diff < 0 ? `${text} ago` : `in ${text}`;
55
54
  }
55
+
56
+ // src/env.ts
57
+ function readEnv(name) {
58
+ return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
59
+ }
60
+ function isDevelopment() {
61
+ if (typeof process === "undefined" || !process.env) return false;
62
+ const mode = process.env.NODE_ENV;
63
+ return mode === "development" || mode === "test";
64
+ }
56
65
  var cache = /* @__PURE__ */ new Map();
57
66
  var crons = /* @__PURE__ */ new WeakMap();
58
67
  var EARLY_SLACK_MS = 6e4;
@@ -93,6 +102,28 @@ function fireAfter(parsed, from) {
93
102
  }
94
103
  return null;
95
104
  }
105
+ function firesBetween(parsed, from, to, limit) {
106
+ const cron = crons.get(parsed);
107
+ if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
108
+ const out = [];
109
+ let probe = from;
110
+ let last = from;
111
+ for (let guard = 0; guard < 1e3; guard++) {
112
+ const batch = cron.nextRuns(Math.min(limit + 1 - out.length, 24), new Date(probe));
113
+ if (batch.length === 0) return out;
114
+ for (const date of batch) {
115
+ const t = date.getTime();
116
+ if (t <= last) continue;
117
+ if (t > to) return out;
118
+ out.push(t);
119
+ last = t;
120
+ if (out.length > limit) return null;
121
+ }
122
+ const end = batch[batch.length - 1].getTime();
123
+ probe = end > probe ? end : probe + 36e5;
124
+ }
125
+ return out;
126
+ }
96
127
  function nextFire(parsed, from, lastRunAt) {
97
128
  if (parsed.kind === "interval") {
98
129
  const base = lastRunAt ?? from;
@@ -305,7 +336,15 @@ function onRunFinish(def, run, state, history, now) {
305
336
  function onCheck(def, stored, lastRun, state, now) {
306
337
  const next = cloneState(state);
307
338
  const alerts = [];
308
- if (!def.schedule) return { state: next, alerts, nextExpectedAt: null, dueAt: null };
339
+ if (!def.schedule) {
340
+ const since = next.open.missed;
341
+ if (since !== void 0) {
342
+ delete next.open.missed;
343
+ next.pendingRecovery = (next.pendingRecovery ?? []).filter((c) => c !== "missed");
344
+ alerts.push({ type: "recovered", run: lastRun, details: { after: ["missed"], reason: "unscheduled", since } });
345
+ }
346
+ return { state: next, alerts, nextExpectedAt: null, dueAt: null };
347
+ }
309
348
  const parsed = parseSchedule(def.schedule, def.timezone);
310
349
  const grace = graceMs(def);
311
350
  const lastRunAt = lastRun?.startedAt ?? null;
@@ -455,6 +494,12 @@ ${out}`);
455
494
  break;
456
495
  }
457
496
  case "recovered": {
497
+ if (draft.details.reason === "unscheduled") {
498
+ title = `${name} is no longer scheduled`;
499
+ const since = draft.details.since;
500
+ lines.push(`${since === void 0 ? "" : `Missed since ${when(since, now)}. `}It has no schedule now, so nothing is due; the missed alert is closed.`);
501
+ break;
502
+ }
458
503
  title = `${name} recovered`;
459
504
  const after = draft.details.after.map((c) => c.replace("_", " ")).join(", ");
460
505
  lines.push(`A run ${run ? when(run.startedAt, now) : "just now"} succeeded${after ? ` after: ${after}` : ""}.`);
@@ -640,191 +685,796 @@ function checkExpectation(expect, output) {
640
685
  return ok ? null : "Output did not pass the expect() check";
641
686
  }
642
687
 
643
- // src/routes/html.ts
688
+ // src/routes/escape.ts
644
689
  function escapeHtml(value) {
645
690
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
646
691
  }
692
+
693
+ // src/routes/icons.ts
694
+ 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>';
695
+ 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>';
696
+ var ICON_192_PNG = "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";
697
+ var ICON_512_PNG = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAMAAADDpiTIAAAAk1BMVEUUFBcVFRkYGBwbGx8cHCAfHyMgICUiIiYjIyglJSkmJisnJywpKS4qKi8aGh0eHiIXFxopKS4nJywmJisjIyggICUfHyMcHCAaGh0YGBwXFxoVFRkUFBciIiYeHiIbGx8lJSkiIiUwMDI+PkBMTE5oaGmEhISRkZKtra27u7vJycjX19bl5ePz8/FaWlufn592dnbWPgTCAAAAEXRSTlMAEDBQYICPn6+/z9/v/0BwIImndBAAAB6HSURBVHja7Z3nets4s4BRWSTbmyPl/q8w1n4rW2IFcB47jiM2EKREEmXmXxwXYebFNDSMVhZ8IwgjEIQUUjeytj3W+1OEYEIJ2HyEBimkktIzAF5wTikYd4IIkaizFwBgQikDg86SWgipnAbgJWcw8e90BXVydhMATBmDgP+QtKCuhXIMgB+qBL//0GgQ4f8t8ouX8NCYxVgRMNpDSyilKFJOeICXIgJ7LSRlfLYcAPxSQda3aE7Iz8peAAiHyb+CG6iknQBQzsE6q0hVCfsAYNFU389gLeCrzEOqnhoJytouAMzNz8n3UhDIDQRfIquVEcDrmT/aYVxBdTgmkit1LVdD4AEA0Gi058P3GBpDk6SO1GXUGdSl2B4AEvEx45cU5v0sXyCiMQiqUm4LAOax9v+PV1gGvq/uF7tX7TcUldoQABbr5vZR1jD1H+AIGNExIIt6KwC03h+svxoDd8WBOwDgyfB/UdgH8OhYUIvhfCCv1gfgpRpM649KgMEWEIoH3UDN564SzZ2onA05+CRBCoy1hCjJyUC8J0jJNT0AiQemP+McOnyLQlBVAwzUhVwNAJYOuIUnCSZaXMj7QMTP6pUAiPsXfdkzmH8lBN76TV0WawBAkv68IQXnv2IgyPpLhVwuDsCA+084VP2rdgaq/DFhYGoVwHvtfyQRTP9VBTP20ucF+NRqYCIAcV/rnycYpv/6iYCkqMfYDIvlAPiBeW/hD12/TYQy3OPwKX3KF8oBXkTPRI84LPRvJ3XVs3VE0vMiHoD2pflJAt5/yzjAe5wARhNOkpkDwHZ9KSHsA99YGOvJBLj5BQPGIeCfnl/5Q0Lub0FTgPScGiT/PdgD8J5v3MF1H1ZUhIh2e8PKtBw0BKBn7T9KIfuzpRzgqFP7MUMC8Fz7/4A1X6vcQDcMmO0SoTPtn0Ltb1k9UM/zAXSe/Q9Q/NkWBvbZLAIMAPiHdqs/yP7sKwa69SBLiwcAwDq53hGD/7cxCNDnthMwuHBw1JS00/+JofqzNBGUnWKAj/YEx2z5oiD9d7oYwCPrAiPJ3I9OfZmA/W1OBDr5uvhxVwjoRPskBi3bLJ0VYlzVdwAQc7C/6wQQ/Q4RLQCdo79gfwcJoNp2gA6Azv5PsL+TBDBdMaipAsge8n9PaoGLnFMFtBPKGOzvSi0Qj5jSKAS0E8AjtP/diQJPmWkiOAgAa1HD4V43h6RuJX50MA0YmtaknQBG4AAcEtLeq5mSiR6gvdn3APPfLQLaq8NDNwsMANC+9TmF+e9aItiyOBnoBvRP7PYSEBSAHhSD/ctC/TO7tZssAvu76AMirVF1ALRPe8HpDyelZTbGTXMA0toDsoP9324mgq3zAqxWZgDEzS/+gATQUaFp85xw32HiHuO2XAWHm3+cFcm1ob0fANxqJMPdHw4XAq00IMYGIaB1AXACGaDLaUDT6+PuHa4dAFo94CgBLbosrGnybh5IRmoHeAjMceEjFX3bA9AEAoDPQYC2DwoQrQOAhyDdl1ZOF+lDQOscGFz+6UElwHUWbgPQ5OMIGYAPWcBR5wKaALTe+chBez5I04yUaQBo0pHAGoAfpWCiMTLROAAIAF6Wgk0XQIbZgF1A3pSC6bALuDVy841HBg7AHxfQmPQNO5NBT/EMJaA/peDzYEi4AYA0AIBVYJ+kuS58+7gHGXIAT6A1n+RpyAX8BaC5dszAAfjlAhpZwM0ej78AvEAJGE4p+NIDQAUAhAPAX2N/VwQvjaQfmoDeFQKNZWGSFG0P0LxTEuzvnTRNWrRDQDMFPMJVoN4JPfamgaQdCj4FjoJ5KE2j0hYAzUaBAHX5J4L3JYVfAPxgfXSA+BUEGinBjwYAClLA0NJA1aCC3C4KHUFXXgppXB31dYXsb8PjBhzQBfZUGoZl+AaAZtCvQVV+St2TEpBudIALAb2NAcduStADAESAQGLAXwCa6wAQAcKIAfjlOxCoWw9whDagt4IbdUAh/niARgS4gp78lWsnBpDOuzDQBvRYmpbGXwA0sn4OAPgMQGM94I/xGybfg5Z8ln3bH3QAKEFJPkvZBwCsBAaaBLDfADS2A3NoA3otzdM/L58AYEgBQk0C8CcAeetLID5Lw8D5JwCQA4acBZKevADEY2EdADRXRIB4KK3rIVoA7EBBvsuuCQBrZgU4nKVAjr+HrpCqwswCMWJND+B5EcAJ/pbWf6lvkVU4AHx4gEYOWPnaB4qe8rQkmtHdECFllCXvvtZDVTMLxOjpFol/PBxx8qQqMt21Kcnxu49XZf53O8h31vCF3Lt5X1CKxKwFDkwlSlMhYt98Aa9u/R5rhQSPxsl2NVH31rWUqn3KrrVHeUEz6WPYyxwwIYwi+SCeiUyQqKUv4QB7D8ABi4cPhFKUUHUCAFywvlKLjANL9MMHBnwGINkvZf0bBi45AGClxJQTufgIsEzjShQAgG2SstUWMkmMojoDAGyq99nKW9kYi6q6BABsifwbnGclcfzsZjbQtDi9fRqYubgd4EDjjZYwFWdPLkYCcTtfGh7AQQdwqNSG/WuunvnJaReA3d4CdqjVxgNg6oU53Rpw2QMcKmXBMRbqnBfwxAMkXFny4Zl6rpxtDrkKQPxk0002jO3fHe0NOboAvN9ZdpOR3O3BA6wmaWQhtxErMwBgFe8f2fmhScLKAgBYXHaxvcpk9AoALJz7R1ZfYBCzMgcAlkz+bG9W05ReAADbp79SUsW5+jgldfk4MH9FOCkwwQ9phEXUKSfQeFU2Sv2O/kpK+WH8oS2+/AMCQsi9IBR2ZwJZ6aQHuC/5l0KOH/b5A0b0lBN6R6UZU3fKAWcASJPZP1rLz8Md5tOyPP0+VELmaocx4kpPwJUQcJzZ+RMifpt/qIM/f5wsmtcVeHUjBLgBQBzPMkMlxP3btiJKZ+04EEXhAgBOhIA0nnG281Fb+MuZR01o6kQYcAGAGdl/JR6q/BNC6WQ/gBN8BQC2CP8iOj9+w26GopdyYiCK01cA4O4QHE+0P1lqr275a/L+Y/lclADAXZLEk+pxuexu/TyfeAKBEZIDAHfIYdKOb1EtnnaVJUqnPKlA0p3dGwbt3hH0c4r9a3xeJe3OznjKu1rqJ3iA2en/hFfMa/423b8UmCCp4qlz9IQOlbnixO4KAMySCWu/gk02P4/45wUyFKkfVVlNRqA2DgQxvgAAi9pf8n8n//bb5hKOeJFN9gI/je/Ui9AFAFiw/J+z+tqiCydkso1+mXeoosTahgC9HQPlDtq/yvNHeBdKp68ZVRIbxgG1t6gtXAsHqgBT+0s85xq/tCe6zFkJK9+x6ec8ggdYwv7l25yZxdO+pR0qZiw5Z6brxRb5AAc8gGHHVWTzkquod2kPz9twesnMilVp59EhK5NAw/x/9tY7PunLY5LnZsmgnbWAjR5gZ2R/mc21/2FgbR8fZv7Ca2bksaIdAGAiP43mE/lv9ipLMfk/Rp3Af0Z6jH8CAAbz0yik5ncU1njyf4zLqxGO4gAAjElisv4jrtkSY75HGdnVBFyVAAAjcdLE/5Ozhfsti7OJLuMIANAryOADFZb2VV8NsCQxAKCTo0FZau9Wy6tBEsGOAICmABwvp2Rm8Q6bk0E9KO0qBq1qBKXj/rG2e5dlLuNRjcYqAw/Qr5lx+1dvlu+yLQ0OosUxANCvmNEQSt6R9fI+qlIMAPQngKPLaqX95yw+ioFRJ0WPAEBPAjCaP5WOXL5yGSVApgBAx/+P9sgKZy7fuYw2BJIYAGjJaIescOgGtmtx93BDA2A3Vj2VTt3Adx2LAmwHAExyiaVbl6+N5wFxAgBM8IjEMfsjdCF3DjkoAPYjFWD1ipyT15GOEN0DAN8BYGQ21O/IQXkfOUIaJQCAmTeUjr7FUEgHgoANAOxGAkDh6AuNY7dF0h0AgAzWRrCz7/Hk+L6RBwLAiCcsHH6V7VRYHwS2ByDVt4DIFTksV71+WQoAjMwC8YqclldhuQvYHIC9/hMUyHHRD4DsQwdgZJt07j4A+hQ2igMH4En/6TLkvGTkDgV4D0Ci7ZXIV+SBvOrHmAQNAPc7ATAZBg8ZgAO7I3w6I/pEhh0CBkC7YCauyBPRnxutwgVA7wBK5I2U9rqATQHQrpeWuT8A5OVsNfgMwEG3CigvyCO56CoBeggUAG3wo8grobZmARsCoM0AqpNfAJwqS7OADQEoA8kA7R7tdgAkug5I4R8AumYATwIEQLcOJq/IO7nKmcrwFIBIpw+OPBTdoGQUHAC6DFD88hGAX2KmOvwEgNuoje2Y56EBkGr+cH3yE4CTpuVH0sAAsHI2hOj1NgJAd5eWrw5A7wJYHBQANEQHoB8aDQoAjSbEyV8ATsI67rcBINH82Qp5LJrBkSQgADSNL5n5DIDuKtl9QACIQB2AdngiHAAOmlOztd8AaIaHD8EAoLE/Kf0GoCSz1OIZABpnd0Gey8WyGLAFAJoIIHLfAciFXTFgCwA0ri5C3ktkVwzYAgCNqzv7D8DZrhiwAQDJMOhV6T8A5XAliJMgACC2lcL2OEASBADDC4EqCwGATM1QjUcA8OFlL4qCEI0CeAAAaChXYQCgkEUuYH0ANNdjnsIA4DRLOd4AMNwNr1AgUs1QjjcARCTsGkA/UBJ5D8ATAgDELPV4AsDwETlRhgJAKWaoxxcAKDgA3VBpwADE4QAQhwuAptv9Fg4Ab7MU5AUAw0lOXYUDQFVbkwWuDcBwF0yigETOUJAfAFSQAugHW3kOwPDfew8JgHcHDLKIRIObQWQZEgDlYAzAkdcAPEEXYGy4T14DkEMOODbc3GsAUmvq340lmaEiHwAoIQccG27pNQCDf06VYQFQKtstsohwAinA2IAJ9xgAAjng+ICJxwBgAGB8wDhIAFRoACgAAAAAACAEbA4AAw9wIy/3TmslZe6WB7AEAGXFbpCH7MeKudFd55XCEAK8TAGI+Ce5ywUECYBXKQBJf96TBIAHcF9ECh5g2th82w+WjEeBOEAAhrvc3l0ONt7Qz+/4WVcBCKgPxEaDgCV1IEEgHtTXznuAnX8AjG3u3IEH8FsoeICwBQMAE+QSHgAXAAAEAPgjqz6Xsk7fWdk0ZPAAU0zj0l8BAGbIOn1nAQBMmBTXNT/GaY3JWY8ddLja4TuCTALX2H3iyuNXlniAdYvmcvk5Vo9efI4D9ADDM2/do6FVseFQx4dceQvAMNsr34+YLX0SMR9f3y7sKB8sAWDtvullWQKowcsXluyOYnYAsHouepHxYtDJIr9n6gUJwPorJ1kd8UX+quT/Gn0feICGNvjqBwOqCh0K/FjXM+FgCAcANnYBG79QYsv2OEuSwPAaUgQA2N4DIPAAAAAAACEAQsBqIgGA8QFLjwGoJAAwNmBZeQwAsuaO5K0lsuWg9Nq3hQ/+z1NYADzNUJEPAAwvkuRhAZDPUJEPACSQBY4NN/EagOE7kmlYANAZKvIBAM0dyUFlgZE1t2av7XklZIH6wa59W9LaAAzfflGEBEAxQ0F+AIAhC9QPFnsOwHCKw3g49ufMlhxwdQA05f5zOAA8z1KQFwBojswFlAQUM9TjPwABdQJowAAMH82lwXQCIjpDPb4A8I7ABdBZ6vEEgOFXcwGALV5QXr/6Hq6AgqkD+Qzl+AOA5i6IQxj2P8xSjjcAaG5OCGRrMJ6lHG8AqIQ9NdA2olFAFQAAGspxGoL9U2yRA9gCABl4IUhnqcYjAPLhkw88gF5QNFwDqDwIAHRT4MV/AF7scoBbAKA5+1T6D0A5SzFeAaC5qJEmvts/GZ7m6hQIADpXt/cdgL1lKfAmAGhcnfQ8DYykXRFgGwB0l/UyvwHQDG+TCLDRVkyNs/N8RYhbFgE2AkDzXArxuhuYkllK8Q6AXAbqAjSDk3lAAOjuQ6YeLwof6CyVeAiAQNZpYmPut1oK3QiAQrPuxbx1AQdNDVAXQQGgXfisQnQAW70wshUAmQzPBegcgMwCA8DK2RCi1yM2aoP+9NH+P6mNzG8GQElCywJ0gyJlcABoG19k55/9d2SmMnwFINfNiNi7RcFId+qvygMEQH8jon8AWDraDQE46RIf7lkpeNAtcdSnIAHQL/t4dkhEzFaExwCcdFohXm0O2+v0LE6BAqDf/RN5tD80iWarwWsAtFmAT3mgdihbZgBb386nDX7Um2bAjtqaAWwNgN4FxJ4EgSS21wFsfT+nvucb+wFAfIcKfAcg1/59cvTB/kf9GPOgARi5FUt6sEU4lXcowH8AivKO8OmE/9cnMmUROADoIv1OA/QDkBcUOgAjJ8Kp42nAkd4x+DAAyPSbYaTT3YCd3r/VGQAwOgtih9cFDzGy3AHYAEAxkgcpZ/tBibpv5IEAgK4jS7+ubg+KRua/uCIAwMQTEkdLgZhYHwAsASAfUQVz8km5p5Fl3jIHAL6bASNBgDtYDB5HVvnEBQEAxt5QOrc/aC/vHHJYAORj+XDkGAH7scS1yAGARiUwdjYqcqohtBuzf31FAMA0jxg7RMAuvnu4wQEw7hJjZ6LAftT+eQEAtCUb/Siu5AGj8R+RDAEAHXkdPQsSOVENHkftL14RANAXBUbvSpUOdISeRl99UDY9kmsVAOOK4c+WrwtEz/wR41xPrLqaN8Oj2RMjJLfY/kk8PqOKDIEHGOoGjH8cklq8P+CQGgzgigCA4UTQ4K4cZW1DYGdw4Xv9igAAnX80eDgrtrQYOBosW8sCAQA6MdomLV8s3CEQv5g8+laUAIBecpP3Y+nOuhMj6c7kvn9sXQZrHQDoZPRwQmJZGDgabVykJ+vUTW+dKbXirv4rMalNVYztuVA0MWtSF/9Z8WlrYWsf4A8B2KTbQ1JrCqqdWUpSXi1UtpVvNF3MbgeJmRXb6pLI7LWf8mKjrgmykgCzj0VTC9YH96mZ/YmV9rf1lbbXo9lD2hHbOK86CMMpRF7t1LSdHgChV1O9qqcNl4eiJ+W4/a0FwJgAxPebtYZ3e9OyyVr7W/xQ5+vedGrHnP/a4AP+rIxnj535n619gD9SUdO3NLGK9muvsR44wo7a3/o+wHctoIxb/lQ98zWzwUOlzF96La4WK5lY/NnQdcJzukw9r7ZR4PCsJkwcarP97QYA/cJoCgIvqywRpS9TzI/wL6tVbPlr7SeTPVZ/5xqNqnrZ9daI8UlzRhY5AgDukFzGUz4iiePny3IqT/Zy2g/U1q3/uwYAKsvjNKXLNIrOS6g9einpRPuTN9vVa3MZ+EcyMpFSoiJGH71WnCaJmpowFWcrbe5MGfhdDKgYT/wRzmOqHlcXHrDAk39I2bX/29UQ8OkDZEyn/gyW6P8qIe6PBRGlXKnp9hdFgQCAB0lRTEwE/vgBlIj4bf6F7Py5oHTej5J/ndAsws+3sFt8OXc6/7bAWsbv0z1B9FSQ+dMjt9j9Z6VzHuAzDERzPytDap8KmRhTED3lhBI1f525LgtX9OqMBzDfeqdJy6SUSkk1FBM4JhgTQvCdf8fq5r+rHuCjGpARvY/2r4D+AUGcK7T72H64R+iKcFJ8GP8hH1OUuUNKdckDGF2+sblYvPbvugf4vFHyTiewtLg1/d0DAOX5zuabgy2P/h4A8HG3eGTrh3Yo+XcYAFQUaWTjNgZZZu4p00UAEMoyC5NB+5O/XiFOfmp0uVr2wcnVTfu76QE+40DC7fnsdZU7qkdXPcBHPfBmy/nwGr85a3/Ebhc6lVuf/YQO9fZdAcHeHLP5rZkVQy7LCR2qbUdQ87PTGnTaA3whUG63ka2K3hy0edMD3P6rdtMLTN6r+6j86eJm6K8HAUAKuzigPJ+6W/8BIpc+gbCKA/ADgI+94yhlayYDdZ0hV0ULgLvJTIZiupIbkJUo3FWUrwAgVHyc3BFL+zBFLzlyWrwF4DMbmLeF39z66l/kuvgMwGdRsBQDXljffwB+M5AQ9tgeoahljvwQ/wH4iAUIcbarH5MUSnatK+SN6ACQ/gwTVVX2cbiD3ucJhJhzqMRqkS0Abmv/yq+hovLjfGjypKoZW/2V5Pg9/9g47pncGlkphuTtFJEEeSd5/vuwT1oSs9FJGWWJb/O+3wFIxJC4BYALT4ddntAVIU7wt7Qn/LfIjylyRb5KY+lMINYKCchrufV+HH9f9KeQqlAootoeQAUEwBAMIUnL3qTpAa4IxHO5Nj1AC4ASFOS7lG0AUCPvq0FDfkvdzAE7AESgIr8l6gKQBJoFQg74YXrS+tIFVOS3XFo0EIQa+5orCTryWWSj+j3/PhnUzgtA/JVOxk8QZIEh54BdACAJCCcF+ANAI+xXEAN8jgCNFEB+AaAEJAEhpgBC/Tke3sgCd6Amf2XXzgHR52aAxgu8WYRBUb4Wgf+7/Rcv/niAc6MXxEBRvkrDtOqzAURQJwYQUJSvQjoRAH3tB+MQA4KLAKX8y4RAEANCiwBfRv8NgIIYEF4EULdfa3QIX6EV4GcToPGGfdSAohn1YVuQl9I065fJv5LAnN66B8xBWx5Kedvzr9+bYaHRI64oaMs/oU0bt/IC0eceQHwS3FMDfIeAj/d0blsBHCoB71LA/xrhoG5XBjGkgSGlgHGnNDw3gkAOu4M9E9W44EScu72BZuZfgcr8kqZB/xr7LwBnACAcAM49AKjGobEaskCvhDRSgFL1ANBi5B2U5pO8D7mDGwCaZwYqcAE+OYCmbWUvAC0X8AaFgD8lwNtgPnALgGgeG4E80J8MsHn4SwwA0LoeIoNjgp6IbN5t3zBzc4+AgFLQ/xJQaHb/NF1ADg1hL6TONUZuAtByAQkozwdJNA4AtVb+VaMhnFHYGOBBAGj2eAup8QCobuJRQSnofglY6Szc2QHcDBAVXBvnvJSVxsCdEIAUaXylZtAQdDwDbJaAVbu069i3RFAK+lsCdm8C7WR5qnl+RGA4J+SyFM0H7opOZd/18K17wqAZ4FELQHYdehcA1XoUsYRKwN0KoOXxC2UAQHsVCNaF3RXSMmWPN+/r9MjmVXE5tINcTQBbj9v3bfXts+3HS0KN3wOnBJwU0bryL+879Ntr2rargHaQk1JqQ7sOgNYWcVTCUTEHBZdao/75rv4f5q2FwBRukHVu/metANDf0xuI7u0gkEEe6JjQzCQA9CeBPZUAymBRwCmpz60vZGoaAEq2QwaFRMAdka1Vf5QNXfszOK/r9qoQZAEOSdReAxps6A/HdtFy+hlcH+eM5K0OkMgHv1UT2ds/VEAMcKUALEZMaeQBumlADkvDTkjRPteZiVkAINlqCaMaCHDB/u35rl3R19b3ElMgwHn7l9pOvr7BIygBAhy3f51rv3+kvRO3zwfmBejYKfvLGN0DwP86HiKHWsDm/L8z3en/Rn5i7FfSzhtCMZwYs7b+7/jn69jF36MdfpF1vAyDc+NWimQd+2ejF7+Pr/LJtL2MkCkMK0P2Sd2p/xHJxoOGwW/mXZ9/gCcFbBN66kYEg2M9Juv8nYYQQhmBDQJ2Sfk2y/5mz8NU3VIyg2LArvQ/m2d/ZDaRe3xArggkAraIKC4z7W8IQB8BooTzApZIdRFz7W/8Qljf+aArhlNjFojC1x67mh7rNg/lLO2pDyJYGti8+it7bJ0ZH+k19+JSdDeWywoWhzaWou86x6t5lT4hjKtEdv1FDU2hTad/0bM4J0mOlgAAFfueTEBUGPYLbxX9y75Wbx39N6V+nPYn4769wZzDO4ObJP9VX6ZXTluwn1jJCdUT82X1VEIcWN37R+e+Rbl84kneyd67rxj4uI0SjpCvKrLqj/PZ1Bt9podvkvR7jZRDKrBa8K/6l/lEPnmlns7447j3h2qRQF9oHSGXfj9fznjtb9asHQgDiD/BVpEVzP8+0OXL5lzoNs9tk3ig/8M4BIKFnX9VD7UEZs2+uebigxsDEwZrREuJGN7jnc+803Vu7l7hIX+TvwMBywhl70P2r/HcO33vcNjDTgBxChA8fPKLYRvn8690vidik0jTATxKeHz0cVU/I68ab1zekXvfl7KxWGdkYGAF6yNZ3HWb8505O+b6k0fHK2wbus/zi92r9huKO191ubto08aBz3xgX1JwBLOmvoguI8H9Lu//GAAQouPbgvgel7BzZIrUkbqMZnZ1ef/xjIe0bVhk4uejHcZw9fj4vOdKXU2W9ET5iKccHtS3M0Pg0xkQ/CVg6xtRXyJNC7rHmP9hAExB4PsnMAIIPo2P1FRjPsr86JEmoLAxaCWpqscdzXzoHCQcbpNcXsrqkWuuD3bC+KWCun/RvgA/P3bXxeOj8EsBbmCxyR+fH/0rl0jDMOVQ9C/QGqjEAluulqjLVZ1RAs8NPtb6hGb1ElvulivEMGUMyrzHTKhaLLbdclkTveSwL+DerK9Ozkv+/sXnKCaUQkYw0+8LIZfeab2Ok37B8PrkxIkvEnVe4w+tGKUJwYQSSAtGAr4UUsn1ttevbg98I7AW8Nvm30tBH7LyH/9/nHTKOzOtrZAAAAAASUVORK5CYII=";
698
+ var MASKABLE_512_PNG = "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";
699
+ 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==";
700
+
701
+ // src/routes/pwa.ts
702
+ var BACKGROUND_COLOR = "#f4f4f5";
703
+ var THEME_COLOR = "#ffffff";
704
+ var THEME_COLOR_DARK = "#111113";
705
+ function manifest(base) {
706
+ const icon = (name, sizes, type, purpose) => ({ src: `${base}/icons/${name}`, sizes, type, purpose });
707
+ return JSON.stringify({
708
+ id: `${base}/`,
709
+ name: "CronWatch",
710
+ short_name: "CronWatch",
711
+ description: "The scheduled jobs of this app: their health, their last day and their runs.",
712
+ start_url: `${base}/`,
713
+ scope: `${base}/`,
714
+ display: "standalone",
715
+ background_color: BACKGROUND_COLOR,
716
+ theme_color: THEME_COLOR,
717
+ icons: [
718
+ icon("icon.svg", "any", "image/svg+xml", "any"),
719
+ icon("maskable.svg", "any", "image/svg+xml", "maskable"),
720
+ icon("icon-192.png", "192x192", "image/png", "any"),
721
+ icon("icon-512.png", "512x512", "image/png", "any"),
722
+ icon("maskable-512.png", "512x512", "image/png", "maskable")
723
+ ]
724
+ });
725
+ }
726
+ var APP_JS = `"use strict";
727
+ (function () {
728
+ var script = document.currentScript;
729
+ if (!script || !("serviceWorker" in navigator)) return;
730
+ var base = new URL("./", script.src);
731
+ navigator.serviceWorker.register(new URL("sw.js", base).href, { scope: base.pathname }).catch(function () {});
732
+ })();
733
+ `;
734
+ var SW_JS = `"use strict";
735
+ var VERSION = "cronwatch-shell-1";
736
+ var SCOPE = self.registration.scope;
737
+ var CACHE = VERSION + " " + SCOPE;
738
+ 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) {
739
+ return new URL(path, SCOPE).href;
740
+ });
741
+ var OFFLINE = SHELL[0];
742
+
743
+ self.addEventListener("install", function (event) {
744
+ event.waitUntil(caches.open(CACHE).then(function (cache) {
745
+ return cache.addAll(SHELL.map(function (url) { return new Request(url, { credentials: "omit", cache: "reload" }); }));
746
+ }).then(function () { return self.skipWaiting(); }));
747
+ });
748
+
749
+ self.addEventListener("activate", function (event) {
750
+ event.waitUntil(caches.keys().then(function (keys) {
751
+ return Promise.all(keys.filter(function (key) {
752
+ return key !== CACHE && key.indexOf("cronwatch-shell-") === 0 && key.slice(key.indexOf(" ") + 1) === SCOPE;
753
+ }).map(function (key) { return caches.delete(key); }));
754
+ }).then(function () { return self.clients.claim(); }));
755
+ });
756
+
757
+ self.addEventListener("fetch", function (event) {
758
+ var request = event.request;
759
+ if (request.method !== "GET") return;
760
+ var url = new URL(request.url);
761
+ url.search = "";
762
+ if (SHELL.indexOf(url.href) !== -1 && url.href !== OFFLINE) {
763
+ event.respondWith(caches.open(CACHE).then(function (cache) {
764
+ return cache.match(url.href).then(function (hit) { return hit || fetch(request); });
765
+ }));
766
+ return;
767
+ }
768
+ if (request.mode !== "navigate") return;
769
+ event.respondWith(fetch(request).catch(function () {
770
+ return caches.open(CACHE).then(function (cache) { return cache.match(OFFLINE); }).then(function (page) {
771
+ return page || new Response("You are offline.", { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } });
772
+ });
773
+ }));
774
+ });
775
+ `;
776
+ var YEAR = "public, max-age=31536000, immutable";
777
+ var REVALIDATE = "no-cache";
778
+ function decode(base64) {
779
+ const bytes = atob(base64);
780
+ const out = new Uint8Array(bytes.length);
781
+ for (let i = 0; i < bytes.length; i++) out[i] = bytes.charCodeAt(i);
782
+ return out;
783
+ }
784
+ var ICONS = {
785
+ "/icons/icon.svg": () => ({ type: "image/svg+xml", body: ICON_SVG, cache: YEAR }),
786
+ "/icons/maskable.svg": () => ({ type: "image/svg+xml", body: MASKABLE_SVG, cache: YEAR }),
787
+ "/icons/icon-192.png": () => ({ type: "image/png", body: decode(ICON_192_PNG), cache: YEAR }),
788
+ "/icons/icon-512.png": () => ({ type: "image/png", body: decode(ICON_512_PNG), cache: YEAR }),
789
+ "/icons/maskable-512.png": () => ({ type: "image/png", body: decode(MASKABLE_512_PNG), cache: YEAR }),
790
+ "/icons/apple-touch-icon.png": () => ({ type: "image/png", body: decode(APPLE_TOUCH_ICON_PNG), cache: YEAR })
791
+ };
792
+ function staticAsset(path, base) {
793
+ if (path === "/manifest.webmanifest") return { type: "application/manifest+json", body: manifest(base), cache: REVALIDATE };
794
+ if (path === "/app.js") return { type: "text/javascript; charset=utf-8", body: APP_JS, cache: REVALIDATE };
795
+ if (path === "/sw.js") return { type: "text/javascript; charset=utf-8", body: SW_JS, cache: REVALIDATE, worker: true };
796
+ return Object.hasOwn(ICONS, path) ? ICONS[path]() : null;
797
+ }
798
+
799
+ // src/routes/timeline.ts
800
+ var HOUR = 36e5;
801
+ var DAY = 24 * HOUR;
802
+ var BOARD_BEHIND_MS = DAY;
803
+ var BOARD_AHEAD_MS = 3 * HOUR;
804
+ var BOARD_LANES = 30;
805
+ var BOARD_RUNS = 200;
806
+ var WEEK_DAYS = 7;
807
+ var W = 1e3;
808
+ var MAX_TICKS = 330;
809
+ var MAX_BOXES = 8;
810
+ var MIN_BOX = 10;
811
+ var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
812
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
813
+ function clock(t) {
814
+ return new Date(t).toISOString().slice(11, 16);
815
+ }
816
+ function dayLabel(t) {
817
+ const d = new Date(t);
818
+ return `${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
819
+ }
820
+ function when2(t, now) {
821
+ if (Math.floor(t / DAY) === Math.floor(now / DAY)) return clock(t);
822
+ const d = new Date(t);
823
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${clock(t)}`;
824
+ }
825
+ function parsedSchedule(job) {
826
+ if (!job.definition.schedule) return null;
827
+ try {
828
+ return parseSchedule(job.definition.schedule, job.definition.timezone);
829
+ } catch {
830
+ return null;
831
+ }
832
+ }
833
+ function safely(fn, fallback) {
834
+ try {
835
+ return fn();
836
+ } catch {
837
+ return fallback;
838
+ }
839
+ }
840
+ function dueTimes(job, parsed, runs, from, to) {
841
+ if (!parsed) return { times: [], dense: false };
842
+ if (parsed.kind === "interval") {
843
+ const every = parsed.everyMs;
844
+ if ((to - from) / every > MAX_TICKS) return { times: [], dense: true };
845
+ const starts = runs.map((r) => r.startedAt).sort((a, b) => a - b);
846
+ const times2 = /* @__PURE__ */ new Set();
847
+ for (const start of starts) {
848
+ const t2 = start + every;
849
+ if (t2 >= from && t2 <= to) times2.add(t2);
850
+ }
851
+ let t = starts.length ? starts[starts.length - 1] + every : job.nextExpectedAt;
852
+ if (t !== null) {
853
+ if (t < from) t += Math.ceil((from - t) / every) * every;
854
+ for (; t <= to; t += every) times2.add(t);
855
+ }
856
+ return { times: [...times2].sort((a, b) => a - b), dense: false };
857
+ }
858
+ const times = safely(() => firesBetween(parsed, from - 1, to, MAX_TICKS), []);
859
+ return times === null ? { times: [], dense: true } : { times, dense: false };
860
+ }
861
+ function missedAt(job, parsed, times, now) {
862
+ if (!parsed || !job.open.includes("missed")) return null;
863
+ const grace = safely(() => graceMs(job.definition), 0);
864
+ const last = job.lastRun?.startedAt ?? null;
865
+ if (last !== null) return safely(() => expectation(parsed, last, last, grace)?.dueAt ?? null, null);
866
+ const past = times.filter((t) => t + grace < now);
867
+ return past.length ? past[past.length - 1] : null;
868
+ }
869
+ function toneOf(run, job, now) {
870
+ if (run.status === "running") return safely(() => isStuck(job.definition, run, now), false) ? "stuck" : "running";
871
+ if (run.status === "failed") return "bad";
872
+ if (run.status === "timeout") return "timeout";
873
+ const latest = job.lastRun?.id === run.id;
874
+ return latest && (job.open.includes("over_budget") || job.open.includes("slow")) ? "warn" : "ok";
875
+ }
876
+ function timeoutText(job) {
877
+ return safely(() => formatDuration(timeoutMs(job.definition)), "configured");
878
+ }
879
+ function describeRun(run, tone, job, now) {
880
+ const at = `${when2(run.startedAt, now)} UTC`;
881
+ if (tone === "running") return `running since ${at}, ${formatDuration(now - run.startedAt)} so far`;
882
+ if (tone === "stuck") return `running since ${at}, past its ${timeoutText(job)} timeout`;
883
+ const took = run.durationMs !== null ? `, took ${formatDuration(run.durationMs)}` : "";
884
+ const extra = tone === "warn" ? job.open.includes("over_budget") ? ", over budget" : ", slow" : "";
885
+ return `${run.status} at ${at}${took}${extra}`;
886
+ }
887
+ function overCeilings(job) {
888
+ const metrics = job.lastRun?.metrics ?? {};
889
+ return Object.entries(job.definition.budget ?? {}).filter(([k, limit]) => (metrics[k] ?? -Infinity) > limit).map(([k]) => k);
890
+ }
891
+ function laneNote(job, missed, now) {
892
+ const last = job.lastRun;
893
+ if (job.silencedUntil !== null && job.silencedUntil > now) return `silenced until ${when2(job.silencedUntil, now)}`;
894
+ if (job.open.includes("missed")) return missed !== null ? `due ${when2(missed, now)}, nothing ran` : "overdue, nothing ran";
895
+ if (last?.status === "running") {
896
+ const stuck = safely(() => isStuck(job.definition, last, now), false);
897
+ return stuck ? `running since ${when2(last.startedAt, now)}, past its ${timeoutText(job)} timeout` : `running since ${when2(last.startedAt, now)}`;
898
+ }
899
+ if (last?.status === "failed") return `failed at ${when2(last.startedAt, now)}${job.consecutiveFailures > 1 ? `, ${job.consecutiveFailures} in a row` : ""}`;
900
+ if (last?.status === "timeout") return `timed out at ${when2(last.startedAt, now)}`;
901
+ if (job.open.includes("stuck")) return "stuck";
902
+ if (job.open.includes("over_budget") && last) {
903
+ const over = overCeilings(job);
904
+ return `went over budget${over.length ? ` on ${over.join(" and ")}` : ""} at ${when2(last.startedAt, now)}`;
905
+ }
906
+ if (job.open.includes("slow") && last?.durationMs != null) return `slow: took ${formatDuration(last.durationMs)}`;
907
+ if (job.open.includes("failed")) return "failing";
908
+ if (!last && job.nextExpectedAt !== null) return `no runs yet, first due ${when2(job.nextExpectedAt, now)}`;
909
+ return null;
910
+ }
911
+ var f = (n) => n.toFixed(1);
912
+ var delay = (x, base = 80, perUnit = 0.75) => `--d:${Math.round(base + Math.max(0, x) * perUnit)}ms`;
913
+ function lane(input, span, options) {
914
+ const { job } = input;
915
+ const { from, to, now } = span;
916
+ const x = (t) => Math.min(W, Math.max(0, (t - from) / (to - from) * W));
917
+ const parsed = parsedSchedule(job);
918
+ const due = dueTimes(job, parsed, input.runs, from, to);
919
+ const missed = missedAt(job, parsed, due.times, now);
920
+ const grace = safely(() => graceMs(job.definition), 0);
921
+ const name = options.label;
922
+ const busy = [];
923
+ let s = `<svg class="marks" viewBox="0 0 ${W} 24" preserveAspectRatio="none" aria-hidden="true" focusable="false">`;
924
+ if (options.nowInLane && now > from && now < to) s += `<rect class="ahead" x="${f(x(now))}" y="0" width="${f(W - x(now))}" height="24"/>`;
925
+ s += `<line class="base" x1="0" y1="12" x2="${W}" y2="12"/>`;
926
+ const inSpan = input.runs.filter((r) => r.startedAt <= to && (r.finishedAt ?? now) >= from).sort((a, b) => a.startedAt - b.startedAt);
927
+ if (!input.complete) {
928
+ const oldest = Math.min(...input.runs.map((r) => r.startedAt));
929
+ if (Number.isFinite(oldest) && oldest > from) {
930
+ 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>`;
931
+ }
932
+ }
933
+ if (due.dense) {
934
+ 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>`;
935
+ }
936
+ for (const t of due.times) {
937
+ const tx = x(t);
938
+ s += `<line class="tick${t > now ? " ahead" : ""}" x1="${f(tx)}" y1="6" x2="${f(tx)}" y2="18" style="${delay(tx, 0, 0.45)}"/>`;
939
+ }
940
+ if (missed !== null && missed <= to) {
941
+ const slots = due.dense ? [] : due.times.filter((t) => t >= missed && t + grace < now);
942
+ if (!slots.includes(missed) && missed >= from) slots.unshift(missed);
943
+ const title = escapeHtml(`${name}: due ${when2(missed, now)} UTC, nothing started${slots.length > 1 ? ` (${slots.length} slots in this span)` : ""}`);
944
+ if (due.dense || slots.length > MAX_BOXES) {
945
+ const x1 = x(Math.max(missed, from)), x2 = Math.max(x(now), x1 + MIN_BOX);
946
+ s += `<rect class="missed" x="${f(x1)}" y="5" width="${f(x2 - x1)}" height="14" style="${delay(x1)}"><title>${title}</title></rect>`;
947
+ busy.push([x1, x2]);
948
+ } else {
949
+ for (const t of slots) {
950
+ if (t < from) continue;
951
+ const x1 = x(t), width = Math.max(x(t + grace) - x1, MIN_BOX);
952
+ s += `<rect class="missed" x="${f(x1)}" y="5" width="${f(width)}" height="14" style="${delay(x1)}"><title>${title}</title></rect>`;
953
+ busy.push([x1, x1 + width]);
954
+ }
955
+ }
956
+ }
957
+ for (const run of inSpan) {
958
+ const tone = toneOf(run, job, now);
959
+ const x1 = x(run.startedAt), x2 = Math.max(x(run.finishedAt ?? now), x1 + 0.5);
960
+ 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>`;
961
+ busy.push([x1, x2]);
962
+ }
963
+ if (options.nowInLane && now > from && now < to) s += `<line class="nowline" x1="${f(x(now))}" y1="0" x2="${f(x(now))}" y2="24"/>`;
964
+ s += `</svg>`;
965
+ const text = laneNote(job, missed, now);
966
+ let note = "";
967
+ if (text) {
968
+ const nowX = x(now);
969
+ const lo = busy.length ? Math.min(...busy.map((b) => b[0])) : nowX;
970
+ const hi = busy.length ? Math.max(...busy.map((b) => b[1])) : nowX;
971
+ const right = W - hi >= lo;
972
+ const room = right ? W - hi : lo;
973
+ if (room > 90) {
974
+ const edge = right ? hi + 14 : lo - 14;
975
+ const place = right ? `left:${f(edge / 10)}%` : `right:${f(100 - edge / 10)}%`;
976
+ note = `<span class="note${right ? "" : " before"}" style="${place};max-width:${f((room - 18) / 10)}%">${escapeHtml(text)}</span>`;
977
+ }
978
+ }
979
+ return { svg: s, note, words: words(job, inSpan, due, missed, span, text) };
980
+ }
981
+ function words(job, runs, due, missed, span, note) {
982
+ const parts = [];
983
+ if (due.dense) parts.push(`due ${job.definition.schedule}`);
984
+ else if (job.definition.schedule) {
985
+ const n = due.times.filter((t) => t <= span.now).length;
986
+ parts.push(`due ${n === 0 ? "no times" : n === 1 ? "once" : `${n} times`} so far`);
987
+ }
988
+ const ok = runs.filter((r) => r.status === "ok").length;
989
+ parts.push(`${runs.length} ${runs.length === 1 ? "run" : "runs"} recorded${!runs.length ? "" : ok === runs.length ? ok === 1 ? ", ok" : ", all ok" : ok ? `, ${ok} ok` : ""}`);
990
+ for (const r of runs.filter((r2) => r2.status === "failed" || r2.status === "timeout").slice(-5)) {
991
+ parts.push(`${r.status} at ${when2(r.startedAt, span.now)} UTC after ${formatDuration(r.durationMs ?? 0)}`);
992
+ }
993
+ if (missed !== null) parts.push(`due at ${when2(missed, span.now)} UTC and nothing started`);
994
+ if (note && !/^(due |failed at|timed out)/.test(note)) parts.push(note);
995
+ return parts.join("; ");
996
+ }
997
+ function hours(span, step, nowLabel) {
998
+ const x = (t) => (t - span.from) / (span.to - span.from) * W;
999
+ const nowX = x(span.now);
1000
+ let lines = "", labels = "";
1001
+ for (let t = Math.ceil(span.from / step) * step; t <= span.to; t += step) {
1002
+ const gx = x(t);
1003
+ lines += `<i class="gl" style="left:${f(gx / 10)}%"></i>`;
1004
+ const nearNow = nowLabel && Math.abs(gx - nowX) < 70;
1005
+ if (gx < 25 || gx > W - 25 || nearNow) continue;
1006
+ const minor = Math.round(t / HOUR) % 6 !== 0;
1007
+ const near = nowLabel && Math.abs(gx - nowX) < 170;
1008
+ const cls = [minor && "minor", near && "near"].filter(Boolean).join(" ");
1009
+ labels += `<span class="${cls}" style="left:${f(gx / 10)}%">${clock(t)}</span>`;
1010
+ }
1011
+ if (nowLabel && span.now >= span.from && span.now <= span.to) labels += `<span class="nowlabel" style="left:${f(nowX / 10)}%">now ${clock(span.now)}</span>`;
1012
+ return { lines, labels };
1013
+ }
1014
+ function legend() {
1015
+ const key = (inner) => `<svg class="key" viewBox="0 0 16 12" aria-hidden="true" focusable="false">${inner}</svg>`;
1016
+ const box = (cls) => key(`<rect class="${cls}" x="2" y="1" width="12" height="10"/>`);
1017
+ const items = [
1018
+ [key(`<line class="tick" x1="8" y1="1" x2="8" y2="11"/>`), "due"],
1019
+ [box("run ok"), "ran"],
1020
+ [box("run bad"), "failed"],
1021
+ [box("run timeout"), "timed out"],
1022
+ [box("run warn"), "over budget or slow"],
1023
+ [box("run running"), "running"],
1024
+ [box("missed"), "missed"]
1025
+ ];
1026
+ return `<p class="legend" aria-hidden="true">${items.map(([k, label]) => `<span>${k}${label}</span>`).join("")}</p>`;
1027
+ }
1028
+ function stateClass(job) {
1029
+ return { healthy: "ok", late: "warn", failing: "bad", stuck: "bad", silenced: "muted", never_ran: "muted" }[job.health];
1030
+ }
1031
+ function dayTimeline(lanes, span, base, total) {
1032
+ const grid = hours(span, 3 * HOUR, true);
1033
+ const nowX = (span.now - span.from) / (span.to - span.from) * 100;
1034
+ const rows = lanes.map((input) => {
1035
+ const { job } = input;
1036
+ const parts = lane(input, span, { nowInLane: false, label: job.name });
1037
+ return {
1038
+ 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>`,
1039
+ words: `<li>${escapeHtml(`${job.name} (${job.definition.schedule ?? "no schedule"}): ${parts.words}.`)}</li>`
1040
+ };
1041
+ });
1042
+ const more = total > lanes.length ? `<p class="more">Showing the first ${lanes.length} of ${total} jobs here; the table below lists them all.</p>` : "";
1043
+ return `<figure class="timeline day">
1044
+ <div class="axis" aria-hidden="true"><span></span><div class="hours">${grid.labels}</div></div>
1045
+ <div class="field">
1046
+ <div class="under" aria-hidden="true"><span></span><div>${grid.lines}<i class="future" style="left:${f(nowX)}%"></i></div></div>
1047
+ <ol class="lanes">${rows.map((r) => r.html).join("")}</ol>
1048
+ <div class="over" aria-hidden="true"><span></span><div><i class="now" style="left:${f(nowX)}%"></i></div></div>
1049
+ </div>
1050
+ ${legend()}${more}
1051
+ <ul class="vh">${rows.map((r) => r.words).join("")}</ul>
1052
+ </figure>`;
1053
+ }
1054
+ function weekTimeline(job, runs, complete, now) {
1055
+ const today = Math.floor(now / DAY) * DAY;
1056
+ const oldest = runs.length ? Math.min(...runs.map((r) => r.startedAt)) : null;
1057
+ const first = { from: today, to: today + DAY, now };
1058
+ const grid = hours(first, 3 * HOUR, false);
1059
+ const rows = [];
1060
+ for (let i = 0; i < WEEK_DAYS; i++) {
1061
+ const from = today - i * DAY;
1062
+ const span = { from, to: from + DAY, now };
1063
+ const dayRuns = runs.filter((r) => r.startedAt < span.to && (r.finishedAt ?? now) >= from);
1064
+ const known = complete || oldest !== null && oldest <= from;
1065
+ const label = i === 0 ? "today" : dayLabel(from);
1066
+ const parts = lane({ job, runs, complete: known }, span, { nowInLane: i === 0, label: `${job.name}, ${label}` });
1067
+ const count = `${dayRuns.length} ${dayRuns.length === 1 ? "run" : "runs"}`;
1068
+ rows.push({
1069
+ 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>`,
1070
+ words: `<li>${escapeHtml(`${i === 0 ? "Today" : dayLabel(from)}: ${parts.words}.`)}</li>`
1071
+ });
1072
+ }
1073
+ return `<figure class="timeline week">
1074
+ <div class="axis" aria-hidden="true"><span></span><div class="hours">${grid.labels}</div></div>
1075
+ <div class="field">
1076
+ <div class="under" aria-hidden="true"><span></span><div>${grid.lines}</div></div>
1077
+ <ol class="lanes">${rows.map((r) => r.html).join("")}</ol>
1078
+ </div>
1079
+ ${legend()}
1080
+ <ul class="vh">${rows.map((r) => r.words).join("")}</ul>
1081
+ </figure>`;
1082
+ }
1083
+ function weekRunsLimit(job, now) {
1084
+ const parsed = parsedSchedule(job);
1085
+ if (!parsed) return 50;
1086
+ const from = Math.floor(now / DAY) * DAY - (WEEK_DAYS - 1) * DAY;
1087
+ const span = now + DAY - from;
1088
+ const expected = parsed.kind === "interval" ? span / parsed.everyMs : (() => {
1089
+ const due = dueTimes(job, parsed, [], now - DAY, now);
1090
+ return due.dense ? Infinity : due.times.length * span / DAY;
1091
+ })();
1092
+ return Math.min(500, Math.max(50, Math.ceil(expected * 1.2) + 10));
1093
+ }
1094
+
1095
+ // src/routes/html.ts
647
1096
  var h = escapeHtml;
648
1097
  var CSS = `
649
- :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}
650
- @media(prefers-color-scheme:dark){:root{--bg:#121311;--fg:#ecece6;--muted:#9a9a91;--line:#2a2b27;--card:#1a1b18;--pill:#24251f}}
1098
+ :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}
1099
+ @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}}
651
1100
  *{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
652
- body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 var(--sans)}
653
- a{color:inherit}main{max-width:1080px;margin:0 auto;padding:24px 16px 64px}
654
- header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:20px}
655
- header h1{font-size:18px;margin:0;letter-spacing:-.01em}header h1 a{text-decoration:none}
656
- header .meta{color:var(--muted);font-size:13px}
657
- .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
658
- table{width:100%;border-collapse:collapse;font-size:14px}
659
- 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}
660
- td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
661
- tr:last-child td{border-bottom:0}
662
- .name{font-weight:600;white-space:nowrap}.name a{text-decoration:none}.name a:hover{text-decoration:underline}
663
- .mono{font-family:var(--mono);font-size:13px}.muted{color:var(--muted)}.nowrap{white-space:nowrap}
664
- .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}
665
- .pill::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
666
- .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.info{color:var(--info)}.mutedpill{color:var(--muted)}
667
- .spark{display:block}
668
- form.inline{display:inline}
669
- 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}
670
- button:hover{border-color:var(--muted)}
671
- .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
672
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px}
673
- .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}
674
- 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}
675
- details summary{cursor:pointer;color:var(--muted);font-size:13px}details{margin-top:6px}
676
- 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)}
677
- 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}
678
- .empty{padding:40px 16px;text-align:center;color:var(--muted)}
679
- 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}
680
- footer{margin-top:28px;color:var(--muted);font-size:12px}
681
- @media(max-width:720px){.hide-sm{display:none}main{padding:16px 16px 48px}}
1101
+ 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}
1102
+ 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}
1103
+ :focus-visible{outline:2px solid var(--ink);outline-offset:2px}
1104
+ code,pre,.mono{font-family:var(--mono)}
1105
+ .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}
1106
+ .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)}
1107
+ .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)}
1108
+ .brand{display:flex;align-items:center;gap:10px;margin:0;font:600 18px/1.2 var(--serif);letter-spacing:-.01em;min-width:0}
1109
+ .brand a{display:inline-flex;align-items:center;gap:10px;text-decoration:none}.brand svg{width:24px;height:24px;flex:none;color:var(--ink)}
1110
+ .brand .crumb{font:500 15px/1.2 var(--mono);color:var(--body);overflow-wrap:anywhere}.brand .slash{color:var(--rule-2);font-weight:400}
1111
+ .actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
1112
+ .meta{font:400 12px/1.4 var(--mono);color:var(--muted)}
1113
+ 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}
1114
+ select{padding:7px 8px}
1115
+ button:hover,select:hover,details.confirm>summary:hover{border-color:var(--muted)}
1116
+ button.primary{background:var(--ink);border-color:var(--ink);color:var(--sheet)}button.primary:hover{opacity:.86}
1117
+ form.inline{display:inline-flex;align-items:center;gap:6px;margin:0}
1118
+ details.confirm{display:inline-flex;align-items:center;gap:8px;margin:0}details.confirm>summary{list-style:none;display:inline-block}
1119
+ details.confirm>summary::-webkit-details-marker{display:none}details.confirm[open]>summary{border-color:var(--muted)}
1120
+ details.confirm form{margin-left:8px;font-size:14px;color:var(--muted)}
1121
+ .sec{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px 40px;padding:30px 0;border-top:1px solid var(--rule)}
1122
+ .top+main>.sec:first-child{border-top:0}
1123
+ .sec>h2{margin:0;font:500 11px/1.5 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-top:5px}
1124
+ .sec>.wide{grid-column:1/-1;min-width:0}
1125
+ .lede{margin:0;color:var(--muted);font-size:16px;max-width:64ch;text-wrap:pretty}
1126
+ .headline{margin:0;font:400 clamp(24px,3.2vw,32px)/1.2 var(--serif);letter-spacing:-.01em;text-wrap:balance}
1127
+ .headline b{font-weight:600}
1128
+ .state{font:500 12.5px/1.4 var(--mono);white-space:nowrap}.state+.state::before{content:" \\00b7 ";color:var(--muted);font-weight:400}
1129
+ .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.muted{color:var(--muted)}.info{color:var(--ink)}
1130
+ .sq{display:inline-block;width:8px;height:8px;border-radius:1.5px;background:currentColor;margin-right:7px;vertical-align:1px;flex:none}
1131
+ .sq.muted{background:none;box-shadow:inset 0 0 0 1.5px var(--tick)}
1132
+ .figures{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:0;margin:22px 0 0;border-top:1px solid var(--rule)}
1133
+ .figures>div{display:flex;flex-direction:column-reverse;justify-content:flex-end;gap:4px;padding:14px 16px 2px 0}
1134
+ .figures dt{font:500 11px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center}
1135
+ .figures dd{margin:0;font:400 30px/1.1 var(--serif);font-variant-numeric:tabular-nums;color:var(--ink)}
1136
+ .figures dd small{font-size:17px;color:var(--muted)}
1137
+ .figures .zero dd{color:var(--rule-2)}
1138
+ .figures .bad dd{color:var(--bad)}.figures .warn dd{color:var(--warn)}
1139
+ .stateline{margin:12px 0 0;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px}
1140
+ .stateline .why{font-style:italic;color:var(--muted)}
1141
+ .jobname{margin:0;font:500 clamp(22px,3vw,28px)/1.2 var(--mono);letter-spacing:-.01em;overflow-wrap:anywhere}
1142
+ .desc{margin:6px 0 0;color:var(--body);max-width:64ch}
1143
+ .intro .actions{margin-top:18px}
1144
+ table{width:100%;border-collapse:collapse}
1145
+ 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}
1146
+ 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)}
1147
+ tbody tr:last-child td{border-bottom:0}
1148
+ td.job{font-family:var(--serif);font-size:15px;min-width:180px}
1149
+ td.job .name{font:500 13.5px/1.5 var(--mono);color:var(--ink)}
1150
+ td.job .desc{display:block;margin:2px 0 0;font-size:14px;color:var(--muted);line-height:1.4}
1151
+ td .tz,td .sub{display:block;color:var(--muted);font-size:11.5px}
1152
+ .nowrap{white-space:nowrap}
1153
+ .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}
1154
+ .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}
1155
+ .metrics{display:flex;flex-wrap:wrap;gap:2px 14px}.metrics .k{color:var(--muted)}
1156
+ 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}
1157
+ 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)}
1158
+ details.out.error>summary{color:var(--bad)}
1159
+ dl.def{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 28px;margin:0}
1160
+ dl.def dt{font:500 11px/1.9 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
1161
+ 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)}
1162
+ .empty{padding:28px 0 8px;color:var(--muted);max-width:60ch}
1163
+ .empty code{font-size:.86em;color:var(--ink)}
1164
+ .message{padding:clamp(56px,12vh,120px) 0;text-align:center}
1165
+ .message h1{margin:0;font:400 clamp(28px,4vw,40px)/1.15 var(--serif);letter-spacing:-.015em}
1166
+ .message p{margin:14px auto 0;max-width:52ch;color:var(--muted);text-wrap:pretty}
1167
+ .signin{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px;margin:28px auto 0;max-width:420px}
1168
+ .signin label{font:500 11px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
1169
+ .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}
1170
+ 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)}
1171
+ .timeline{margin:18px 0 0}
1172
+ .timeline .axis,.timeline .under,.timeline .over,.timeline .lane{display:grid;grid-template-columns:var(--who) minmax(0,1fr)}
1173
+ .timeline .hours{position:relative;height:22px;font:400 11px/1 var(--mono);color:var(--muted);letter-spacing:.04em}
1174
+ .timeline .hours span{position:absolute;top:2px;transform:translateX(-50%);white-space:nowrap}
1175
+ .timeline .hours .nowlabel{color:var(--ink);font-weight:500;animation:cw-fade .5s 1s both}
1176
+ .timeline .field{position:relative}
1177
+ .timeline .under,.timeline .over{position:absolute;inset:0;pointer-events:none}
1178
+ .timeline .under>div,.timeline .over>div{position:relative}
1179
+ .timeline .gl{position:absolute;top:0;bottom:0;width:1px;background:var(--rule)}
1180
+ .timeline .future{position:absolute;top:0;bottom:0;right:0;background:var(--sunk)}
1181
+ .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}
1182
+ .timeline .lanes{position:relative;list-style:none;margin:0;padding:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
1183
+ .timeline .lane{align-items:center;min-height:40px}
1184
+ .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}
1185
+ .timeline .who .sched{grid-column:2}
1186
+ .timeline.week .who{display:flex;align-items:baseline;gap:10px}
1187
+ .timeline .who .name{font:500 13px/1.35 var(--mono);color:var(--ink);overflow-wrap:anywhere}
1188
+ .timeline .who .sched{font:400 11px/1.35 var(--mono);color:var(--muted);white-space:nowrap}
1189
+ .timeline .track{position:relative;height:24px}
1190
+ .timeline .marks{display:block;width:100%;height:24px;overflow:visible}
1191
+ .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}
1192
+ .timeline .note.before{text-align:right}
1193
+ .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)}
1194
+ .timeline .legend span{display:inline-flex;align-items:center;gap:7px}
1195
+ .timeline .more{margin:10px 0 0;padding-left:var(--who);font-size:14px;color:var(--muted);font-style:italic}
1196
+ .key{width:16px;height:12px;overflow:visible}
1197
+ .marks *{vector-effect:non-scaling-stroke}
1198
+ svg .base{stroke:var(--rule);stroke-width:1}
1199
+ svg .tick{stroke:var(--tick);stroke-width:1.5}svg .tick.ahead{stroke-dasharray:2 2;opacity:.75}
1200
+ svg .cadence{stroke:var(--tick);stroke-width:2;stroke-dasharray:1 3}
1201
+ svg .run{stroke-width:2;stroke-linejoin:round}
1202
+ svg .run.ok{fill:var(--ok);stroke:var(--ok)}
1203
+ svg .run.bad{fill:var(--bad);stroke:var(--bad)}
1204
+ svg .run.timeout{fill:var(--bad);fill-opacity:.28;stroke:var(--bad);stroke-width:1.5}
1205
+ svg .run.warn{fill:var(--warn);stroke:var(--warn)}
1206
+ svg .run.running{fill:none;stroke:var(--ink);stroke-width:1.5}
1207
+ svg .run.stuck{fill:var(--bad);fill-opacity:.12;stroke:var(--bad);stroke-width:1.5}
1208
+ svg .missed{fill:none;stroke:var(--bad);stroke-width:1.5;stroke-dasharray:3 2.5}
1209
+ svg .unloaded{fill:var(--sunk)}
1210
+ svg .ahead{fill:var(--sunk)}
1211
+ svg .nowline{stroke:var(--ink);stroke-width:1.5}
1212
+ .marks .tick{animation:cw-fade .4s var(--d,0ms) both}
1213
+ .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}
1214
+ .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}
1215
+ .figures>div{animation:cw-rise .5s cubic-bezier(.2,.8,.2,1) both}
1216
+ .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}
1217
+ @keyframes cw-fade{from{opacity:0}}
1218
+ @keyframes cw-grow{from{opacity:0;transform:scaleX(0)}}
1219
+ @keyframes cw-drop{from{opacity:0;transform:scaleY(0)}}
1220
+ @keyframes cw-rise{from{opacity:0;transform:translateY(4px)}}
1221
+ @keyframes cw-breathe{to{opacity:.38}}
1222
+ body{padding:0 env(safe-area-inset-right) 0 env(safe-area-inset-left)}
1223
+ footer{padding-bottom:calc(40px + env(safe-area-inset-bottom))}
1224
+ @media(display-mode:standalone){
1225
+ .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}
1226
+ .message{padding-top:clamp(40px,8vh,80px)}
1227
+ }
1228
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}
1229
+ @media(max-width:760px){
1230
+ .sec{grid-template-columns:minmax(0,1fr);gap:10px;padding:24px 0}
1231
+ .hide-sm{display:none}
1232
+ :root{--who:0px}
1233
+ .timeline .lane{grid-template-columns:minmax(0,1fr);padding:6px 0 8px}
1234
+ .timeline .who{padding:0 0 4px}
1235
+ .timeline .who .name{background:var(--sheet);padding-right:4px}
1236
+ .timeline .hours .minor,.timeline .hours .near{display:none}
1237
+ .timeline .note{font-size:13px}
1238
+ td.job{min-width:0}
1239
+ table.board thead{display:none}
1240
+ table.board tr{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:14px;padding:12px 0;border-bottom:1px solid var(--rule)}
1241
+ table.board tbody tr:last-child{border-bottom:0}
1242
+ table.board td{border:0;padding:0}
1243
+ table.board td.last{grid-column:1/-1;margin-top:4px;white-space:normal}
1244
+ table.board td.last .sub{display:inline;margin-left:8px}
1245
+ .runs td.nowrap{white-space:normal}
1246
+ dl.def{grid-template-columns:minmax(0,1fr);gap:0}dl.def dd{margin-bottom:10px}
1247
+ }
682
1248
  `;
683
- function layout(title, body, options = {}) {
1249
+ 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>`;
1250
+ function layout(title, body, base, options = {}) {
1251
+ const b = h(base);
684
1252
  return `<!doctype html>
685
1253
  <html lang="en">
686
1254
  <head>
687
1255
  <meta charset="utf-8">
688
- <meta name="viewport" content="width=device-width,initial-scale=1">
1256
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
689
1257
  <meta name="robots" content="noindex,nofollow">
1258
+ <meta name="color-scheme" content="light dark">
690
1259
  ${options.refresh ? `<meta http-equiv="refresh" content="${options.refresh}">` : ""}
691
1260
  <title>${h(title)}</title>
1261
+ <meta name="theme-color" content="${THEME_COLOR}" media="(prefers-color-scheme: light)">
1262
+ <meta name="theme-color" content="${THEME_COLOR_DARK}" media="(prefers-color-scheme: dark)">
1263
+ <meta name="mobile-web-app-capable" content="yes">
1264
+ <meta name="apple-mobile-web-app-capable" content="yes">
1265
+ <meta name="apple-mobile-web-app-title" content="CronWatch">
1266
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
1267
+ <link rel="manifest" href="${b}/manifest.webmanifest">
1268
+ <link rel="icon" href="${b}/icons/icon.svg" type="image/svg+xml">
1269
+ <link rel="apple-touch-icon" href="${b}/icons/apple-touch-icon.png">
1270
+ <script src="${b}/app.js" defer></script>
692
1271
  <style>${CSS}</style>
693
1272
  </head>
694
- <body><main>${body}</main></body>
1273
+ <body><div class="sheet">${body}</div></body>
695
1274
  </html>`;
696
1275
  }
697
- function healthPill(job) {
698
- const map = {
699
- healthy: ["ok", "healthy"],
700
- late: ["warn", "late"],
701
- failing: ["bad", "failing"],
702
- stuck: ["bad", "stuck"],
703
- silenced: ["mutedpill", "silenced"],
704
- never_ran: ["info", "never ran"]
705
- };
706
- const [cls, label] = map[job.health];
707
- const extras = job.open.filter((c) => !["missed", "failed", "stuck"].includes(c)).map((c) => c.replace("_", " "));
708
- return `<span class="pill ${cls}">${label}</span>${extras.length ? ` <span class="pill warn">${h(extras.join(", "))}</span>` : ""}`;
1276
+ function brand(base, crumb) {
1277
+ const home = `<a href="${h(base)}/">${MARK}<span>CronWatch</span></a>`;
1278
+ 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>`;
1279
+ }
1280
+ var HEALTH = {
1281
+ failing: ["bad", "failing"],
1282
+ stuck: ["bad", "stuck"],
1283
+ late: ["warn", "late"],
1284
+ healthy: ["ok", "healthy"],
1285
+ silenced: ["muted", "silenced"],
1286
+ never_ran: ["muted", "never ran"]
1287
+ };
1288
+ function healthState(job) {
1289
+ const [cls, label] = HEALTH[job.health];
1290
+ const extras = job.open.filter((c) => !["missed", "failed", "stuck"].includes(c)).map((c) => `<span class="state warn">${h(c.replace("_", " "))}</span>`).join("");
1291
+ return `<span class="state ${cls}"><i class="sq ${cls}" aria-hidden="true"></i>${label}</span>${extras}`;
709
1292
  }
710
- function runPill(run) {
1293
+ function runState(run) {
711
1294
  const cls = run.status === "ok" ? "ok" : run.status === "running" ? "info" : "bad";
712
- return `<span class="pill ${cls}">${h(run.status)}</span>`;
1295
+ return `<span class="state ${cls}">${h(run.status)}</span>`;
713
1296
  }
714
1297
  function sparkline(runs) {
715
- const points = [...runs].reverse().filter((r) => r.durationMs !== null).slice(-20);
1298
+ const points = [...runs].slice(0, 20).reverse();
716
1299
  if (points.length < 2) return "";
717
- const w = 96, hgt = 22;
718
- const max = Math.max(...points.map((r) => r.durationMs), 1);
719
- const step = w / (points.length - 1);
720
- const path = points.map((r, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(hgt - 2 - r.durationMs / max * (hgt - 4)).toFixed(1)}`).join(" ");
721
- 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("");
722
- 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>`;
1300
+ const bar = 4, gap = 1.5, hgt = 22;
1301
+ const max = Math.max(...points.map((r) => r.durationMs ?? 0), 1);
1302
+ const bars = points.map((r, i) => {
1303
+ const x = (i * (bar + gap)).toFixed(1);
1304
+ if (r.status === "running") return `<rect class="running" x="${x}" y="${hgt - 6.5}" width="${bar - 1}" height="6"/>`;
1305
+ const tall = Math.max(r.status === "ok" ? 2 : 6, (r.durationMs ?? 0) / max * hgt);
1306
+ const cls = r.status === "ok" ? "" : ` class="bad"`;
1307
+ return `<rect${cls} x="${x}" y="${(hgt - tall).toFixed(1)}" width="${bar}" height="${tall.toFixed(1)}" rx=".5"/>`;
1308
+ }).join("");
1309
+ const w = points.length * (bar + gap) - gap;
1310
+ return `<svg class="spark" width="${w.toFixed(1)}" height="${hgt}" viewBox="0 0 ${w.toFixed(1)} ${hgt}" aria-hidden="true" focusable="false">${bars}</svg>`;
723
1311
  }
724
1312
  function stamp(at, now) {
725
1313
  if (at === null) return `<span class="muted">never</span>`;
726
- const iso = new Date(at).toISOString().replace("T", " ").slice(0, 19);
727
- return `<span class="nowrap" title="${iso} UTC">${h(formatRelative(at, now))}</span>`;
728
- }
729
- function dashboardPage(jobs, runsByJob, now, base, checkedAt) {
1314
+ const iso = new Date(at).toISOString();
1315
+ return `<time class="nowrap" datetime="${iso}" title="${iso.replace("T", " ").slice(0, 19)} UTC">${h(formatRelative(at, now))}</time>`;
1316
+ }
1317
+ function healthFigures(jobs) {
1318
+ return `<dl class="figures">${Object.keys(HEALTH).map((health) => {
1319
+ const [cls, label] = HEALTH[health];
1320
+ const n = jobs.filter((j) => j.health === health).length;
1321
+ return `<div class="${n === 0 ? "zero" : cls}"><dt><i class="sq ${cls}" aria-hidden="true"></i>${label}</dt><dd>${n}</dd></div>`;
1322
+ }).join("")}</dl>`;
1323
+ }
1324
+ function dashboardPage(jobs, runsByJob, now, base, checkedAt, lanes = jobs.slice(0, BOARD_LANES).map((job) => ({ job, runs: runsByJob.get(job.name) ?? [], complete: true }))) {
1325
+ const attention = jobs.filter((j) => j.health !== "healthy").length;
1326
+ 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>.`;
730
1327
  const rows = jobs.map((job) => {
731
1328
  const last = job.lastRun;
1329
+ const d = job.definition;
732
1330
  return `<tr>
733
- <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>
734
- <td>${healthPill(job)}</td>
735
- <td class="mono nowrap">${h(job.definition.schedule ?? "")}<span class="muted">${job.definition.schedule ? "" : "no schedule"}</span></td>
736
- <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>
737
- <td class="nowrap hide-sm">${stamp(job.nextExpectedAt, now)}</td>
1331
+ <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>
1332
+ <td class="health">${healthState(job)}</td>
1333
+ <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>
1334
+ <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>
1335
+ <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>
738
1336
  <td class="hide-sm">${sparkline(runsByJob.get(job.name) ?? [])}</td>
739
1337
  </tr>`;
740
1338
  }).join("\n");
1339
+ const span = { from: now - BOARD_BEHIND_MS, to: now + BOARD_AHEAD_MS, now };
741
1340
  const body = `
742
- <header>
743
- <h1><a href="${h(base)}/">CronWatch</a></h1>
1341
+ <header class="top">
1342
+ ${brand(base)}
744
1343
  <div class="actions">
745
- <span class="meta">${jobs.length} job${jobs.length === 1 ? "" : "s"}${""}</span>
746
- <form class="inline" method="post" action="${h(base)}/check"><button type="submit">Run check now</button></form>
1344
+ <span class="meta">${h(clock(now))} UTC${""}</span>
1345
+ <form class="inline" method="post" action="${h(base)}/check"><button class="primary" type="submit">Run check now</button></form>
747
1346
  </div>
748
1347
  </header>
749
- <div class="card">
750
- ${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>
751
- <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>
752
- <tbody>${rows}</tbody></table>`}
753
- </div>
754
- <footer>Refreshes every minute. <a href="${h(base)}/api/jobs">JSON</a></footer>`;
755
- return layout("CronWatch", body, { refresh: 60 });
756
- }
757
- function jobPage(job, runs, now, base) {
1348
+ <main>
1349
+ <section class="sec" aria-label="Health">
1350
+ <h2>Health</h2>
1351
+ <div>
1352
+ <p class="headline">${headline}</p>
1353
+ ${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>`}
1354
+ </div>
1355
+ </section>
1356
+ ${jobs.length ? `<section class="sec" aria-label="Last 24 hours">
1357
+ <h2>Last 24 hours</h2>
1358
+ <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>
1359
+ <div class="wide">${dayTimeline(lanes, span, base, jobs.length)}</div>
1360
+ </section>
1361
+ <section class="sec" aria-label="Jobs">
1362
+ <h2>Jobs</h2>
1363
+ <p class="lede">Every job in the store. Open one for its week, its runs and their output.</p>
1364
+ <div class="wide"><table class="board">
1365
+ <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>
1366
+ <tbody>${rows}</tbody></table></div>
1367
+ </section>` : ""}
1368
+ </main>
1369
+ <footer><span>Refreshes every minute. Times are UTC.</span><a href="${h(base)}/api/jobs">JSON</a></footer>`;
1370
+ return layout("CronWatch", body, base, { refresh: 60 });
1371
+ }
1372
+ function jobPage(job, runs, now, base, complete = true) {
758
1373
  const d = job.definition;
759
1374
  const okRate = `${Math.round(job.stats.okRate * 100)}%`;
760
- const runRows = runs.map((run) => {
1375
+ const listed = runs.slice(0, 50);
1376
+ const runRows = listed.map((run) => {
761
1377
  const detail = [
762
- run.error ? `<details open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
763
- run.output ? `<details${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
1378
+ run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
1379
+ run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
764
1380
  ].join("");
765
- 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(" ");
766
- return `<tr>
767
- <td class="nowrap">${runPill(run)}</td>
768
- <td class="nowrap">${stamp(run.startedAt, now)}</td>
1381
+ 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("");
1382
+ return `<tr${detail ? ` class="has-detail"` : ""}>
1383
+ <td class="nowrap">${runState(run)}</td>
1384
+ <td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
769
1385
  <td class="nowrap">${run.durationMs !== null ? h(formatDuration(run.durationMs)) : `<span class="muted">running</span>`}</td>
770
- <td class="hide-sm">${metrics}</td>
771
- <td class="mono hide-sm muted">${h(run.trigger)}</td>
772
- </tr>${detail ? `<tr><td colspan="5" style="padding-top:0">${detail}</td></tr>` : ""}`;
1386
+ <td class="hide-sm">${metrics ? `<span class="metrics">${metrics}</span>` : ""}</td>
1387
+ <td class="hide-sm muted">${h(run.trigger)}</td>
1388
+ </tr>${detail ? `<tr class="detail"><td colspan="5">${detail}</td></tr>` : ""}`;
773
1389
  }).join("\n");
774
1390
  const silenced = job.silencedUntil !== null && job.silencedUntil > now;
1391
+ const path = `${h(base)}/jobs/${encodeURIComponent(job.name)}`;
1392
+ const parsed = parsedSchedule(job);
1393
+ const why = laneNote(job, missedAt(job, parsed, [], now), now);
775
1394
  const body = `
776
- <header>
777
- <h1><a href="${h(base)}/">CronWatch</a> <span class="muted">/</span> ${h(job.name)}</h1>
778
- <div class="actions">
779
- ${healthPill(job)}
780
- ${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>`}
781
- <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>
782
- </div>
1395
+ <header class="top">
1396
+ ${brand(base, job.name)}
1397
+ <div class="actions"><span class="meta">${h(clock(now))} UTC</span></div>
783
1398
  </header>
784
- <div class="grid">
785
- <div class="card stat"><div class="k">Last run</div><div class="v">${job.lastRun ? h(formatRelative(job.lastRun.startedAt, now)) : "never"}</div></div>
786
- <div class="card stat"><div class="k">Next due</div><div class="v">${job.nextExpectedAt ? h(formatRelative(job.nextExpectedAt, now)) : "no schedule"}</div></div>
787
- <div class="card stat"><div class="k">Success, last ${h(job.stats.runs)}</div><div class="v">${h(okRate)}</div></div>
788
- <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>
789
- </div>
790
- <div class="card" style="margin-bottom:20px">
791
- <dl>
792
- <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>
793
- <dt>Grace</dt><dd class="mono">${h(d.grace ?? "10m")}</dd>
794
- <dt>Timeout</dt><dd class="mono">${h(d.timeout ?? "1h")}</dd>
795
- ${d.maxDuration ? `<dt>Max duration</dt><dd class="mono">${h(d.maxDuration)}</dd>` : ""}
796
- ${d.budget ? `<dt>Budget</dt><dd class="mono">${h(Object.entries(d.budget).map(([k, v]) => `${k} \u2264 ${v}`).join(", "))}</dd>` : ""}
797
- ${d.expect ? `<dt>Expect</dt><dd class="mono">${h(d.expect)}</dd>` : ""}
1399
+ <main>
1400
+ <section class="sec intro" aria-label="Job">
1401
+ <h2>Job</h2>
1402
+ <div>
1403
+ <h1 class="jobname">${h(job.name)}</h1>
1404
+ ${d.description ? `<p class="desc">${h(d.description)}</p>` : ""}
1405
+ <p class="stateline">${healthState(job)}${why ? `<span class="why">${h(why)}</span>` : ""}</p>
1406
+ <div class="actions">
1407
+ ${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>`}
1408
+ <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>
1409
+ </div>
1410
+ <dl class="figures">
1411
+ <div><dt>Last run</dt><dd>${job.lastRun ? h(formatRelative(job.lastRun.startedAt, now)) : "never"}</dd></div>
1412
+ <div><dt>Next due</dt><dd>${job.nextExpectedAt !== null ? h(formatRelative(job.nextExpectedAt, now)) : `<small>no schedule</small>`}</dd></div>
1413
+ <div><dt>Success, last ${h(job.stats.runs)}</dt><dd>${h(okRate)}</dd></div>
1414
+ <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>
1415
+ </dl>
1416
+ </div>
1417
+ </section>
1418
+ <section class="sec" aria-label="Last 7 days">
1419
+ <h2>Last 7 days</h2>
1420
+ <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>
1421
+ <div class="wide">${weekTimeline(job, runs, complete, now)}</div>
1422
+ </section>
1423
+ <section class="sec" aria-label="Runs">
1424
+ <h2>Runs</h2>
1425
+ ${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>
1426
+ <div class="wide"><table class="runs">
1427
+ <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>
1428
+ <tbody>${runRows}</tbody></table></div>`}
1429
+ </section>
1430
+ <section class="sec" aria-label="Definition">
1431
+ <h2>Definition</h2>
1432
+ <dl class="def">
1433
+ <dt>Schedule</dt><dd>${d.schedule ? h(d.schedule) + (d.timezone ? ` <span class="muted">${h(d.timezone)}</span>` : "") : `<span class="muted">none</span>`}</dd>
1434
+ <dt>Grace</dt><dd>${h(d.grace ?? "10m")}</dd>
1435
+ <dt>Timeout</dt><dd>${h(d.timeout ?? "1h")}</dd>
1436
+ ${d.maxDuration ? `<dt>Max duration</dt><dd>${h(d.maxDuration)}</dd>` : ""}
1437
+ ${d.budget ? `<dt>Budget</dt><dd>${h(Object.entries(d.budget).map(([k, v]) => `${k} \u2264 ${v}`).join(", "))}</dd>` : ""}
1438
+ ${d.expect ? `<dt>Expect</dt><dd>${h(d.expect)}</dd>` : ""}
798
1439
  ${d.failuresBeforeAlert && d.failuresBeforeAlert > 1 ? `<dt>Alert after</dt><dd>${h(d.failuresBeforeAlert)} consecutive failures</dd>` : ""}
799
- ${d.description ? `<dt>Description</dt><dd>${h(d.description)}</dd>` : ""}
800
- ${d.tags?.length ? `<dt>Tags</dt><dd>${d.tags.map((t) => `<span class="pill mutedpill">${h(t)}</span>`).join(" ")}</dd>` : ""}
801
- ${job.open.length ? `<dt>Open</dt><dd>${job.open.map((c) => `<span class="pill warn">${h(c.replace("_", " "))}</span>`).join(" ")}</dd>` : ""}
802
- ${job.consecutiveFailures > 0 ? `<dt>Consecutive failures</dt><dd>${h(job.consecutiveFailures)}</dd>` : ""}
803
- </dl>
804
- </div>
805
- <div class="card">
806
- ${runs.length === 0 ? `<div class="empty">No runs yet.</div>` : `<table>
807
- <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>
808
- <tbody>${runRows}</tbody></table>`}
809
- </div>
810
- <footer><a href="${h(base)}/api/jobs/${encodeURIComponent(job.name)}">JSON</a></footer>`;
811
- return layout(`${job.name}: CronWatch`, body, { refresh: 60 });
812
- }
813
- function messagePage(title, message, base) {
814
- 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>`);
1440
+ ${d.tags?.length ? `<dt>Tags</dt><dd>${d.tags.map((t) => h(t)).join(", ")}</dd>` : ""}
1441
+ ${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>` : ""}
1442
+ ${job.consecutiveFailures > 0 ? `<dt>Failures in a row</dt><dd>${h(job.consecutiveFailures)}</dd>` : ""}
1443
+ </dl>
1444
+ </section>
1445
+ </main>
1446
+ <footer><span>Refreshes every minute. Times are UTC.</span><a href="${h(base)}/api/jobs/${encodeURIComponent(job.name)}">JSON</a></footer>`;
1447
+ return layout(`${job.name}: CronWatch`, body, base, { refresh: 60 });
1448
+ }
1449
+ function messagePage(title, message, base, signIn = false) {
1450
+ 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>` : "";
1451
+ return layout(title, `<header class="top">${brand(base)}</header><main class="message"><h1>${h(title)}</h1><p>${h(message)}</p>${form}</main>`, base);
815
1452
  }
816
1453
 
817
1454
  // src/routes/index.ts
818
1455
  var COOKIE = "cronwatch_token";
819
- function cookieValue(token) {
820
- return createHash("sha256").update(`cronwatch-cookie:${token}`).digest("hex");
1456
+ async function cookieValue(token) {
1457
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`cronwatch-cookie:${token}`));
1458
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
821
1459
  }
822
1460
  var DEFAULT_RUNS = 20;
823
1461
  var MAX_RUNS = 500;
824
- var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
1462
+ var BOARD_PAGE_RUNS = 20;
1463
+ async function boardLanes(cw, entries, now) {
1464
+ const from = now - BOARD_BEHIND_MS;
1465
+ return Promise.all(entries.slice(0, BOARD_LANES).map(async ({ job, runs }) => {
1466
+ const short = runs.length >= BOARD_PAGE_RUNS && runs[runs.length - 1].startedAt > from;
1467
+ if (!short) return { job, runs, complete: true };
1468
+ const deeper = await cw.runs(job.name, BOARD_RUNS);
1469
+ return { job, runs: deeper, complete: deeper.length < BOARD_RUNS };
1470
+ }));
1471
+ }
1472
+ 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'";
1473
+ var ASSET_CSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'";
825
1474
  var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
826
1475
  function developmentToken() {
827
- return randomBytes(32).toString("base64url");
1476
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
1477
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
828
1478
  }
829
1479
  function developmentSignInLine(origin, base, token) {
830
1480
  return `[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: ${origin}${base}/?token=${token}`;
@@ -845,6 +1495,34 @@ function readCookie(request, name) {
845
1495
  }
846
1496
  return null;
847
1497
  }
1498
+ function firstValue(value) {
1499
+ const first = value?.split(",")[0]?.trim();
1500
+ return first ? first : null;
1501
+ }
1502
+ function configuredOrigin(value) {
1503
+ if (value === void 0 || value === "") return null;
1504
+ let url;
1505
+ try {
1506
+ url = new URL(value);
1507
+ } catch {
1508
+ throw new Error(`routes: origin must be an absolute URL such as "https://app.example.com", got ${JSON.stringify(value)}`);
1509
+ }
1510
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`routes: origin must be http or https, got ${JSON.stringify(value)}`);
1511
+ return url.origin;
1512
+ }
1513
+ function forwardedOrigin(request, url) {
1514
+ const proto = firstValue(request.headers.get("x-forwarded-proto"))?.toLowerCase() ?? null;
1515
+ const host = firstValue(request.headers.get("x-forwarded-host"));
1516
+ if (proto === null && host === null) return url.origin;
1517
+ if (proto !== null && proto !== "http" && proto !== "https") return url.origin;
1518
+ try {
1519
+ const built = new URL(`${proto ?? url.protocol.slice(0, -1)}://${host ?? url.host}`);
1520
+ if (built.pathname !== "/" || built.username || built.password || built.search || built.hash) return url.origin;
1521
+ return built.origin;
1522
+ } catch {
1523
+ return url.origin;
1524
+ }
1525
+ }
848
1526
  function stripBase(pathname, base) {
849
1527
  let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
850
1528
  if (path === "") path = "/";
@@ -869,9 +1547,9 @@ async function readBody(request) {
869
1547
  }
870
1548
  return {};
871
1549
  }
872
- function crossSite(request, url) {
1550
+ function crossSite(request, publicOrigin) {
873
1551
  const origin = request.headers.get("origin");
874
- if (origin !== null && origin !== url.origin) return true;
1552
+ if (origin !== null && origin !== publicOrigin) return true;
875
1553
  const site = request.headers.get("sec-fetch-site");
876
1554
  return site !== null && site !== "same-origin" && site !== "none";
877
1555
  }
@@ -888,48 +1566,61 @@ function runsLimit(value) {
888
1566
  }
889
1567
  function createRoutes(cw, options = {}) {
890
1568
  const optedOut = options.token === null;
891
- const configured = optedOut ? null : options.token || process.env.CRONWATCH_TOKEN || null;
1569
+ const configured = optedOut ? null : options.token || readEnv("CRONWATCH_TOKEN") || null;
892
1570
  const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
893
- const developing = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
1571
+ const developing = isDevelopment();
1572
+ const fixedOrigin = configuredOrigin(options.origin);
1573
+ const trustProxy = options.trustProxy === true;
1574
+ const originOf = (request, url) => fixedOrigin ?? (trustProxy ? forwardedOrigin(request, url) : url.origin);
894
1575
  const generated = !configured && !optedOut && developing;
895
1576
  const token = generated ? developmentToken() : configured;
896
1577
  let announced = false;
1578
+ let cookie = null;
1579
+ const expectedCookie = (value) => cookie ??= cookieValue(value);
897
1580
  const serve = async (request, url, path, wantsHtml) => {
898
1581
  const method = request.method.toUpperCase();
1582
+ const publicOrigin = originOf(request, url);
899
1583
  if (generated && !announced) {
900
1584
  announced = true;
901
- console.info(developmentSignInLine(url.origin, base, token));
1585
+ console.info(developmentSignInLine(publicOrigin, base, token));
1586
+ }
1587
+ if (method === "GET" || method === "HEAD") {
1588
+ if (path === "/offline") {
1589
+ return html(messagePage("You are offline", "CronWatch shows live data from your app, so it needs a connection.", base), 200, "no-cache");
1590
+ }
1591
+ const asset = staticAsset(path, base);
1592
+ if (asset) return shell(asset, base);
902
1593
  }
903
1594
  if (!token && !optedOut) {
904
1595
  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);
905
1596
  }
906
- if (method !== "GET" && method !== "HEAD" && crossSite(request, url)) {
1597
+ if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
907
1598
  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);
908
1599
  }
909
1600
  const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
910
1601
  if (token) {
911
1602
  const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
912
- const cookie = readCookie(request, COOKIE);
1603
+ const sent = readCookie(request, COOKIE);
913
1604
  const isCheck = path === "/api/check";
914
1605
  const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
915
- const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : cookie !== null && constantTimeEqual(cookie, cookieValue(token));
1606
+ const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
916
1607
  if (!cronSecretOk && !tokenOk) {
917
1608
  if (generated) {
918
- 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);
1609
+ 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);
919
1610
  }
920
- 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);
1611
+ 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);
921
1612
  }
922
1613
  if (query !== null) {
923
1614
  url.searchParams.delete("token");
924
- const secure = url.protocol === "https:" ? "; Secure" : "";
1615
+ const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
925
1616
  return redirect(url.pathname + (url.search || ""), {
926
- "set-cookie": `${COOKIE}=${cookieValue(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
1617
+ "set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
927
1618
  });
928
1619
  }
929
1620
  }
930
1621
  const redirectBack = () => {
931
1622
  const referer = request.headers.get("referer") ?? "";
932
- return redirect(referer.startsWith(url.origin + "/") ? referer : `${base}/`);
1623
+ return redirect(referer.startsWith(publicOrigin + "/") ? referer : `${base}/`);
933
1624
  };
934
1625
  const decoded = path.split("/").filter(Boolean).map(safeDecode);
935
1626
  if (decoded.some((part) => part === null)) {
@@ -937,14 +1628,18 @@ function createRoutes(cw, options = {}) {
937
1628
  }
938
1629
  const parts = decoded;
939
1630
  if (method === "GET" && path === "/") {
940
- const entries = await cw.jobsWithRuns(20);
1631
+ const entries = await cw.jobsWithRuns(BOARD_PAGE_RUNS);
1632
+ const now = cw.now();
941
1633
  const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
942
- return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, cw.now(), base));
1634
+ return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, now, base, null, await boardLanes(cw, entries, now)));
943
1635
  }
944
1636
  if (method === "GET" && parts[0] === "jobs" && parts.length === 2) {
945
1637
  const job = await cw.jobSummary(parts[1]);
946
1638
  if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
947
- return html(jobPage(job, await cw.runs(job.name, 50), cw.now(), base));
1639
+ const now = cw.now();
1640
+ const limit = weekRunsLimit(job, now);
1641
+ const runs = await cw.runs(job.name, limit);
1642
+ return html(jobPage(job, runs, now, base, runs.length < limit));
948
1643
  }
949
1644
  if (method === "POST" && path === "/check") {
950
1645
  await cw.check();
@@ -1042,12 +1737,18 @@ function api(body, status = 200, headers = {}) {
1042
1737
  function redirect(location, headers = {}) {
1043
1738
  return new Response(null, { status: 303, headers: { location, "cache-control": "no-store", ...SECURITY_HEADERS, ...headers } });
1044
1739
  }
1045
- function html(body, status = 200) {
1740
+ function shell(asset, base) {
1741
+ const headers = { "content-type": asset.type, "cache-control": asset.cache, ...SECURITY_HEADERS };
1742
+ if (asset.type === "image/svg+xml") headers["content-security-policy"] = ASSET_CSP;
1743
+ if (asset.worker) headers["service-worker-allowed"] = `${base}/`;
1744
+ return new Response(asset.body, { status: 200, headers });
1745
+ }
1746
+ function html(body, status = 200, cache2 = "no-store") {
1046
1747
  return new Response(body, {
1047
1748
  status,
1048
1749
  headers: {
1049
1750
  "content-type": "text/html; charset=utf-8",
1050
- "cache-control": "no-store",
1751
+ "cache-control": cache2,
1051
1752
  "content-security-policy": CSP,
1052
1753
  "x-frame-options": "DENY",
1053
1754
  ...SECURITY_HEADERS
@@ -1090,6 +1791,7 @@ function memory() {
1090
1791
  }
1091
1792
  },
1092
1793
  async insertRun(run) {
1794
+ if (runs.has(run.id)) throw new Error(`run ${run.id} already exists`);
1093
1795
  runs.set(run.id, clone(run));
1094
1796
  order.set(run.id, ++seq);
1095
1797
  },
@@ -1099,6 +1801,13 @@ function memory() {
1099
1801
  const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1100
1802
  runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1101
1803
  },
1804
+ async updateRunIf(run, fromStatuses) {
1805
+ const existing = runs.get(run.id);
1806
+ if (!existing || !fromStatuses.includes(existing.status)) return false;
1807
+ const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1808
+ runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1809
+ return true;
1810
+ },
1102
1811
  async getRun(id) {
1103
1812
  const r = runs.get(id);
1104
1813
  return r ? clone(r) : null;
@@ -1194,6 +1903,7 @@ var CronWatch = class {
1194
1903
  store;
1195
1904
  alerts;
1196
1905
  triage;
1906
+ sources;
1197
1907
  /** The secret handler() requests must carry, or null when none is set. */
1198
1908
  cronSecret;
1199
1909
  retentionMs;
@@ -1209,6 +1919,8 @@ var CronWatch = class {
1209
1919
  synced = /* @__PURE__ */ new Set();
1210
1920
  /** The tail of each job's queue of state updates. See serial(). */
1211
1921
  queues = /* @__PURE__ */ new Map();
1922
+ /** start() calls with an id still in flight, so two at once in this process record one run. */
1923
+ starting = /* @__PURE__ */ new Map();
1212
1924
  ready = null;
1213
1925
  checking = null;
1214
1926
  lastPruneAt = 0;
@@ -1221,7 +1933,8 @@ var CronWatch = class {
1221
1933
  this.store = options.store ?? (this.usingDefaultStore = true, memory());
1222
1934
  this.alerts = options.alerts ?? [consoleChannel()];
1223
1935
  this.triage = options.triage;
1224
- const secret = options.cronSecret === void 0 ? process.env.CRON_SECRET : options.cronSecret;
1936
+ this.sources = options.sources ?? [];
1937
+ const secret = options.cronSecret === void 0 ? readEnv("CRON_SECRET") : options.cronSecret;
1225
1938
  this.cronSecret = secret ? secret : null;
1226
1939
  this.secretOptOut = options.cronSecret === null;
1227
1940
  this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
@@ -1265,6 +1978,12 @@ var CronWatch = class {
1265
1978
  definedJobs() {
1266
1979
  return [...this.definitions.values()];
1267
1980
  }
1981
+ /** A handle on a run started elsewhere, as job(name).resume(runId). The job must be declared in this process. */
1982
+ resumeRun(name, runId) {
1983
+ const definition = this.definitions.get(name);
1984
+ if (!definition) return Promise.reject(new Error(`resumeRun: job "${name}" is not declared; call job() first`));
1985
+ return this.resumeHandle(definition, runId);
1986
+ }
1268
1987
  handle(definition) {
1269
1988
  const self = this;
1270
1989
  return {
@@ -1303,6 +2022,12 @@ var CronWatch = class {
1303
2022
  };
1304
2023
  return json(body, outcome.run.status === "ok" ? 200 : 500);
1305
2024
  };
2025
+ },
2026
+ start(options) {
2027
+ return self.startRun(definition, options ?? {});
2028
+ },
2029
+ resume(runId) {
2030
+ return self.resumeHandle(definition, runId);
1306
2031
  }
1307
2032
  };
1308
2033
  }
@@ -1322,7 +2047,7 @@ var CronWatch = class {
1322
2047
  if (!this.ready) {
1323
2048
  this.ready = (async () => {
1324
2049
  if (this.store.init) await this.store.init();
1325
- if (this.usingDefaultStore && process.env.NODE_ENV === "production") {
2050
+ if (this.usingDefaultStore && readEnv("NODE_ENV") === "production") {
1326
2051
  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.");
1327
2052
  }
1328
2053
  })().catch((error) => {
@@ -1439,6 +2164,21 @@ var CronWatch = class {
1439
2164
  run.durationMs = Math.max(0, finishedAt - startedAt);
1440
2165
  run.metrics = recorder.metrics();
1441
2166
  run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2167
+ this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
2168
+ await started;
2169
+ try {
2170
+ const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
2171
+ if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
2172
+ } catch (e) {
2173
+ this.onError(e, `recording ${name}`);
2174
+ }
2175
+ return { run, result, error, threw };
2176
+ }
2177
+ /**
2178
+ * Sets a finished run's status and error from how it ended, then redacts
2179
+ * its output and error. Shared by execute() and RunHandle.finish().
2180
+ */
2181
+ conclude(definition, run, result, error, threw, expectText) {
1442
2182
  if (threw) {
1443
2183
  run.status = "failed";
1444
2184
  run.error = errorMessage(error);
@@ -1446,7 +2186,6 @@ var CronWatch = class {
1446
2186
  run.status = "failed";
1447
2187
  run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
1448
2188
  } else {
1449
- const expectText = recorder.expectText() ?? (typeof result === "string" ? result : null);
1450
2189
  const unmet = checkExpectation(definition.expect, expectText);
1451
2190
  if (unmet) {
1452
2191
  run.status = "failed";
@@ -1457,44 +2196,336 @@ var CronWatch = class {
1457
2196
  }
1458
2197
  if (run.output !== null) run.output = stripNul(this.redact(run.output));
1459
2198
  if (run.error !== null) run.error = stripNul(this.redact(run.error));
1460
- await started;
2199
+ }
2200
+ /**
2201
+ * Writes a finished run and evaluates it. `recorded` says whether its
2202
+ * start was written; if not, it is inserted now. Returns why nothing was
2203
+ * recorded (another process finished the run first, say), or null. Throws
2204
+ * when the store does, so a handle can be finished again. Shared by
2205
+ * execute() and RunHandle.finish().
2206
+ */
2207
+ async recordFinish(definition, run, recorded, finishedAt) {
1461
2208
  if (!recorded) {
2209
+ await this.sync(definition);
1462
2210
  try {
1463
- await this.sync(definition);
1464
2211
  await this.store.insertRun(run);
1465
- recorded = true;
2212
+ await this.finishRun(toStored(definition), run, finishedAt);
2213
+ return null;
1466
2214
  } catch (e) {
1467
- this.onError(e, `recording ${name}`);
2215
+ const stored = await this.store.getRun(run.id).catch(() => null);
2216
+ if (!stored) throw e;
2217
+ if (stored.job !== run.job) return `belongs to job "${stored.job}"`;
1468
2218
  }
1469
- if (recorded) await this.finishRun(toStored(definition), run, finishedAt, false);
1470
- } else if (await this.markedTimedOut(run)) {
2219
+ }
2220
+ const claim = await this.claimFinish(run);
2221
+ if ("ignored" in claim) return claim.ignored;
2222
+ if (!claim.lateAfterTimeout || run.status === "ok") await this.finishRun(toStored(definition), run, finishedAt);
2223
+ return null;
2224
+ }
2225
+ /** A conditional write (Store.updateRunIf), or for a store without one, a read then a plain write. */
2226
+ async writeRunIf(run, fromStatuses) {
2227
+ if (typeof this.store.updateRunIf === "function") return this.store.updateRunIf(run, fromStatuses);
2228
+ const stored = await this.store.getRun(run.id);
2229
+ if (!stored || !fromStatuses.includes(stored.status)) return false;
2230
+ await this.store.updateRun(run);
2231
+ return true;
2232
+ }
2233
+ /**
2234
+ * Writes a finished run over its stored row, only while that row is still
2235
+ * running, or else still marked timeout by a check. Only the process whose
2236
+ * write lands goes on to evaluate the run; for the others it returns why
2237
+ * nothing was written. `lateAfterTimeout` means a check already counted
2238
+ * the run as a stuck failure: a late failure must not count twice, while
2239
+ * a late success still closes stuck and recovers. Throws when the store does.
2240
+ */
2241
+ async claimFinish(run) {
2242
+ if (await this.writeRunIf(run, ["running"])) return { lateAfterTimeout: false };
2243
+ if (await this.writeRunIf(run, ["timeout"])) return { lateAfterTimeout: true };
2244
+ const stored = await this.store.getRun(run.id);
2245
+ return { ignored: stored ? `was already finished as ${stored.status}` : "was not found" };
2246
+ }
2247
+ /** job.start(): records a running run and returns a handle to finish it. See JobHandle.start. */
2248
+ async startRun(definition, options) {
2249
+ const id = options.id;
2250
+ if (id === void 0) return this.recordStart(definition, options.trigger);
2251
+ checkRunId(definition.name, id, "start");
2252
+ const key = `${definition.name}
2253
+ ${id}`;
2254
+ const inFlight = this.starting.get(key);
2255
+ if (inFlight) return inFlight;
2256
+ const started = this.recordStart(definition, options.trigger, id).finally(() => {
2257
+ if (this.starting.get(key) === started) this.starting.delete(key);
2258
+ });
2259
+ this.starting.set(key, started);
2260
+ return started;
2261
+ }
2262
+ /**
2263
+ * The start of execute() without the function: the run is inserted and
2264
+ * missed and stuck close (onRunStart). A store that fails is reported and
2265
+ * the handle inserts the finished run instead, as execute() does.
2266
+ */
2267
+ async recordStart(definition, trigger = "start", id) {
2268
+ const name = definition.name;
2269
+ if (id !== void 0) {
2270
+ let stored = null;
1471
2271
  try {
1472
- await this.store.updateRun(run);
2272
+ await this.ensureReady();
2273
+ stored = await this.store.getRun(id);
1473
2274
  } catch (e) {
1474
2275
  this.onError(e, `recording ${name}`);
1475
2276
  }
1476
- } else {
1477
- await this.finishRun(toStored(definition), run, finishedAt, true);
2277
+ if (stored) return this.existingHandle(definition, stored);
1478
2278
  }
1479
- return { run, result, error, threw };
2279
+ const run = {
2280
+ id: id ?? crypto.randomUUID(),
2281
+ job: name,
2282
+ status: "running",
2283
+ startedAt: this.now(),
2284
+ finishedAt: null,
2285
+ durationMs: null,
2286
+ error: null,
2287
+ output: null,
2288
+ metrics: {},
2289
+ trigger
2290
+ };
2291
+ let recorded = false;
2292
+ try {
2293
+ await this.sync(definition);
2294
+ await this.store.insertRun({ ...run });
2295
+ recorded = true;
2296
+ } catch (e) {
2297
+ const stored = id === void 0 ? null : await this.store.getRun(id).catch(() => null);
2298
+ if (stored) return this.existingHandle(definition, stored);
2299
+ this.onError(e, `recording ${name}`);
2300
+ }
2301
+ if (recorded) {
2302
+ await this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
2303
+ }
2304
+ return this.runHandle(definition, run.id, run, recorded, null);
1480
2305
  }
1481
- /** Whether a check already marked this run as timed out, for a failure that finished late. */
1482
- async markedTimedOut(run) {
1483
- if (run.status === "ok") return false;
2306
+ /** job.resume() and cw.resumeRun(). A store that cannot be read is reported, and finish() reads it again. */
2307
+ async resumeHandle(definition, runId) {
2308
+ checkRunId(definition.name, runId, "resume");
2309
+ let stored;
1484
2310
  try {
1485
- return (await this.store.getRun(run.id))?.status === "timeout";
1486
- } catch {
1487
- return false;
2311
+ await this.ensureReady();
2312
+ stored = await this.store.getRun(runId);
2313
+ } catch (e) {
2314
+ this.onError(e, `resuming ${definition.name}`);
2315
+ return this.runHandle(definition, runId, null, true, null);
2316
+ }
2317
+ if (!stored) return this.runHandle(definition, runId, null, true, "was not found");
2318
+ return this.existingHandle(definition, stored);
2319
+ }
2320
+ /** A handle on a stored run. One still running, or marked timeout by a check, can be finished. */
2321
+ existingHandle(definition, stored) {
2322
+ if (stored.job !== definition.name) {
2323
+ throw new Error(`run "${stored.id}" belongs to job "${stored.job}", not "${definition.name}"`);
2324
+ }
2325
+ const finished = stored.status === "ok" || stored.status === "failed";
2326
+ return this.runHandle(definition, stored.id, stored, true, finished ? `already finished as ${stored.status}` : null);
2327
+ }
2328
+ /**
2329
+ * The handle itself. `base` is the run as last known here, `recorded`
2330
+ * whether its start is in the store, and `inactive` why finish() has
2331
+ * nothing to do, or null. Lines and metrics wait in the handle until
2332
+ * flush() or finish() merges them onto a fresh read of the stored run.
2333
+ */
2334
+ runHandle(definition, id, base, recorded, inactive) {
2335
+ const self = this;
2336
+ const name = definition.name;
2337
+ const fresh = () => createRecorder({ id, job: name, startedAt: base?.startedAt ?? 0 });
2338
+ let recorder = fresh();
2339
+ let finished = inactive !== null;
2340
+ let finishCalled = false;
2341
+ let head = null;
2342
+ let queue = Promise.resolve();
2343
+ const inTurn = (fn) => {
2344
+ const result = queue.then(fn);
2345
+ queue = result.catch(() => {
2346
+ });
2347
+ return result;
2348
+ };
2349
+ const ignored = (why) => self.report(new Error(`run ${id} of ${name} ${why}; ignored`), `finishing ${name}`);
2350
+ const finish = (outcome) => {
2351
+ if (finishCalled) {
2352
+ ignored("was already finished by this handle");
2353
+ return Promise.resolve(null);
2354
+ }
2355
+ finishCalled = true;
2356
+ const wasInactive = finished;
2357
+ finished = true;
2358
+ const retryable = (error) => {
2359
+ finishCalled = false;
2360
+ finished = false;
2361
+ self.report(error, `finishing ${name}`);
2362
+ return null;
2363
+ };
2364
+ return inTurn(async () => {
2365
+ if (wasInactive) {
2366
+ ignored(inactive);
2367
+ return null;
2368
+ }
2369
+ let from = base;
2370
+ if (recorded) {
2371
+ try {
2372
+ from = await self.store.getRun(id) ?? base;
2373
+ } catch (e) {
2374
+ return retryable(e);
2375
+ }
2376
+ }
2377
+ if (!from) {
2378
+ ignored("was not found");
2379
+ return null;
2380
+ }
2381
+ if (from.job !== name) {
2382
+ ignored(`belongs to job "${from.job}"`);
2383
+ return null;
2384
+ }
2385
+ if (from.status === "ok" || from.status === "failed") {
2386
+ ignored(`was already finished as ${from.status}`);
2387
+ return null;
2388
+ }
2389
+ const failed = typeof outcome === "object" && outcome !== null && "error" in outcome;
2390
+ const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
2391
+ const error = failed ? outcome.error : void 0;
2392
+ const finishedAt = self.now();
2393
+ const added = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2394
+ const run = {
2395
+ ...from,
2396
+ status: "running",
2397
+ finishedAt,
2398
+ durationMs: Math.max(0, finishedAt - from.startedAt),
2399
+ error: null,
2400
+ output: joinOutput(from.output, added),
2401
+ metrics: { ...from.metrics, ...recorder.metrics() }
2402
+ };
2403
+ const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
2404
+ self.conclude(definition, run, result, error, failed, expectText);
2405
+ let why;
2406
+ try {
2407
+ why = await self.recordFinish(definition, run, recorded, finishedAt);
2408
+ } catch (e) {
2409
+ return retryable(e);
2410
+ }
2411
+ if (why) {
2412
+ ignored(why);
2413
+ return null;
2414
+ }
2415
+ return run;
2416
+ });
2417
+ };
2418
+ return {
2419
+ id,
2420
+ job: name,
2421
+ startedAt: base?.startedAt ?? null,
2422
+ get active() {
2423
+ return !finished;
2424
+ },
2425
+ log: (...parts) => recorder.context.log(...parts),
2426
+ metric: (metric, value) => recorder.context.metric(metric, value),
2427
+ metrics: (values) => recorder.context.metrics(values),
2428
+ flush: () => inTurn(async () => {
2429
+ if (finished || !recorded) return;
2430
+ const lines = recorder.output();
2431
+ const metrics = recorder.metrics();
2432
+ if (lines === null && Object.keys(metrics).length === 0) return;
2433
+ const taken = recorder;
2434
+ recorder = fresh();
2435
+ const putBack = () => {
2436
+ const later = recorder;
2437
+ recorder = fresh();
2438
+ for (const text of [taken.expectText(), later.expectText()]) if (text !== null) recorder.context.log(text);
2439
+ recorder.context.metrics({ ...taken.metrics(), ...later.metrics() });
2440
+ };
2441
+ try {
2442
+ const stored = await self.store.getRun(id);
2443
+ if (!stored || stored.status !== "running") return putBack();
2444
+ if (stored.job !== name) {
2445
+ putBack();
2446
+ self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
2447
+ return;
2448
+ }
2449
+ const output = lines === null ? stored.output : joinOutput(stored.output, stripNul(self.redact(lines)));
2450
+ if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
2451
+ const text = taken.expectText();
2452
+ if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
2453
+ } catch (e) {
2454
+ putBack();
2455
+ self.report(e, `flushing ${name}`);
2456
+ }
2457
+ }),
2458
+ finish,
2459
+ fail: (error) => finish({ error })
2460
+ };
2461
+ }
2462
+ /**
2463
+ * Record a run that happened outside this process, for a Source. Its job
2464
+ * must be declared with job() first. Runs are keyed by id: a new one is
2465
+ * inserted, a stored one still running (or marked timeout by a check) is
2466
+ * finished when this one is not running, and anything else is left alone,
2467
+ * so recording the same run twice changes nothing. Finishing is
2468
+ * conditional (see Store.updateRunIf): when two processes record the same
2469
+ * finish, only the one whose write lands evaluates it, and the other
2470
+ * reports it as already finished. A stored run of another job is left
2471
+ * alone and reported. A finished run is judged as if it had been wrapped
2472
+ * here (expect, failures, duration, budgets) and its output and error are
2473
+ * redacted the same way; one finishing after a check marked it timeout is
2474
+ * judged only when it succeeded, as RunHandle.finish() does. Returns the
2475
+ * alerts it sent.
2476
+ */
2477
+ async recordRun(input, options = {}) {
2478
+ const declared = this.definitions.get(input.job);
2479
+ if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
2480
+ await this.sync(declared);
2481
+ const run = { ...input, metrics: { ...input.metrics } };
2482
+ if (run.status === "ok") {
2483
+ const unmet = checkExpectation(declared.expect, run.output);
2484
+ if (unmet) {
2485
+ run.status = "failed";
2486
+ run.error = unmet;
2487
+ }
2488
+ }
2489
+ if (run.output !== null) run.output = stripNul(this.redact(capOutput(run.output)));
2490
+ if (run.error !== null) run.error = stripNul(this.redact(capOutput(run.error)));
2491
+ const evaluate = options.evaluate !== false;
2492
+ const definition = toStored(declared);
2493
+ const stored = await this.store.getRun(run.id);
2494
+ if (stored) return this.recordOver(definition, stored, run, evaluate);
2495
+ try {
2496
+ await this.store.insertRun(run);
2497
+ } catch (e) {
2498
+ const again = await this.store.getRun(run.id).catch(() => null);
2499
+ if (again) return this.recordOver(definition, again, run, evaluate);
2500
+ throw e;
2501
+ }
2502
+ if (!evaluate) return [];
2503
+ await this.updateState(run.job, (before) => ({ state: onRunStart(before), result: void 0 }));
2504
+ if (run.status === "running") return [];
2505
+ return this.finishRun(definition, run, this.now());
2506
+ }
2507
+ /** recordRun() for a run already stored. */
2508
+ async recordOver(definition, stored, run, evaluate) {
2509
+ if (stored.job !== run.job) {
2510
+ this.report(new Error(`run ${run.id} of ${run.job} belongs to job "${stored.job}"; ignored`), `recording ${run.job}`);
2511
+ return [];
2512
+ }
2513
+ if (stored.status !== "running" && stored.status !== "timeout" || run.status === "running") return [];
2514
+ const claim = await this.claimFinish(run);
2515
+ if ("ignored" in claim) {
2516
+ this.report(new Error(`run ${run.id} of ${run.job} ${claim.ignored}; ignored`), `recording ${run.job}`);
2517
+ return [];
1488
2518
  }
2519
+ if (!evaluate || claim.lateAfterTimeout && run.status !== "ok") return [];
2520
+ return this.finishRun(definition, run, this.now());
1489
2521
  }
1490
2522
  /**
1491
- * Record a finished run (ok, failed, or timed out by a check), evaluate it
1492
- * against the job's state and send what that produces. Never throws.
2523
+ * Evaluate a finished run (ok, failed, or timed out by a check), already
2524
+ * written, against the job's state and send what that produces. Never throws.
1493
2525
  */
1494
- async finishRun(definition, run, now, write) {
2526
+ async finishRun(definition, run, now) {
1495
2527
  let drafts;
1496
2528
  try {
1497
- if (write) await this.store.updateRun(run);
1498
2529
  let history = null;
1499
2530
  ({ result: drafts } = await this.updateState(run.job, async (previous) => {
1500
2531
  history ??= await this.history(run);
@@ -1535,9 +2566,16 @@ var CronWatch = class {
1535
2566
  }
1536
2567
  async runCheck() {
1537
2568
  await this.ensureReady();
2569
+ const alerts = [];
2570
+ for (const source of this.sources) {
2571
+ try {
2572
+ alerts.push(...await source.sync(this) ?? []);
2573
+ } catch (e) {
2574
+ this.onError(e, `source ${source.name}`);
2575
+ }
2576
+ }
1538
2577
  for (const definition of this.definitions.values()) await this.sync(definition);
1539
2578
  const now = this.now();
1540
- const alerts = [];
1541
2579
  for (const run of await this.store.runningRuns()) {
1542
2580
  try {
1543
2581
  const declared = this.definitions.get(run.job);
@@ -1548,7 +2586,8 @@ var CronWatch = class {
1548
2586
  run.finishedAt = now;
1549
2587
  run.durationMs = now - run.startedAt;
1550
2588
  run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
1551
- alerts.push(...await this.finishRun(definition, run, now, true));
2589
+ if (!await this.writeRunIf(run, ["running"])) continue;
2590
+ alerts.push(...await this.finishRun(definition, run, now));
1552
2591
  } catch (e) {
1553
2592
  this.onError(e, `checking ${run.job}`);
1554
2593
  }
@@ -1666,7 +2705,11 @@ var CronWatch = class {
1666
2705
  routes(options) {
1667
2706
  return createRoutes(this, options);
1668
2707
  }
1669
- /** Check on an interval, for long-running servers. Default every minute. */
2708
+ /**
2709
+ * Check on an interval, for long-running servers. Default every minute.
2710
+ * Not for serverless functions or Cloudflare Workers, where nothing runs
2711
+ * between requests: call check() from a cron there instead.
2712
+ */
1670
2713
  start(every = "1m") {
1671
2714
  if (this.timer) return;
1672
2715
  const ms = Math.max(5e3, parseDuration(every, "check interval"));
@@ -1775,7 +2818,7 @@ var CronWatch = class {
1775
2818
  const results = await Promise.all(
1776
2819
  this.alerts.map(async (channel) => {
1777
2820
  try {
1778
- await withTimeout(channel.send(alert), CHANNEL_TIMEOUT_MS);
2821
+ await withTimeout(channel.send(alert, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
1779
2822
  return true;
1780
2823
  } catch (e) {
1781
2824
  this.onError(e, `alert channel ${channel.name}`);
@@ -1799,8 +2842,24 @@ var CronWatch = class {
1799
2842
  }
1800
2843
  }
1801
2844
  };
1802
- function isDevelopment() {
1803
- return process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
2845
+ var RESERVED_RUN_ID_PREFIX = "pgcron:";
2846
+ function checkRunId(job, id, method) {
2847
+ if (typeof id !== "string" || id.length === 0 || id.length > 200) {
2848
+ throw new Error(`job "${job}": ${method}() needs a run id of 1 to 200 characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
2849
+ }
2850
+ if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
2851
+ 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`);
2852
+ }
2853
+ }
2854
+ function joinLines(before, after) {
2855
+ if (before === null || before === "") return after;
2856
+ if (after === null) return before;
2857
+ return `${before}
2858
+ ${after}`;
2859
+ }
2860
+ function joinOutput(before, after) {
2861
+ const joined = joinLines(before, after);
2862
+ return joined === null ? null : capOutput(joined);
1804
2863
  }
1805
2864
  function clampLimit(limit, fallback, min) {
1806
2865
  const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;