@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.
- package/README.md +8 -2
- package/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/bugsnag.cjs +108 -0
- package/dist/bugsnag.cjs.map +1 -0
- package/dist/bugsnag.d.cts +26 -0
- package/dist/bugsnag.d.ts +26 -0
- package/dist/bugsnag.js +106 -0
- package/dist/bugsnag.js.map +1 -0
- package/dist/client-B7bqnkI5.d.cts +450 -0
- package/dist/client-pmqH3apS.d.ts +450 -0
- package/dist/d1.cjs +214 -0
- package/dist/d1.cjs.map +1 -0
- package/dist/d1.d.cts +42 -0
- package/dist/d1.d.ts +42 -0
- package/dist/d1.js +212 -0
- package/dist/d1.js.map +1 -0
- package/dist/datadog.cjs +95 -0
- package/dist/datadog.cjs.map +1 -0
- package/dist/datadog.d.cts +24 -0
- package/dist/datadog.d.ts +24 -0
- package/dist/datadog.js +93 -0
- package/dist/datadog.js.map +1 -0
- package/dist/discord.cjs +2 -0
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.d.cts +1 -1
- package/dist/discord.d.ts +1 -1
- package/dist/discord.js +2 -0
- package/dist/discord.js.map +1 -1
- package/dist/email-BJ-z_epD.d.cts +21 -0
- package/dist/email-_H0faCh3.d.ts +21 -0
- package/dist/honeybadger.cjs +101 -0
- package/dist/honeybadger.cjs.map +1 -0
- package/dist/honeybadger.d.cts +23 -0
- package/dist/honeybadger.d.ts +23 -0
- package/dist/honeybadger.js +99 -0
- package/dist/honeybadger.js.map +1 -0
- package/dist/index.cjs +1238 -179
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -260
- package/dist/index.d.ts +5 -260
- package/dist/index.js +1238 -179
- package/dist/index.js.map +1 -1
- package/dist/mailgun.cjs +115 -0
- package/dist/mailgun.cjs.map +1 -0
- package/dist/mailgun.d.cts +21 -0
- package/dist/mailgun.d.ts +21 -0
- package/dist/mailgun.js +113 -0
- package/dist/mailgun.js.map +1 -0
- package/dist/newrelic.cjs +88 -0
- package/dist/newrelic.cjs.map +1 -0
- package/dist/newrelic.d.cts +25 -0
- package/dist/newrelic.d.ts +25 -0
- package/dist/newrelic.js +86 -0
- package/dist/newrelic.js.map +1 -0
- package/dist/node.cjs +191 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +97 -0
- package/dist/node.d.ts +97 -0
- package/dist/node.js +186 -0
- package/dist/node.js.map +1 -0
- package/dist/pg-cron.cjs +286 -0
- package/dist/pg-cron.cjs.map +1 -0
- package/dist/pg-cron.d.cts +93 -0
- package/dist/pg-cron.d.ts +93 -0
- package/dist/pg-cron.js +280 -0
- package/dist/pg-cron.js.map +1 -0
- package/dist/postgres.cjs +20 -0
- package/dist/postgres.cjs.map +1 -1
- package/dist/postgres.d.cts +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.js +20 -0
- package/dist/postgres.js.map +1 -1
- package/dist/postmark.cjs +106 -0
- package/dist/postmark.cjs.map +1 -0
- package/dist/postmark.d.cts +18 -0
- package/dist/postmark.d.ts +18 -0
- package/dist/postmark.js +104 -0
- package/dist/postmark.js.map +1 -0
- package/dist/resend.cjs +114 -0
- package/dist/resend.cjs.map +1 -0
- package/dist/resend.d.cts +16 -0
- package/dist/resend.d.ts +16 -0
- package/dist/resend.js +112 -0
- package/dist/resend.js.map +1 -0
- package/dist/rollbar.cjs +108 -0
- package/dist/rollbar.cjs.map +1 -0
- package/dist/rollbar.d.cts +20 -0
- package/dist/rollbar.d.ts +20 -0
- package/dist/rollbar.js +106 -0
- package/dist/rollbar.js.map +1 -0
- package/dist/sendgrid.cjs +114 -0
- package/dist/sendgrid.cjs.map +1 -0
- package/dist/sendgrid.d.cts +19 -0
- package/dist/sendgrid.d.ts +19 -0
- package/dist/sendgrid.js +112 -0
- package/dist/sendgrid.js.map +1 -0
- package/dist/sentry.cjs +130 -0
- package/dist/sentry.cjs.map +1 -0
- package/dist/sentry.d.cts +28 -0
- package/dist/sentry.d.ts +28 -0
- package/dist/sentry.js +127 -0
- package/dist/sentry.js.map +1 -0
- package/dist/ses.cjs +170 -0
- package/dist/ses.cjs.map +1 -0
- package/dist/ses.d.cts +25 -0
- package/dist/ses.d.ts +25 -0
- package/dist/ses.js +168 -0
- package/dist/ses.js.map +1 -0
- package/dist/slack.cjs +2 -0
- package/dist/slack.cjs.map +1 -1
- package/dist/slack.d.cts +1 -1
- package/dist/slack.d.ts +1 -1
- package/dist/slack.js +2 -0
- package/dist/slack.js.map +1 -1
- package/dist/sqlite.cjs +18 -0
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +1 -1
- package/dist/sqlite.d.ts +1 -1
- package/dist/sqlite.js +18 -0
- package/dist/sqlite.js.map +1 -1
- package/dist/twilio.cjs +168 -0
- package/dist/twilio.cjs.map +1 -0
- package/dist/twilio.d.cts +52 -0
- package/dist/twilio.d.ts +52 -0
- package/dist/twilio.js +162 -0
- package/dist/twilio.js.map +1 -0
- package/dist/{types-DUt2apl0.d.cts → types-Ddq1MUPL.d.cts} +29 -2
- package/dist/{types-DUt2apl0.d.ts → types-Ddq1MUPL.d.ts} +29 -2
- package/dist/webhook.cjs +11 -5
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +6 -3
- package/dist/webhook.d.ts +6 -3
- package/dist/webhook.js +11 -6
- package/dist/webhook.js.map +1 -1
- 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)
|
|
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/
|
|
688
|
+
// src/routes/escape.ts
|
|
644
689
|
function escapeHtml(value) {
|
|
645
690
|
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 = "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";
|
|
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{
|
|
650
|
-
@media(prefers-color-scheme:dark){:root{--
|
|
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(--
|
|
653
|
-
a{color:inherit
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
.
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
.
|
|
663
|
-
.
|
|
664
|
-
.
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
.
|
|
668
|
-
form.inline{display:inline}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
.
|
|
672
|
-
.
|
|
673
|
-
.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
.
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
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><
|
|
1273
|
+
<body><div class="sheet">${body}</div></body>
|
|
695
1274
|
</html>`;
|
|
696
1275
|
}
|
|
697
|
-
function
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
|
1293
|
+
function runState(run) {
|
|
711
1294
|
const cls = run.status === "ok" ? "ok" : run.status === "running" ? "info" : "bad";
|
|
712
|
-
return `<span class="
|
|
1295
|
+
return `<span class="state ${cls}">${h(run.status)}</span>`;
|
|
713
1296
|
}
|
|
714
1297
|
function sparkline(runs) {
|
|
715
|
-
const points = [...runs].
|
|
1298
|
+
const points = [...runs].slice(0, 20).reverse();
|
|
716
1299
|
if (points.length < 2) return "";
|
|
717
|
-
const
|
|
718
|
-
const max = Math.max(...points.map((r) => r.durationMs), 1);
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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()
|
|
727
|
-
return `<
|
|
728
|
-
}
|
|
729
|
-
function
|
|
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="
|
|
734
|
-
<td>${
|
|
735
|
-
<td class="
|
|
736
|
-
<td class="nowrap">${last ? `${
|
|
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
|
-
|
|
1341
|
+
<header class="top">
|
|
1342
|
+
${brand(base)}
|
|
744
1343
|
<div class="actions">
|
|
745
|
-
<span class="meta">${
|
|
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
|
-
<
|
|
750
|
-
|
|
751
|
-
<
|
|
752
|
-
<
|
|
753
|
-
</
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
|
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="
|
|
766
|
-
return `<tr>
|
|
767
|
-
<td class="nowrap">${
|
|
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="
|
|
772
|
-
</tr>${detail ? `<tr><td colspan="5"
|
|
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
|
-
|
|
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
|
-
<
|
|
785
|
-
|
|
786
|
-
<
|
|
787
|
-
<div
|
|
788
|
-
|
|
789
|
-
</
|
|
790
|
-
<
|
|
791
|
-
<
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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.
|
|
800
|
-
${
|
|
801
|
-
${job.
|
|
802
|
-
|
|
803
|
-
</
|
|
804
|
-
</
|
|
805
|
-
<
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
</
|
|
810
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
1550
|
+
function crossSite(request, publicOrigin) {
|
|
873
1551
|
const origin = request.headers.get("origin");
|
|
874
|
-
if (origin !== null && origin !==
|
|
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 ||
|
|
1569
|
+
const configured = optedOut ? null : options.token || readEnv("CRONWATCH_TOKEN") || null;
|
|
892
1570
|
const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
|
|
893
|
-
const developing =
|
|
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(
|
|
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,
|
|
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
|
|
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) :
|
|
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 =
|
|
1615
|
+
const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
|
|
925
1616
|
return redirect(url.pathname + (url.search || ""), {
|
|
926
|
-
"set-cookie": `${COOKIE}=${
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
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":
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
2212
|
+
await this.finishRun(toStored(definition), run, finishedAt);
|
|
2213
|
+
return null;
|
|
1466
2214
|
} catch (e) {
|
|
1467
|
-
this.
|
|
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
|
-
|
|
1470
|
-
|
|
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.
|
|
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
|
-
|
|
1477
|
-
await this.finishRun(toStored(definition), run, finishedAt, true);
|
|
2277
|
+
if (stored) return this.existingHandle(definition, stored);
|
|
1478
2278
|
}
|
|
1479
|
-
|
|
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
|
-
/**
|
|
1482
|
-
async
|
|
1483
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1803
|
-
|
|
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;
|