@cronwatch/sdk 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bugsnag.cjs +11 -1
- package/dist/bugsnag.cjs.map +1 -1
- package/dist/bugsnag.js +11 -1
- package/dist/bugsnag.js.map +1 -1
- package/dist/datadog.cjs +11 -1
- package/dist/datadog.cjs.map +1 -1
- package/dist/datadog.js +11 -1
- package/dist/datadog.js.map +1 -1
- package/dist/discord.cjs +13 -1
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.js +13 -1
- package/dist/discord.js.map +1 -1
- package/dist/honeybadger.cjs +11 -1
- package/dist/honeybadger.cjs.map +1 -1
- package/dist/honeybadger.js +11 -1
- package/dist/honeybadger.js.map +1 -1
- package/dist/index.cjs +776 -134
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +776 -134
- package/dist/index.js.map +1 -1
- package/dist/mailgun.cjs +11 -1
- package/dist/mailgun.cjs.map +1 -1
- package/dist/mailgun.js +11 -1
- package/dist/mailgun.js.map +1 -1
- package/dist/newrelic.cjs +11 -1
- package/dist/newrelic.cjs.map +1 -1
- package/dist/newrelic.js +11 -1
- package/dist/newrelic.js.map +1 -1
- package/dist/postmark.cjs +11 -1
- package/dist/postmark.cjs.map +1 -1
- package/dist/postmark.js +11 -1
- package/dist/postmark.js.map +1 -1
- package/dist/resend.cjs +11 -1
- package/dist/resend.cjs.map +1 -1
- package/dist/resend.js +11 -1
- package/dist/resend.js.map +1 -1
- package/dist/rollbar.cjs +11 -1
- package/dist/rollbar.cjs.map +1 -1
- package/dist/rollbar.js +11 -1
- package/dist/rollbar.js.map +1 -1
- package/dist/sendgrid.cjs +11 -1
- package/dist/sendgrid.cjs.map +1 -1
- package/dist/sendgrid.js +11 -1
- package/dist/sendgrid.js.map +1 -1
- package/dist/sentry.cjs +11 -1
- package/dist/sentry.cjs.map +1 -1
- package/dist/sentry.js +11 -1
- package/dist/sentry.js.map +1 -1
- package/dist/ses.cjs +11 -1
- package/dist/ses.cjs.map +1 -1
- package/dist/ses.js +11 -1
- package/dist/ses.js.map +1 -1
- package/dist/slack.cjs +13 -1
- package/dist/slack.cjs.map +1 -1
- package/dist/slack.js +13 -1
- package/dist/slack.js.map +1 -1
- package/dist/twilio.cjs +11 -1
- package/dist/twilio.cjs.map +1 -1
- package/dist/twilio.js +11 -1
- package/dist/twilio.js.map +1 -1
- package/dist/webhook.cjs +13 -1
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.js +13 -1
- package/dist/webhook.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -104,6 +104,28 @@ function fireAfter(parsed, from) {
|
|
|
104
104
|
}
|
|
105
105
|
return null;
|
|
106
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
|
+
}
|
|
107
129
|
function nextFire(parsed, from, lastRunAt) {
|
|
108
130
|
if (parsed.kind === "interval") {
|
|
109
131
|
const base = lastRunAt ?? from;
|
|
@@ -665,178 +687,770 @@ function checkExpectation(expect, output) {
|
|
|
665
687
|
return ok ? null : "Output did not pass the expect() check";
|
|
666
688
|
}
|
|
667
689
|
|
|
668
|
-
// src/routes/
|
|
690
|
+
// src/routes/escape.ts
|
|
669
691
|
function escapeHtml(value) {
|
|
670
692
|
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
671
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
|
|
672
1098
|
var h = escapeHtml;
|
|
673
1099
|
var CSS = `
|
|
674
|
-
:root{
|
|
675
|
-
@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}}
|
|
676
1102
|
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
|
677
|
-
body{margin:0;background:var(--
|
|
678
|
-
a{color:inherit
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
.
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
.
|
|
688
|
-
.
|
|
689
|
-
.
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
.
|
|
693
|
-
form.inline{display:inline}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
.
|
|
697
|
-
.
|
|
698
|
-
.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
.
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
+
}
|
|
707
1250
|
`;
|
|
708
|
-
|
|
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);
|
|
709
1254
|
return `<!doctype html>
|
|
710
1255
|
<html lang="en">
|
|
711
1256
|
<head>
|
|
712
1257
|
<meta charset="utf-8">
|
|
713
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1258
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
714
1259
|
<meta name="robots" content="noindex,nofollow">
|
|
1260
|
+
<meta name="color-scheme" content="light dark">
|
|
715
1261
|
${options.refresh ? `<meta http-equiv="refresh" content="${options.refresh}">` : ""}
|
|
716
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>
|
|
717
1273
|
<style>${CSS}</style>
|
|
718
1274
|
</head>
|
|
719
|
-
<body><
|
|
1275
|
+
<body><div class="sheet">${body}</div></body>
|
|
720
1276
|
</html>`;
|
|
721
1277
|
}
|
|
722
|
-
function
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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}`;
|
|
734
1294
|
}
|
|
735
|
-
function
|
|
1295
|
+
function runState(run) {
|
|
736
1296
|
const cls = run.status === "ok" ? "ok" : run.status === "running" ? "info" : "bad";
|
|
737
|
-
return `<span class="
|
|
1297
|
+
return `<span class="state ${cls}">${h(run.status)}</span>`;
|
|
738
1298
|
}
|
|
739
1299
|
function sparkline(runs) {
|
|
740
|
-
const points = [...runs].
|
|
1300
|
+
const points = [...runs].slice(0, 20).reverse();
|
|
741
1301
|
if (points.length < 2) return "";
|
|
742
|
-
const
|
|
743
|
-
const max = Math.max(...points.map((r) => r.durationMs), 1);
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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>`;
|
|
748
1313
|
}
|
|
749
1314
|
function stamp(at, now) {
|
|
750
1315
|
if (at === null) return `<span class="muted">never</span>`;
|
|
751
|
-
const iso = new Date(at).toISOString()
|
|
752
|
-
return `<
|
|
753
|
-
}
|
|
754
|
-
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>.`;
|
|
755
1329
|
const rows = jobs.map((job) => {
|
|
756
1330
|
const last = job.lastRun;
|
|
1331
|
+
const d = job.definition;
|
|
757
1332
|
return `<tr>
|
|
758
|
-
<td class="
|
|
759
|
-
<td>${
|
|
760
|
-
<td class="
|
|
761
|
-
<td class="nowrap">${last ? `${
|
|
762
|
-
<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>
|
|
763
1338
|
<td class="hide-sm">${sparkline(runsByJob.get(job.name) ?? [])}</td>
|
|
764
1339
|
</tr>`;
|
|
765
1340
|
}).join("\n");
|
|
1341
|
+
const span = { from: now - BOARD_BEHIND_MS, to: now + BOARD_AHEAD_MS, now };
|
|
766
1342
|
const body = `
|
|
767
|
-
<header>
|
|
768
|
-
|
|
1343
|
+
<header class="top">
|
|
1344
|
+
${brand(base)}
|
|
769
1345
|
<div class="actions">
|
|
770
|
-
<span class="meta">${
|
|
771
|
-
<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>
|
|
772
1348
|
</div>
|
|
773
1349
|
</header>
|
|
774
|
-
<
|
|
775
|
-
|
|
776
|
-
<
|
|
777
|
-
<
|
|
778
|
-
</
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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) {
|
|
783
1375
|
const d = job.definition;
|
|
784
1376
|
const okRate = `${Math.round(job.stats.okRate * 100)}%`;
|
|
785
|
-
const
|
|
1377
|
+
const listed = runs.slice(0, 50);
|
|
1378
|
+
const runRows = listed.map((run) => {
|
|
786
1379
|
const detail = [
|
|
787
|
-
run.error ? `<details open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
|
|
788
|
-
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>` : ""
|
|
789
1382
|
].join("");
|
|
790
|
-
const metrics = Object.entries(run.metrics).map(([k, v]) => `<span class="
|
|
791
|
-
return `<tr>
|
|
792
|
-
<td class="nowrap">${
|
|
793
|
-
<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>
|
|
794
1387
|
<td class="nowrap">${run.durationMs !== null ? h(formatDuration(run.durationMs)) : `<span class="muted">running</span>`}</td>
|
|
795
|
-
<td class="hide-sm">${metrics}</td>
|
|
796
|
-
<td class="
|
|
797
|
-
</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>` : ""}`;
|
|
798
1391
|
}).join("\n");
|
|
799
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);
|
|
800
1396
|
const body = `
|
|
801
|
-
<header>
|
|
802
|
-
|
|
803
|
-
<div class="actions">
|
|
804
|
-
${healthPill(job)}
|
|
805
|
-
${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>`}
|
|
806
|
-
<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>
|
|
807
|
-
</div>
|
|
1397
|
+
<header class="top">
|
|
1398
|
+
${brand(base, job.name)}
|
|
1399
|
+
<div class="actions"><span class="meta">${h(clock(now))} UTC</span></div>
|
|
808
1400
|
</header>
|
|
809
|
-
<
|
|
810
|
-
|
|
811
|
-
<
|
|
812
|
-
<div
|
|
813
|
-
|
|
814
|
-
</
|
|
815
|
-
<
|
|
816
|
-
<
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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>` : ""}
|
|
823
1441
|
${d.failuresBeforeAlert && d.failuresBeforeAlert > 1 ? `<dt>Alert after</dt><dd>${h(d.failuresBeforeAlert)} consecutive failures</dd>` : ""}
|
|
824
|
-
${d.
|
|
825
|
-
${
|
|
826
|
-
${job.
|
|
827
|
-
|
|
828
|
-
</
|
|
829
|
-
</
|
|
830
|
-
<
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
</
|
|
835
|
-
|
|
836
|
-
return layout(`${job.name}: CronWatch`, body, { refresh: 60 });
|
|
837
|
-
}
|
|
838
|
-
function messagePage(title, message, base) {
|
|
839
|
-
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);
|
|
840
1454
|
}
|
|
841
1455
|
|
|
842
1456
|
// src/routes/index.ts
|
|
@@ -847,7 +1461,18 @@ async function cookieValue(token) {
|
|
|
847
1461
|
}
|
|
848
1462
|
var DEFAULT_RUNS = 20;
|
|
849
1463
|
var MAX_RUNS = 500;
|
|
850
|
-
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'";
|
|
851
1476
|
var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
|
|
852
1477
|
function developmentToken() {
|
|
853
1478
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
@@ -961,6 +1586,13 @@ function createRoutes(cw, options = {}) {
|
|
|
961
1586
|
announced = true;
|
|
962
1587
|
console.info(developmentSignInLine(publicOrigin, base, token));
|
|
963
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);
|
|
1595
|
+
}
|
|
964
1596
|
if (!token && !optedOut) {
|
|
965
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);
|
|
966
1598
|
}
|
|
@@ -976,9 +1608,9 @@ function createRoutes(cw, options = {}) {
|
|
|
976
1608
|
const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
|
|
977
1609
|
if (!cronSecretOk && !tokenOk) {
|
|
978
1610
|
if (generated) {
|
|
979
|
-
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);
|
|
980
1612
|
}
|
|
981
|
-
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);
|
|
982
1614
|
}
|
|
983
1615
|
if (query !== null) {
|
|
984
1616
|
url.searchParams.delete("token");
|
|
@@ -998,14 +1630,18 @@ function createRoutes(cw, options = {}) {
|
|
|
998
1630
|
}
|
|
999
1631
|
const parts = decoded;
|
|
1000
1632
|
if (method === "GET" && path === "/") {
|
|
1001
|
-
const entries = await cw.jobsWithRuns(
|
|
1633
|
+
const entries = await cw.jobsWithRuns(BOARD_PAGE_RUNS);
|
|
1634
|
+
const now = cw.now();
|
|
1002
1635
|
const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
|
|
1003
|
-
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)));
|
|
1004
1637
|
}
|
|
1005
1638
|
if (method === "GET" && parts[0] === "jobs" && parts.length === 2) {
|
|
1006
1639
|
const job = await cw.jobSummary(parts[1]);
|
|
1007
1640
|
if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
|
|
1008
|
-
|
|
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));
|
|
1009
1645
|
}
|
|
1010
1646
|
if (method === "POST" && path === "/check") {
|
|
1011
1647
|
await cw.check();
|
|
@@ -1103,12 +1739,18 @@ function api(body, status = 200, headers = {}) {
|
|
|
1103
1739
|
function redirect(location, headers = {}) {
|
|
1104
1740
|
return new Response(null, { status: 303, headers: { location, "cache-control": "no-store", ...SECURITY_HEADERS, ...headers } });
|
|
1105
1741
|
}
|
|
1106
|
-
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") {
|
|
1107
1749
|
return new Response(body, {
|
|
1108
1750
|
status,
|
|
1109
1751
|
headers: {
|
|
1110
1752
|
"content-type": "text/html; charset=utf-8",
|
|
1111
|
-
"cache-control":
|
|
1753
|
+
"cache-control": cache2,
|
|
1112
1754
|
"content-security-policy": CSP,
|
|
1113
1755
|
"x-frame-options": "DENY",
|
|
1114
1756
|
...SECURITY_HEADERS
|