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