@cronwatch/sdk 0.4.0 → 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/dist/index.js CHANGED
@@ -102,6 +102,28 @@ function fireAfter(parsed, from) {
102
102
  }
103
103
  return null;
104
104
  }
105
+ function firesBetween(parsed, from, to, limit) {
106
+ const cron = crons.get(parsed);
107
+ if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
108
+ const out = [];
109
+ let probe = from;
110
+ let last = from;
111
+ for (let guard = 0; guard < 1e3; guard++) {
112
+ const batch = cron.nextRuns(Math.min(limit + 1 - out.length, 24), new Date(probe));
113
+ if (batch.length === 0) return out;
114
+ for (const date of batch) {
115
+ const t = date.getTime();
116
+ if (t <= last) continue;
117
+ if (t > to) return out;
118
+ out.push(t);
119
+ last = t;
120
+ if (out.length > limit) return null;
121
+ }
122
+ const end = batch[batch.length - 1].getTime();
123
+ probe = end > probe ? end : probe + 36e5;
124
+ }
125
+ return out;
126
+ }
105
127
  function nextFire(parsed, from, lastRunAt) {
106
128
  if (parsed.kind === "interval") {
107
129
  const base = lastRunAt ?? from;
@@ -663,178 +685,770 @@ function checkExpectation(expect, output) {
663
685
  return ok ? null : "Output did not pass the expect() check";
664
686
  }
665
687
 
666
- // src/routes/html.ts
688
+ // src/routes/escape.ts
667
689
  function escapeHtml(value) {
668
690
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
669
691
  }
692
+
693
+ // src/routes/icons.ts
694
+ var ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="10" fill="#141417"/><rect x="0.5" y="0.5" width="39" height="39" rx="9.5" fill="none" stroke="#2a2a2f"/><circle cx="20" cy="20" r="10.5" fill="none" stroke="#f3f3f1" stroke-width="2"/><path d="M20 12.5V20h6" fill="none" stroke="#f3f3f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
695
+ var MASKABLE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" fill="#141417"/><circle cx="20" cy="20" r="10.5" fill="none" stroke="#f3f3f1" stroke-width="2"/><path d="M20 12.5V20h6" fill="none" stroke="#f3f3f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
696
+ var ICON_192_PNG = "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";
697
+ var ICON_512_PNG = "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";
698
+ var MASKABLE_512_PNG = "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";
699
+ var APPLE_TOUCH_ICON_PNG = "iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAMAAAAKE/YAAAAAM1BMVEUUFBciIiU+PkBaWlt2dnaRkZKfn5+tra27u7tMTE7X19bz8/FoaGnl5eMwMDKEhITJycgc8OlvAAAFSklEQVR42u2caZPjKAyGOZ042XXm///LSVXaB9fW7vYYfGTawiJO1+j91IcNj4UQAoMZI5FIJBKJRCKRSCQSiUQikUgkEolEIpG+izh+iZ9ijIVPvTd0baRclhic0+17QvOrqfzT/4pBP8K7QUulvrzGWvdG0A0328oJOtwxLISA7IXf+OzcB/ZXfzw01wJUhgwsHO0eqvbge0Rrj4SuTybrPt23h7nHLayZWVh3Cp0IIfRa9UyteIPnl/4YSzfD8on1w1/n8aF5iOuyQVx1PwBaXuZmdvbyDKT5UPMnFB/u5dDqNGPo3O+7pJDn2QW9fTG0rmYBwc1dt2bzzsblLNQM5qXQlZ6a/eeij3rPhFj+eWpcM7wwekyZzbJPaR4YC57NPaavJ71XcvcyS0+YQ7+sWNSfP7RLP5cnvtfWcq8/m96v5Xy/AvLyn8GmlUrmXwI9iRv9al/6+xfJeW0McUGluUhGHgB/zJS5y4patkt+Ocny0M0ludlnjg/OJ/VeGnBbQ29Ien94eJapVpzH7gh/cqilbzIZA7OZGfNdrFneykLXiQ/bHcyM+bSkuih00gm7nasCbbdaLD60igGu3z2zdjEcGlUOmsdmNJbtlo0mqHkxaDV6cTAMQWbMDL0qBd3E4btHWS0K0UGqphB07HkGaanImZXCUaETQ58Zks5ZpgZAx76i7ljQd5WTJAOgx6ZcTkjy9VMsiseEjgvPqGvNbZxBFoAeG1I4TGgn4LnbZmg+ltmhvo4I42iuODr0NdqGocqtVIEFbWICjwsd02mDDj0GacuQZRdVYEHHtaELNvRYoK+Rocem03ds6LuG+sdW6DGIPhi6HtD1DDC0x4f2haA5B8el7boua8GBHq+/40PfBTBpgkIbVkCmMLQvAe2B0AoIXQOXZp+swwzt6mpKIWhg4vFs+BSnIaykH4XcAyuXVtVqTl0GusLyY1Wv5DW40AW04/GPg2bvBj0gXbMPOsCq2rA3wrYr/KEM9MaUd/iK2qZPXwOhFRB6YyIWevHbjjZM3npJWxZ6c5xuARG91WXdQxSNBoWgdQloXRjaN/jMjS8EHV4xRwy40DEREwVd2rFC0CXniNjQcW0C3akbA+3lW6Hb8cIPbOixQNEiQ8f8QGFDK3AKtRl6bDqJ3BXj/lqNDh1DncSFlgwcTTdDhzFvO6OeI+DjWzkb0KHjxNqjmlr6r2bue6AdNKfepphLuwLQsZ/4Gx7zzWekYgDo6HIWbYBpLAMmS8BQ0MeLB6w3XCEW2RaxdOLLGqkvSs1yOgqk9sTUEuUdF4/Rcyi0dYIxG1e/UWYwOq7Vg4wAa+e4aydni+gi6YgTdtgWP1giYaOBT7vdOtm0qmHeBqw6eZWj1D6/rhMPs7CigClbmySmale6J9KSgKve4OTnnGyQ3bHZNNkgy1yXnRhu1IWPQxc/ZUe++pzaHHq6CJ5mynSrWJc3NO4sAx4DQro5XmVFPpUyZ2xazQhcPr1JcTA1r9IF1SHDw3KirU83dknlA9A10jpNzivgrCHCpdRc8ev2ntSE/cdcih2DepbzH3gMirnpjf68xUe4uk473YsPnB17tC877fG+CtM5iJbPj1c3rqrktDFEmz39yc8f3OKEmzy5H1ou5o+N1D/cIil0PH/Ktmvh5bbavsL42rX/xuKhlq3Qq16j9uwM/pZHsP/Aw+7/xTHodoLB7t0WvHvdNhgJGiEGaQ7/FsL/8eHFn8r4lh8lwVq2Dc5WVj1veGHkh8Xa4v6Hf2gnrjIU/6QRiUQikUgkEolEIpFIJBKJRCKRSCQSifR99A+QQLqujpJ2+QAAAABJRU5ErkJggg==";
700
+
701
+ // src/routes/pwa.ts
702
+ var BACKGROUND_COLOR = "#f4f4f5";
703
+ var THEME_COLOR = "#ffffff";
704
+ var THEME_COLOR_DARK = "#111113";
705
+ function manifest(base) {
706
+ const icon = (name, sizes, type, purpose) => ({ src: `${base}/icons/${name}`, sizes, type, purpose });
707
+ return JSON.stringify({
708
+ id: `${base}/`,
709
+ name: "CronWatch",
710
+ short_name: "CronWatch",
711
+ description: "The scheduled jobs of this app: their health, their last day and their runs.",
712
+ start_url: `${base}/`,
713
+ scope: `${base}/`,
714
+ display: "standalone",
715
+ background_color: BACKGROUND_COLOR,
716
+ theme_color: THEME_COLOR,
717
+ icons: [
718
+ icon("icon.svg", "any", "image/svg+xml", "any"),
719
+ icon("maskable.svg", "any", "image/svg+xml", "maskable"),
720
+ icon("icon-192.png", "192x192", "image/png", "any"),
721
+ icon("icon-512.png", "512x512", "image/png", "any"),
722
+ icon("maskable-512.png", "512x512", "image/png", "maskable")
723
+ ]
724
+ });
725
+ }
726
+ var APP_JS = `"use strict";
727
+ (function () {
728
+ var script = document.currentScript;
729
+ if (!script || !("serviceWorker" in navigator)) return;
730
+ var base = new URL("./", script.src);
731
+ navigator.serviceWorker.register(new URL("sw.js", base).href, { scope: base.pathname }).catch(function () {});
732
+ })();
733
+ `;
734
+ var SW_JS = `"use strict";
735
+ var VERSION = "cronwatch-shell-1";
736
+ var SCOPE = self.registration.scope;
737
+ var CACHE = VERSION + " " + SCOPE;
738
+ var SHELL = ["offline", "manifest.webmanifest", "app.js", "icons/icon.svg", "icons/maskable.svg", "icons/icon-192.png", "icons/icon-512.png", "icons/maskable-512.png", "icons/apple-touch-icon.png"].map(function (path) {
739
+ return new URL(path, SCOPE).href;
740
+ });
741
+ var OFFLINE = SHELL[0];
742
+
743
+ self.addEventListener("install", function (event) {
744
+ event.waitUntil(caches.open(CACHE).then(function (cache) {
745
+ return cache.addAll(SHELL.map(function (url) { return new Request(url, { credentials: "omit", cache: "reload" }); }));
746
+ }).then(function () { return self.skipWaiting(); }));
747
+ });
748
+
749
+ self.addEventListener("activate", function (event) {
750
+ event.waitUntil(caches.keys().then(function (keys) {
751
+ return Promise.all(keys.filter(function (key) {
752
+ return key !== CACHE && key.indexOf("cronwatch-shell-") === 0 && key.slice(key.indexOf(" ") + 1) === SCOPE;
753
+ }).map(function (key) { return caches.delete(key); }));
754
+ }).then(function () { return self.clients.claim(); }));
755
+ });
756
+
757
+ self.addEventListener("fetch", function (event) {
758
+ var request = event.request;
759
+ if (request.method !== "GET") return;
760
+ var url = new URL(request.url);
761
+ url.search = "";
762
+ if (SHELL.indexOf(url.href) !== -1 && url.href !== OFFLINE) {
763
+ event.respondWith(caches.open(CACHE).then(function (cache) {
764
+ return cache.match(url.href).then(function (hit) { return hit || fetch(request); });
765
+ }));
766
+ return;
767
+ }
768
+ if (request.mode !== "navigate") return;
769
+ event.respondWith(fetch(request).catch(function () {
770
+ return caches.open(CACHE).then(function (cache) { return cache.match(OFFLINE); }).then(function (page) {
771
+ return page || new Response("You are offline.", { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } });
772
+ });
773
+ }));
774
+ });
775
+ `;
776
+ var YEAR = "public, max-age=31536000, immutable";
777
+ var REVALIDATE = "no-cache";
778
+ function decode(base64) {
779
+ const bytes = atob(base64);
780
+ const out = new Uint8Array(bytes.length);
781
+ for (let i = 0; i < bytes.length; i++) out[i] = bytes.charCodeAt(i);
782
+ return out;
783
+ }
784
+ var ICONS = {
785
+ "/icons/icon.svg": () => ({ type: "image/svg+xml", body: ICON_SVG, cache: YEAR }),
786
+ "/icons/maskable.svg": () => ({ type: "image/svg+xml", body: MASKABLE_SVG, cache: YEAR }),
787
+ "/icons/icon-192.png": () => ({ type: "image/png", body: decode(ICON_192_PNG), cache: YEAR }),
788
+ "/icons/icon-512.png": () => ({ type: "image/png", body: decode(ICON_512_PNG), cache: YEAR }),
789
+ "/icons/maskable-512.png": () => ({ type: "image/png", body: decode(MASKABLE_512_PNG), cache: YEAR }),
790
+ "/icons/apple-touch-icon.png": () => ({ type: "image/png", body: decode(APPLE_TOUCH_ICON_PNG), cache: YEAR })
791
+ };
792
+ function staticAsset(path, base) {
793
+ if (path === "/manifest.webmanifest") return { type: "application/manifest+json", body: manifest(base), cache: REVALIDATE };
794
+ if (path === "/app.js") return { type: "text/javascript; charset=utf-8", body: APP_JS, cache: REVALIDATE };
795
+ if (path === "/sw.js") return { type: "text/javascript; charset=utf-8", body: SW_JS, cache: REVALIDATE, worker: true };
796
+ return Object.hasOwn(ICONS, path) ? ICONS[path]() : null;
797
+ }
798
+
799
+ // src/routes/timeline.ts
800
+ var HOUR = 36e5;
801
+ var DAY = 24 * HOUR;
802
+ var BOARD_BEHIND_MS = DAY;
803
+ var BOARD_AHEAD_MS = 3 * HOUR;
804
+ var BOARD_LANES = 30;
805
+ var BOARD_RUNS = 200;
806
+ var WEEK_DAYS = 7;
807
+ var W = 1e3;
808
+ var MAX_TICKS = 330;
809
+ var MAX_BOXES = 8;
810
+ var MIN_BOX = 10;
811
+ var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
812
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
813
+ function clock(t) {
814
+ return new Date(t).toISOString().slice(11, 16);
815
+ }
816
+ function dayLabel(t) {
817
+ const d = new Date(t);
818
+ return `${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
819
+ }
820
+ function when2(t, now) {
821
+ if (Math.floor(t / DAY) === Math.floor(now / DAY)) return clock(t);
822
+ const d = new Date(t);
823
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${clock(t)}`;
824
+ }
825
+ function parsedSchedule(job) {
826
+ if (!job.definition.schedule) return null;
827
+ try {
828
+ return parseSchedule(job.definition.schedule, job.definition.timezone);
829
+ } catch {
830
+ return null;
831
+ }
832
+ }
833
+ function safely(fn, fallback) {
834
+ try {
835
+ return fn();
836
+ } catch {
837
+ return fallback;
838
+ }
839
+ }
840
+ function dueTimes(job, parsed, runs, from, to) {
841
+ if (!parsed) return { times: [], dense: false };
842
+ if (parsed.kind === "interval") {
843
+ const every = parsed.everyMs;
844
+ if ((to - from) / every > MAX_TICKS) return { times: [], dense: true };
845
+ const starts = runs.map((r) => r.startedAt).sort((a, b) => a - b);
846
+ const times2 = /* @__PURE__ */ new Set();
847
+ for (const start of starts) {
848
+ const t2 = start + every;
849
+ if (t2 >= from && t2 <= to) times2.add(t2);
850
+ }
851
+ let t = starts.length ? starts[starts.length - 1] + every : job.nextExpectedAt;
852
+ if (t !== null) {
853
+ if (t < from) t += Math.ceil((from - t) / every) * every;
854
+ for (; t <= to; t += every) times2.add(t);
855
+ }
856
+ return { times: [...times2].sort((a, b) => a - b), dense: false };
857
+ }
858
+ const times = safely(() => firesBetween(parsed, from - 1, to, MAX_TICKS), []);
859
+ return times === null ? { times: [], dense: true } : { times, dense: false };
860
+ }
861
+ function missedAt(job, parsed, times, now) {
862
+ if (!parsed || !job.open.includes("missed")) return null;
863
+ const grace = safely(() => graceMs(job.definition), 0);
864
+ const last = job.lastRun?.startedAt ?? null;
865
+ if (last !== null) return safely(() => expectation(parsed, last, last, grace)?.dueAt ?? null, null);
866
+ const past = times.filter((t) => t + grace < now);
867
+ return past.length ? past[past.length - 1] : null;
868
+ }
869
+ function toneOf(run, job, now) {
870
+ if (run.status === "running") return safely(() => isStuck(job.definition, run, now), false) ? "stuck" : "running";
871
+ if (run.status === "failed") return "bad";
872
+ if (run.status === "timeout") return "timeout";
873
+ const latest = job.lastRun?.id === run.id;
874
+ return latest && (job.open.includes("over_budget") || job.open.includes("slow")) ? "warn" : "ok";
875
+ }
876
+ function timeoutText(job) {
877
+ return safely(() => formatDuration(timeoutMs(job.definition)), "configured");
878
+ }
879
+ function describeRun(run, tone, job, now) {
880
+ const at = `${when2(run.startedAt, now)} UTC`;
881
+ if (tone === "running") return `running since ${at}, ${formatDuration(now - run.startedAt)} so far`;
882
+ if (tone === "stuck") return `running since ${at}, past its ${timeoutText(job)} timeout`;
883
+ const took = run.durationMs !== null ? `, took ${formatDuration(run.durationMs)}` : "";
884
+ const extra = tone === "warn" ? job.open.includes("over_budget") ? ", over budget" : ", slow" : "";
885
+ return `${run.status} at ${at}${took}${extra}`;
886
+ }
887
+ function overCeilings(job) {
888
+ const metrics = job.lastRun?.metrics ?? {};
889
+ return Object.entries(job.definition.budget ?? {}).filter(([k, limit]) => (metrics[k] ?? -Infinity) > limit).map(([k]) => k);
890
+ }
891
+ function laneNote(job, missed, now) {
892
+ const last = job.lastRun;
893
+ if (job.silencedUntil !== null && job.silencedUntil > now) return `silenced until ${when2(job.silencedUntil, now)}`;
894
+ if (job.open.includes("missed")) return missed !== null ? `due ${when2(missed, now)}, nothing ran` : "overdue, nothing ran";
895
+ if (last?.status === "running") {
896
+ const stuck = safely(() => isStuck(job.definition, last, now), false);
897
+ return stuck ? `running since ${when2(last.startedAt, now)}, past its ${timeoutText(job)} timeout` : `running since ${when2(last.startedAt, now)}`;
898
+ }
899
+ if (last?.status === "failed") return `failed at ${when2(last.startedAt, now)}${job.consecutiveFailures > 1 ? `, ${job.consecutiveFailures} in a row` : ""}`;
900
+ if (last?.status === "timeout") return `timed out at ${when2(last.startedAt, now)}`;
901
+ if (job.open.includes("stuck")) return "stuck";
902
+ if (job.open.includes("over_budget") && last) {
903
+ const over = overCeilings(job);
904
+ return `went over budget${over.length ? ` on ${over.join(" and ")}` : ""} at ${when2(last.startedAt, now)}`;
905
+ }
906
+ if (job.open.includes("slow") && last?.durationMs != null) return `slow: took ${formatDuration(last.durationMs)}`;
907
+ if (job.open.includes("failed")) return "failing";
908
+ if (!last && job.nextExpectedAt !== null) return `no runs yet, first due ${when2(job.nextExpectedAt, now)}`;
909
+ return null;
910
+ }
911
+ var f = (n) => n.toFixed(1);
912
+ var delay = (x, base = 80, perUnit = 0.75) => `--d:${Math.round(base + Math.max(0, x) * perUnit)}ms`;
913
+ function lane(input, span, options) {
914
+ const { job } = input;
915
+ const { from, to, now } = span;
916
+ const x = (t) => Math.min(W, Math.max(0, (t - from) / (to - from) * W));
917
+ const parsed = parsedSchedule(job);
918
+ const due = dueTimes(job, parsed, input.runs, from, to);
919
+ const missed = missedAt(job, parsed, due.times, now);
920
+ const grace = safely(() => graceMs(job.definition), 0);
921
+ const name = options.label;
922
+ const busy = [];
923
+ let s = `<svg class="marks" viewBox="0 0 ${W} 24" preserveAspectRatio="none" aria-hidden="true" focusable="false">`;
924
+ if (options.nowInLane && now > from && now < to) s += `<rect class="ahead" x="${f(x(now))}" y="0" width="${f(W - x(now))}" height="24"/>`;
925
+ s += `<line class="base" x1="0" y1="12" x2="${W}" y2="12"/>`;
926
+ const inSpan = input.runs.filter((r) => r.startedAt <= to && (r.finishedAt ?? now) >= from).sort((a, b) => a.startedAt - b.startedAt);
927
+ if (!input.complete) {
928
+ const oldest = Math.min(...input.runs.map((r) => r.startedAt));
929
+ if (Number.isFinite(oldest) && oldest > from) {
930
+ s += `<rect class="unloaded" x="0" y="4" width="${f(x(oldest))}" height="16"><title>${escapeHtml(`${name}: runs before ${when2(oldest, now)} UTC are not loaded here`)}</title></rect>`;
931
+ }
932
+ }
933
+ if (due.dense) {
934
+ s += `<line class="cadence" x1="0" y1="12" x2="${W}" y2="12"><title>${escapeHtml(`${name}: due ${job.definition.schedule ?? ""}, too often to mark each time`)}</title></line>`;
935
+ }
936
+ for (const t of due.times) {
937
+ const tx = x(t);
938
+ s += `<line class="tick${t > now ? " ahead" : ""}" x1="${f(tx)}" y1="6" x2="${f(tx)}" y2="18" style="${delay(tx, 0, 0.45)}"/>`;
939
+ }
940
+ if (missed !== null && missed <= to) {
941
+ const slots = due.dense ? [] : due.times.filter((t) => t >= missed && t + grace < now);
942
+ if (!slots.includes(missed) && missed >= from) slots.unshift(missed);
943
+ const title = escapeHtml(`${name}: due ${when2(missed, now)} UTC, nothing started${slots.length > 1 ? ` (${slots.length} slots in this span)` : ""}`);
944
+ if (due.dense || slots.length > MAX_BOXES) {
945
+ const x1 = x(Math.max(missed, from)), x2 = Math.max(x(now), x1 + MIN_BOX);
946
+ s += `<rect class="missed" x="${f(x1)}" y="5" width="${f(x2 - x1)}" height="14" style="${delay(x1)}"><title>${title}</title></rect>`;
947
+ busy.push([x1, x2]);
948
+ } else {
949
+ for (const t of slots) {
950
+ if (t < from) continue;
951
+ const x1 = x(t), width = Math.max(x(t + grace) - x1, MIN_BOX);
952
+ s += `<rect class="missed" x="${f(x1)}" y="5" width="${f(width)}" height="14" style="${delay(x1)}"><title>${title}</title></rect>`;
953
+ busy.push([x1, x1 + width]);
954
+ }
955
+ }
956
+ }
957
+ for (const run of inSpan) {
958
+ const tone = toneOf(run, job, now);
959
+ const x1 = x(run.startedAt), x2 = Math.max(x(run.finishedAt ?? now), x1 + 0.5);
960
+ s += `<rect class="run ${tone}" x="${f(x1)}" y="5" width="${f(x2 - x1)}" height="14" style="${delay(x1)}"><title>${escapeHtml(`${name}: ${describeRun(run, tone, job, now)}`)}</title></rect>`;
961
+ busy.push([x1, x2]);
962
+ }
963
+ if (options.nowInLane && now > from && now < to) s += `<line class="nowline" x1="${f(x(now))}" y1="0" x2="${f(x(now))}" y2="24"/>`;
964
+ s += `</svg>`;
965
+ const text = laneNote(job, missed, now);
966
+ let note = "";
967
+ if (text) {
968
+ const nowX = x(now);
969
+ const lo = busy.length ? Math.min(...busy.map((b) => b[0])) : nowX;
970
+ const hi = busy.length ? Math.max(...busy.map((b) => b[1])) : nowX;
971
+ const right = W - hi >= lo;
972
+ const room = right ? W - hi : lo;
973
+ if (room > 90) {
974
+ const edge = right ? hi + 14 : lo - 14;
975
+ const place = right ? `left:${f(edge / 10)}%` : `right:${f(100 - edge / 10)}%`;
976
+ note = `<span class="note${right ? "" : " before"}" style="${place};max-width:${f((room - 18) / 10)}%">${escapeHtml(text)}</span>`;
977
+ }
978
+ }
979
+ return { svg: s, note, words: words(job, inSpan, due, missed, span, text) };
980
+ }
981
+ function words(job, runs, due, missed, span, note) {
982
+ const parts = [];
983
+ if (due.dense) parts.push(`due ${job.definition.schedule}`);
984
+ else if (job.definition.schedule) {
985
+ const n = due.times.filter((t) => t <= span.now).length;
986
+ parts.push(`due ${n === 0 ? "no times" : n === 1 ? "once" : `${n} times`} so far`);
987
+ }
988
+ const ok = runs.filter((r) => r.status === "ok").length;
989
+ parts.push(`${runs.length} ${runs.length === 1 ? "run" : "runs"} recorded${!runs.length ? "" : ok === runs.length ? ok === 1 ? ", ok" : ", all ok" : ok ? `, ${ok} ok` : ""}`);
990
+ for (const r of runs.filter((r2) => r2.status === "failed" || r2.status === "timeout").slice(-5)) {
991
+ parts.push(`${r.status} at ${when2(r.startedAt, span.now)} UTC after ${formatDuration(r.durationMs ?? 0)}`);
992
+ }
993
+ if (missed !== null) parts.push(`due at ${when2(missed, span.now)} UTC and nothing started`);
994
+ if (note && !/^(due |failed at|timed out)/.test(note)) parts.push(note);
995
+ return parts.join("; ");
996
+ }
997
+ function hours(span, step, nowLabel) {
998
+ const x = (t) => (t - span.from) / (span.to - span.from) * W;
999
+ const nowX = x(span.now);
1000
+ let lines = "", labels = "";
1001
+ for (let t = Math.ceil(span.from / step) * step; t <= span.to; t += step) {
1002
+ const gx = x(t);
1003
+ lines += `<i class="gl" style="left:${f(gx / 10)}%"></i>`;
1004
+ const nearNow = nowLabel && Math.abs(gx - nowX) < 70;
1005
+ if (gx < 25 || gx > W - 25 || nearNow) continue;
1006
+ const minor = Math.round(t / HOUR) % 6 !== 0;
1007
+ const near = nowLabel && Math.abs(gx - nowX) < 170;
1008
+ const cls = [minor && "minor", near && "near"].filter(Boolean).join(" ");
1009
+ labels += `<span class="${cls}" style="left:${f(gx / 10)}%">${clock(t)}</span>`;
1010
+ }
1011
+ if (nowLabel && span.now >= span.from && span.now <= span.to) labels += `<span class="nowlabel" style="left:${f(nowX / 10)}%">now ${clock(span.now)}</span>`;
1012
+ return { lines, labels };
1013
+ }
1014
+ function legend() {
1015
+ const key = (inner) => `<svg class="key" viewBox="0 0 16 12" aria-hidden="true" focusable="false">${inner}</svg>`;
1016
+ const box = (cls) => key(`<rect class="${cls}" x="2" y="1" width="12" height="10"/>`);
1017
+ const items = [
1018
+ [key(`<line class="tick" x1="8" y1="1" x2="8" y2="11"/>`), "due"],
1019
+ [box("run ok"), "ran"],
1020
+ [box("run bad"), "failed"],
1021
+ [box("run timeout"), "timed out"],
1022
+ [box("run warn"), "over budget or slow"],
1023
+ [box("run running"), "running"],
1024
+ [box("missed"), "missed"]
1025
+ ];
1026
+ return `<p class="legend" aria-hidden="true">${items.map(([k, label]) => `<span>${k}${label}</span>`).join("")}</p>`;
1027
+ }
1028
+ function stateClass(job) {
1029
+ return { healthy: "ok", late: "warn", failing: "bad", stuck: "bad", silenced: "muted", never_ran: "muted" }[job.health];
1030
+ }
1031
+ function dayTimeline(lanes, span, base, total) {
1032
+ const grid = hours(span, 3 * HOUR, true);
1033
+ const nowX = (span.now - span.from) / (span.to - span.from) * 100;
1034
+ const rows = lanes.map((input) => {
1035
+ const { job } = input;
1036
+ const parts = lane(input, span, { nowInLane: false, label: job.name });
1037
+ return {
1038
+ html: `<li class="lane"><div class="who"><i class="sq ${stateClass(job)}" aria-hidden="true"></i><a class="name" href="${escapeHtml(base)}/jobs/${encodeURIComponent(job.name)}">${escapeHtml(job.name)}</a><span class="sched">${escapeHtml(job.definition.schedule ?? "no schedule")}</span></div><div class="track">${parts.svg}${parts.note}</div></li>`,
1039
+ words: `<li>${escapeHtml(`${job.name} (${job.definition.schedule ?? "no schedule"}): ${parts.words}.`)}</li>`
1040
+ };
1041
+ });
1042
+ const more = total > lanes.length ? `<p class="more">Showing the first ${lanes.length} of ${total} jobs here; the table below lists them all.</p>` : "";
1043
+ return `<figure class="timeline day">
1044
+ <div class="axis" aria-hidden="true"><span></span><div class="hours">${grid.labels}</div></div>
1045
+ <div class="field">
1046
+ <div class="under" aria-hidden="true"><span></span><div>${grid.lines}<i class="future" style="left:${f(nowX)}%"></i></div></div>
1047
+ <ol class="lanes">${rows.map((r) => r.html).join("")}</ol>
1048
+ <div class="over" aria-hidden="true"><span></span><div><i class="now" style="left:${f(nowX)}%"></i></div></div>
1049
+ </div>
1050
+ ${legend()}${more}
1051
+ <ul class="vh">${rows.map((r) => r.words).join("")}</ul>
1052
+ </figure>`;
1053
+ }
1054
+ function weekTimeline(job, runs, complete, now) {
1055
+ const today = Math.floor(now / DAY) * DAY;
1056
+ const oldest = runs.length ? Math.min(...runs.map((r) => r.startedAt)) : null;
1057
+ const first = { from: today, to: today + DAY, now };
1058
+ const grid = hours(first, 3 * HOUR, false);
1059
+ const rows = [];
1060
+ for (let i = 0; i < WEEK_DAYS; i++) {
1061
+ const from = today - i * DAY;
1062
+ const span = { from, to: from + DAY, now };
1063
+ const dayRuns = runs.filter((r) => r.startedAt < span.to && (r.finishedAt ?? now) >= from);
1064
+ const known = complete || oldest !== null && oldest <= from;
1065
+ const label = i === 0 ? "today" : dayLabel(from);
1066
+ const parts = lane({ job, runs, complete: known }, span, { nowInLane: i === 0, label: `${job.name}, ${label}` });
1067
+ const count = `${dayRuns.length} ${dayRuns.length === 1 ? "run" : "runs"}`;
1068
+ rows.push({
1069
+ html: `<li class="lane${i === 0 ? " today" : ""}"><div class="who"><span class="name">${escapeHtml(i === 0 ? `Today, ${dayLabel(from).slice(4)}` : dayLabel(from))}</span><span class="sched">${escapeHtml(count)}</span></div><div class="track">${parts.svg}${i === 0 ? parts.note : ""}</div></li>`,
1070
+ words: `<li>${escapeHtml(`${i === 0 ? "Today" : dayLabel(from)}: ${parts.words}.`)}</li>`
1071
+ });
1072
+ }
1073
+ return `<figure class="timeline week">
1074
+ <div class="axis" aria-hidden="true"><span></span><div class="hours">${grid.labels}</div></div>
1075
+ <div class="field">
1076
+ <div class="under" aria-hidden="true"><span></span><div>${grid.lines}</div></div>
1077
+ <ol class="lanes">${rows.map((r) => r.html).join("")}</ol>
1078
+ </div>
1079
+ ${legend()}
1080
+ <ul class="vh">${rows.map((r) => r.words).join("")}</ul>
1081
+ </figure>`;
1082
+ }
1083
+ function weekRunsLimit(job, now) {
1084
+ const parsed = parsedSchedule(job);
1085
+ if (!parsed) return 50;
1086
+ const from = Math.floor(now / DAY) * DAY - (WEEK_DAYS - 1) * DAY;
1087
+ const span = now + DAY - from;
1088
+ const expected = parsed.kind === "interval" ? span / parsed.everyMs : (() => {
1089
+ const due = dueTimes(job, parsed, [], now - DAY, now);
1090
+ return due.dense ? Infinity : due.times.length * span / DAY;
1091
+ })();
1092
+ return Math.min(500, Math.max(50, Math.ceil(expected * 1.2) + 10));
1093
+ }
1094
+
1095
+ // src/routes/html.ts
670
1096
  var h = escapeHtml;
671
1097
  var CSS = `
672
- :root{--bg:#fbfbf9;--fg:#1b1b18;--muted:#6b6b64;--line:#e6e5df;--card:#fff;--ok:#1f8a4c;--warn:#b7791f;--bad:#c62828;--info:#2b5fb3;--pill:#f1f0ea;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
673
- @media(prefers-color-scheme:dark){:root{--bg:#121311;--fg:#ecece6;--muted:#9a9a91;--line:#2a2b27;--card:#1a1b18;--pill:#24251f}}
1098
+ :root{color-scheme:light dark;--paper:#f4f4f5;--sheet:#fff;--sunk:#fafafa;--rule:#e4e4e7;--rule-2:#d4d4d8;--tick:#909098;--ink:#000;--body:#18181b;--muted:#71717a;--ok:#15803d;--warn:#a16207;--bad:#b91c1c;--serif:"Newsreader",ui-serif,Georgia,Cambria,"Times New Roman",serif;--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--who:200px}
1099
+ @media(prefers-color-scheme:dark){:root{--paper:#09090b;--sheet:#111113;--sunk:#18181b;--rule:#27272a;--rule-2:#3f3f46;--tick:#66666f;--ink:#fff;--body:#e4e4e7;--muted:#a1a1aa;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171}}
674
1100
  *{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
675
- body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 var(--sans)}
676
- a{color:inherit}main{max-width:1080px;margin:0 auto;padding:24px 16px 64px}
677
- header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:20px}
678
- header h1{font-size:18px;margin:0;letter-spacing:-.01em}header h1 a{text-decoration:none}
679
- header .meta{color:var(--muted);font-size:13px}
680
- .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
681
- table{width:100%;border-collapse:collapse;font-size:14px}
682
- th{text-align:left;font-weight:600;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em;padding:10px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
683
- td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
684
- tr:last-child td{border-bottom:0}
685
- .name{font-weight:600;white-space:nowrap}.name a{text-decoration:none}.name a:hover{text-decoration:underline}
686
- .mono{font-family:var(--mono);font-size:13px}.muted{color:var(--muted)}.nowrap{white-space:nowrap}
687
- .pill{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border-radius:999px;background:var(--pill);font-size:12px;font-weight:600;white-space:nowrap}
688
- .pill::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
689
- .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.info{color:var(--info)}.mutedpill{color:var(--muted)}
690
- .spark{display:block}
691
- form.inline{display:inline}
692
- button,select{font:inherit;font-size:13px;padding:5px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);cursor:pointer}
693
- button:hover{border-color:var(--muted)}
694
- .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
695
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px}
696
- .stat{padding:12px 14px}.stat .k{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.stat .v{font-size:20px;font-weight:600;margin-top:2px}
697
- pre{margin:0;padding:10px 12px;background:var(--pill);border-radius:8px;font:12.5px/1.45 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:320px;overflow:auto}
698
- details summary{cursor:pointer;color:var(--muted);font-size:13px}details{margin-top:6px}
699
- details.confirm{margin:0}details.confirm summary{list-style:none;display:inline-block;font-size:13px;padding:5px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg)}
700
- details.confirm summary::-webkit-details-marker{display:none}details.confirm[open] summary{border-color:var(--muted)}details.confirm form{margin-left:8px;font-size:13px}
701
- .empty{padding:40px 16px;text-align:center;color:var(--muted)}
702
- dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:0;padding:14px 16px;font-size:14px}dt{color:var(--muted)}dd{margin:0}
703
- footer{margin-top:28px;color:var(--muted);font-size:12px}
704
- @media(max-width:720px){.hide-sm{display:none}main{padding:16px 16px 48px}}
1101
+ body{margin:0;background:var(--paper);color:var(--ink);font:400 16px/1.55 var(--serif);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
1102
+ a{color:inherit;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:.16em;text-decoration-color:var(--rule-2)}a:hover{text-decoration-color:currentColor}
1103
+ :focus-visible{outline:2px solid var(--ink);outline-offset:2px}
1104
+ code,pre,.mono{font-family:var(--mono)}
1105
+ .vh{position:absolute!important;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
1106
+ .sheet{max-width:1180px;min-height:100vh;margin:0 auto;background:var(--sheet);border-inline:1px solid var(--rule);padding:0 clamp(16px,4vw,48px)}
1107
+ .top{display:flex;align-items:center;justify-content:space-between;gap:12px 20px;flex-wrap:wrap;padding:18px 0 17px;border-bottom:1px solid var(--rule)}
1108
+ .brand{display:flex;align-items:center;gap:10px;margin:0;font:600 18px/1.2 var(--serif);letter-spacing:-.01em;min-width:0}
1109
+ .brand a{display:inline-flex;align-items:center;gap:10px;text-decoration:none}.brand svg{width:24px;height:24px;flex:none;color:var(--ink)}
1110
+ .brand .crumb{font:500 15px/1.2 var(--mono);color:var(--body);overflow-wrap:anywhere}.brand .slash{color:var(--rule-2);font-weight:400}
1111
+ .actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
1112
+ .meta{font:400 12px/1.4 var(--mono);color:var(--muted)}
1113
+ button,select,details.confirm>summary{font:500 12.5px/1 var(--mono);color:var(--ink);background:var(--sheet);border:1px solid var(--rule-2);border-radius:3px;padding:8px 11px;cursor:pointer}
1114
+ select{padding:7px 8px}
1115
+ button:hover,select:hover,details.confirm>summary:hover{border-color:var(--muted)}
1116
+ button.primary{background:var(--ink);border-color:var(--ink);color:var(--sheet)}button.primary:hover{opacity:.86}
1117
+ form.inline{display:inline-flex;align-items:center;gap:6px;margin:0}
1118
+ details.confirm{display:inline-flex;align-items:center;gap:8px;margin:0}details.confirm>summary{list-style:none;display:inline-block}
1119
+ details.confirm>summary::-webkit-details-marker{display:none}details.confirm[open]>summary{border-color:var(--muted)}
1120
+ details.confirm form{margin-left:8px;font-size:14px;color:var(--muted)}
1121
+ .sec{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px 40px;padding:30px 0;border-top:1px solid var(--rule)}
1122
+ .top+main>.sec:first-child{border-top:0}
1123
+ .sec>h2{margin:0;font:500 11px/1.5 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-top:5px}
1124
+ .sec>.wide{grid-column:1/-1;min-width:0}
1125
+ .lede{margin:0;color:var(--muted);font-size:16px;max-width:64ch;text-wrap:pretty}
1126
+ .headline{margin:0;font:400 clamp(24px,3.2vw,32px)/1.2 var(--serif);letter-spacing:-.01em;text-wrap:balance}
1127
+ .headline b{font-weight:600}
1128
+ .state{font:500 12.5px/1.4 var(--mono);white-space:nowrap}.state+.state::before{content:" \\00b7 ";color:var(--muted);font-weight:400}
1129
+ .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.muted{color:var(--muted)}.info{color:var(--ink)}
1130
+ .sq{display:inline-block;width:8px;height:8px;border-radius:1.5px;background:currentColor;margin-right:7px;vertical-align:1px;flex:none}
1131
+ .sq.muted{background:none;box-shadow:inset 0 0 0 1.5px var(--tick)}
1132
+ .figures{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:0;margin:22px 0 0;border-top:1px solid var(--rule)}
1133
+ .figures>div{display:flex;flex-direction:column-reverse;justify-content:flex-end;gap:4px;padding:14px 16px 2px 0}
1134
+ .figures dt{font:500 11px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center}
1135
+ .figures dd{margin:0;font:400 30px/1.1 var(--serif);font-variant-numeric:tabular-nums;color:var(--ink)}
1136
+ .figures dd small{font-size:17px;color:var(--muted)}
1137
+ .figures .zero dd{color:var(--rule-2)}
1138
+ .figures .bad dd{color:var(--bad)}.figures .warn dd{color:var(--warn)}
1139
+ .stateline{margin:12px 0 0;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px}
1140
+ .stateline .why{font-style:italic;color:var(--muted)}
1141
+ .jobname{margin:0;font:500 clamp(22px,3vw,28px)/1.2 var(--mono);letter-spacing:-.01em;overflow-wrap:anywhere}
1142
+ .desc{margin:6px 0 0;color:var(--body);max-width:64ch}
1143
+ .intro .actions{margin-top:18px}
1144
+ table{width:100%;border-collapse:collapse}
1145
+ th{text-align:left;font:500 10.5px/1.2 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:0 14px 10px 0;border-bottom:1px solid var(--rule);white-space:nowrap}
1146
+ td{padding:12px 14px 12px 0;border-bottom:1px solid var(--rule);vertical-align:top;font:400 12.5px/1.5 var(--mono);color:var(--body)}
1147
+ tbody tr:last-child td{border-bottom:0}
1148
+ td.job{font-family:var(--serif);font-size:15px;min-width:180px}
1149
+ td.job .name{font:500 13.5px/1.5 var(--mono);color:var(--ink)}
1150
+ td.job .desc{display:block;margin:2px 0 0;font-size:14px;color:var(--muted);line-height:1.4}
1151
+ td .tz,td .sub{display:block;color:var(--muted);font-size:11.5px}
1152
+ .nowrap{white-space:nowrap}
1153
+ .spark{display:block;overflow:visible}.spark rect{fill:var(--tick)}.spark rect.bad{fill:var(--bad)}.spark rect.warn{fill:var(--warn)}.spark rect.running{fill:none;stroke:var(--ink);stroke-width:1}
1154
+ .runs td{padding-top:11px;padding-bottom:11px}.runs tr.has-detail td{border-bottom:0;padding-bottom:4px}.runs tr.detail td{padding-top:0}
1155
+ .metrics{display:flex;flex-wrap:wrap;gap:2px 14px}.metrics .k{color:var(--muted)}
1156
+ pre{margin:6px 0 0;padding:12px 14px;background:var(--sunk);border:1px solid var(--rule);border-radius:3px;font:400 12.5px/1.55 var(--mono);color:var(--body);white-space:pre-wrap;overflow-wrap:anywhere;max-height:340px;overflow:auto}
1157
+ details.out{margin-top:4px}details.out>summary{cursor:pointer;font:400 12px/1.6 var(--mono);color:var(--muted)}details.out>summary:hover{color:var(--ink)}
1158
+ details.out.error>summary{color:var(--bad)}
1159
+ dl.def{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 28px;margin:0}
1160
+ dl.def dt{font:500 11px/1.9 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
1161
+ dl.def dd{margin:0;font:400 13.5px/1.7 var(--mono);color:var(--body);overflow-wrap:anywhere}dl.def dd.prose{font:400 16px/1.55 var(--serif)}
1162
+ .empty{padding:28px 0 8px;color:var(--muted);max-width:60ch}
1163
+ .empty code{font-size:.86em;color:var(--ink)}
1164
+ .message{padding:clamp(56px,12vh,120px) 0;text-align:center}
1165
+ .message h1{margin:0;font:400 clamp(28px,4vw,40px)/1.15 var(--serif);letter-spacing:-.015em}
1166
+ .message p{margin:14px auto 0;max-width:52ch;color:var(--muted);text-wrap:pretty}
1167
+ .signin{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px;margin:28px auto 0;max-width:420px}
1168
+ .signin label{font:500 11px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
1169
+ .signin input{flex:1 1 180px;min-width:0;font:400 16px/1.2 var(--mono);color:var(--ink);background:var(--sheet);border:1px solid var(--rule-2);border-radius:3px;padding:8px 10px}
1170
+ footer{display:flex;flex-wrap:wrap;gap:6px 18px;padding:20px 0 40px;border-top:1px solid var(--rule);font:400 12px/1.5 var(--mono);color:var(--muted)}
1171
+ .timeline{margin:18px 0 0}
1172
+ .timeline .axis,.timeline .under,.timeline .over,.timeline .lane{display:grid;grid-template-columns:var(--who) minmax(0,1fr)}
1173
+ .timeline .hours{position:relative;height:22px;font:400 11px/1 var(--mono);color:var(--muted);letter-spacing:.04em}
1174
+ .timeline .hours span{position:absolute;top:2px;transform:translateX(-50%);white-space:nowrap}
1175
+ .timeline .hours .nowlabel{color:var(--ink);font-weight:500;animation:cw-fade .5s 1s both}
1176
+ .timeline .field{position:relative}
1177
+ .timeline .under,.timeline .over{position:absolute;inset:0;pointer-events:none}
1178
+ .timeline .under>div,.timeline .over>div{position:relative}
1179
+ .timeline .gl{position:absolute;top:0;bottom:0;width:1px;background:var(--rule)}
1180
+ .timeline .future{position:absolute;top:0;bottom:0;right:0;background:var(--sunk)}
1181
+ .timeline .now{position:absolute;top:-6px;bottom:0;width:1.5px;margin-left:-.75px;background:var(--ink);transform-origin:top;animation:cw-drop .6s .85s cubic-bezier(.2,.8,.2,1) both}
1182
+ .timeline .lanes{position:relative;list-style:none;margin:0;padding:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
1183
+ .timeline .lane{align-items:center;min-height:40px}
1184
+ .timeline .who{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;column-gap:0;padding:6px 14px 6px 0;min-width:0}
1185
+ .timeline .who .sched{grid-column:2}
1186
+ .timeline.week .who{display:flex;align-items:baseline;gap:10px}
1187
+ .timeline .who .name{font:500 13px/1.35 var(--mono);color:var(--ink);overflow-wrap:anywhere}
1188
+ .timeline .who .sched{font:400 11px/1.35 var(--mono);color:var(--muted);white-space:nowrap}
1189
+ .timeline .track{position:relative;height:24px}
1190
+ .timeline .marks{display:block;width:100%;height:24px;overflow:visible}
1191
+ .timeline .note{position:absolute;top:50%;transform:translateY(-50%);font:italic 400 14px/1.2 var(--serif);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:0 3px;text-shadow:0 0 3px var(--sheet),0 0 3px var(--sheet),0 0 6px var(--sheet);animation:cw-fade .6s 1.1s both}
1192
+ .timeline .note.before{text-align:right}
1193
+ .timeline .legend{display:flex;flex-wrap:wrap;gap:6px 18px;margin:14px 0 0;padding-left:var(--who);font:400 11.5px/1.4 var(--mono);color:var(--muted)}
1194
+ .timeline .legend span{display:inline-flex;align-items:center;gap:7px}
1195
+ .timeline .more{margin:10px 0 0;padding-left:var(--who);font-size:14px;color:var(--muted);font-style:italic}
1196
+ .key{width:16px;height:12px;overflow:visible}
1197
+ .marks *{vector-effect:non-scaling-stroke}
1198
+ svg .base{stroke:var(--rule);stroke-width:1}
1199
+ svg .tick{stroke:var(--tick);stroke-width:1.5}svg .tick.ahead{stroke-dasharray:2 2;opacity:.75}
1200
+ svg .cadence{stroke:var(--tick);stroke-width:2;stroke-dasharray:1 3}
1201
+ svg .run{stroke-width:2;stroke-linejoin:round}
1202
+ svg .run.ok{fill:var(--ok);stroke:var(--ok)}
1203
+ svg .run.bad{fill:var(--bad);stroke:var(--bad)}
1204
+ svg .run.timeout{fill:var(--bad);fill-opacity:.28;stroke:var(--bad);stroke-width:1.5}
1205
+ svg .run.warn{fill:var(--warn);stroke:var(--warn)}
1206
+ svg .run.running{fill:none;stroke:var(--ink);stroke-width:1.5}
1207
+ svg .run.stuck{fill:var(--bad);fill-opacity:.12;stroke:var(--bad);stroke-width:1.5}
1208
+ svg .missed{fill:none;stroke:var(--bad);stroke-width:1.5;stroke-dasharray:3 2.5}
1209
+ svg .unloaded{fill:var(--sunk)}
1210
+ svg .ahead{fill:var(--sunk)}
1211
+ svg .nowline{stroke:var(--ink);stroke-width:1.5}
1212
+ .marks .tick{animation:cw-fade .4s var(--d,0ms) both}
1213
+ .marks .run,.marks .missed{transform-box:fill-box;transform-origin:0 50%;animation:cw-grow .55s cubic-bezier(.2,.8,.2,1) var(--d,0ms) both}
1214
+ .marks .missed,.marks .run.running,.marks .run.stuck{animation:cw-grow .55s cubic-bezier(.2,.8,.2,1) var(--d,0ms) both,cw-breathe 2.6s ease-in-out calc(var(--d,0ms) + .6s) infinite alternate}
1215
+ .figures>div{animation:cw-rise .5s cubic-bezier(.2,.8,.2,1) both}
1216
+ .figures>div:nth-child(2){animation-delay:40ms}.figures>div:nth-child(3){animation-delay:80ms}.figures>div:nth-child(4){animation-delay:120ms}.figures>div:nth-child(5){animation-delay:160ms}.figures>div:nth-child(6){animation-delay:200ms}
1217
+ @keyframes cw-fade{from{opacity:0}}
1218
+ @keyframes cw-grow{from{opacity:0;transform:scaleX(0)}}
1219
+ @keyframes cw-drop{from{opacity:0;transform:scaleY(0)}}
1220
+ @keyframes cw-rise{from{opacity:0;transform:translateY(4px)}}
1221
+ @keyframes cw-breathe{to{opacity:.38}}
1222
+ body{padding:0 env(safe-area-inset-right) 0 env(safe-area-inset-left)}
1223
+ footer{padding-bottom:calc(40px + env(safe-area-inset-bottom))}
1224
+ @media(display-mode:standalone){
1225
+ .top{position:sticky;top:0;z-index:2;background:var(--sheet);padding-top:calc(14px + env(safe-area-inset-top));padding-bottom:13px;-webkit-user-select:none;user-select:none}
1226
+ .message{padding-top:clamp(40px,8vh,80px)}
1227
+ }
1228
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}
1229
+ @media(max-width:760px){
1230
+ .sec{grid-template-columns:minmax(0,1fr);gap:10px;padding:24px 0}
1231
+ .hide-sm{display:none}
1232
+ :root{--who:0px}
1233
+ .timeline .lane{grid-template-columns:minmax(0,1fr);padding:6px 0 8px}
1234
+ .timeline .who{padding:0 0 4px}
1235
+ .timeline .who .name{background:var(--sheet);padding-right:4px}
1236
+ .timeline .hours .minor,.timeline .hours .near{display:none}
1237
+ .timeline .note{font-size:13px}
1238
+ td.job{min-width:0}
1239
+ table.board thead{display:none}
1240
+ table.board tr{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:14px;padding:12px 0;border-bottom:1px solid var(--rule)}
1241
+ table.board tbody tr:last-child{border-bottom:0}
1242
+ table.board td{border:0;padding:0}
1243
+ table.board td.last{grid-column:1/-1;margin-top:4px;white-space:normal}
1244
+ table.board td.last .sub{display:inline;margin-left:8px}
1245
+ .runs td.nowrap{white-space:normal}
1246
+ dl.def{grid-template-columns:minmax(0,1fr);gap:0}dl.def dd{margin-bottom:10px}
1247
+ }
705
1248
  `;
706
- function layout(title, body, options = {}) {
1249
+ var MARK = `<svg viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect x="1" y="1" width="38" height="38" rx="9.5" fill="none" stroke="currentColor" stroke-opacity=".22" stroke-width="1.5"/><circle cx="20" cy="20" r="10.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M20 12.5V20h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
1250
+ function layout(title, body, base, options = {}) {
1251
+ const b = h(base);
707
1252
  return `<!doctype html>
708
1253
  <html lang="en">
709
1254
  <head>
710
1255
  <meta charset="utf-8">
711
- <meta name="viewport" content="width=device-width,initial-scale=1">
1256
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
712
1257
  <meta name="robots" content="noindex,nofollow">
1258
+ <meta name="color-scheme" content="light dark">
713
1259
  ${options.refresh ? `<meta http-equiv="refresh" content="${options.refresh}">` : ""}
714
1260
  <title>${h(title)}</title>
1261
+ <meta name="theme-color" content="${THEME_COLOR}" media="(prefers-color-scheme: light)">
1262
+ <meta name="theme-color" content="${THEME_COLOR_DARK}" media="(prefers-color-scheme: dark)">
1263
+ <meta name="mobile-web-app-capable" content="yes">
1264
+ <meta name="apple-mobile-web-app-capable" content="yes">
1265
+ <meta name="apple-mobile-web-app-title" content="CronWatch">
1266
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
1267
+ <link rel="manifest" href="${b}/manifest.webmanifest">
1268
+ <link rel="icon" href="${b}/icons/icon.svg" type="image/svg+xml">
1269
+ <link rel="apple-touch-icon" href="${b}/icons/apple-touch-icon.png">
1270
+ <script src="${b}/app.js" defer></script>
715
1271
  <style>${CSS}</style>
716
1272
  </head>
717
- <body><main>${body}</main></body>
1273
+ <body><div class="sheet">${body}</div></body>
718
1274
  </html>`;
719
1275
  }
720
- function healthPill(job) {
721
- const map = {
722
- healthy: ["ok", "healthy"],
723
- late: ["warn", "late"],
724
- failing: ["bad", "failing"],
725
- stuck: ["bad", "stuck"],
726
- silenced: ["mutedpill", "silenced"],
727
- never_ran: ["info", "never ran"]
728
- };
729
- const [cls, label] = map[job.health];
730
- const extras = job.open.filter((c) => !["missed", "failed", "stuck"].includes(c)).map((c) => c.replace("_", " "));
731
- return `<span class="pill ${cls}">${label}</span>${extras.length ? ` <span class="pill warn">${h(extras.join(", "))}</span>` : ""}`;
1276
+ function brand(base, crumb) {
1277
+ const home = `<a href="${h(base)}/">${MARK}<span>CronWatch</span></a>`;
1278
+ return crumb === void 0 ? `<p class="brand">${home}</p>` : `<p class="brand">${home}<span class="slash" aria-hidden="true">/</span><span class="crumb">${h(crumb)}</span></p>`;
1279
+ }
1280
+ var HEALTH = {
1281
+ failing: ["bad", "failing"],
1282
+ stuck: ["bad", "stuck"],
1283
+ late: ["warn", "late"],
1284
+ healthy: ["ok", "healthy"],
1285
+ silenced: ["muted", "silenced"],
1286
+ never_ran: ["muted", "never ran"]
1287
+ };
1288
+ function healthState(job) {
1289
+ const [cls, label] = HEALTH[job.health];
1290
+ const extras = job.open.filter((c) => !["missed", "failed", "stuck"].includes(c)).map((c) => `<span class="state warn">${h(c.replace("_", " "))}</span>`).join("");
1291
+ return `<span class="state ${cls}"><i class="sq ${cls}" aria-hidden="true"></i>${label}</span>${extras}`;
732
1292
  }
733
- function runPill(run) {
1293
+ function runState(run) {
734
1294
  const cls = run.status === "ok" ? "ok" : run.status === "running" ? "info" : "bad";
735
- return `<span class="pill ${cls}">${h(run.status)}</span>`;
1295
+ return `<span class="state ${cls}">${h(run.status)}</span>`;
736
1296
  }
737
1297
  function sparkline(runs) {
738
- const points = [...runs].reverse().filter((r) => r.durationMs !== null).slice(-20);
1298
+ const points = [...runs].slice(0, 20).reverse();
739
1299
  if (points.length < 2) return "";
740
- const w = 96, hgt = 22;
741
- const max = Math.max(...points.map((r) => r.durationMs), 1);
742
- const step = w / (points.length - 1);
743
- const path = points.map((r, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(hgt - 2 - r.durationMs / max * (hgt - 4)).toFixed(1)}`).join(" ");
744
- const dots = points.map((r, i) => r.status === "ok" ? "" : `<circle cx="${(i * step).toFixed(1)}" cy="${(hgt - 2 - r.durationMs / max * (hgt - 4)).toFixed(1)}" r="2.2" fill="var(--bad)"/>`).join("");
745
- return `<svg class="spark" width="${w}" height="${hgt}" viewBox="0 0 ${w} ${hgt}" aria-hidden="true"><path d="${path}" fill="none" stroke="var(--muted)" stroke-width="1.5"/>${dots}</svg>`;
1300
+ const bar = 4, gap = 1.5, hgt = 22;
1301
+ const max = Math.max(...points.map((r) => r.durationMs ?? 0), 1);
1302
+ const bars = points.map((r, i) => {
1303
+ const x = (i * (bar + gap)).toFixed(1);
1304
+ if (r.status === "running") return `<rect class="running" x="${x}" y="${hgt - 6.5}" width="${bar - 1}" height="6"/>`;
1305
+ const tall = Math.max(r.status === "ok" ? 2 : 6, (r.durationMs ?? 0) / max * hgt);
1306
+ const cls = r.status === "ok" ? "" : ` class="bad"`;
1307
+ return `<rect${cls} x="${x}" y="${(hgt - tall).toFixed(1)}" width="${bar}" height="${tall.toFixed(1)}" rx=".5"/>`;
1308
+ }).join("");
1309
+ const w = points.length * (bar + gap) - gap;
1310
+ return `<svg class="spark" width="${w.toFixed(1)}" height="${hgt}" viewBox="0 0 ${w.toFixed(1)} ${hgt}" aria-hidden="true" focusable="false">${bars}</svg>`;
746
1311
  }
747
1312
  function stamp(at, now) {
748
1313
  if (at === null) return `<span class="muted">never</span>`;
749
- const iso = new Date(at).toISOString().replace("T", " ").slice(0, 19);
750
- return `<span class="nowrap" title="${iso} UTC">${h(formatRelative(at, now))}</span>`;
751
- }
752
- function dashboardPage(jobs, runsByJob, now, base, checkedAt) {
1314
+ const iso = new Date(at).toISOString();
1315
+ return `<time class="nowrap" datetime="${iso}" title="${iso.replace("T", " ").slice(0, 19)} UTC">${h(formatRelative(at, now))}</time>`;
1316
+ }
1317
+ function healthFigures(jobs) {
1318
+ return `<dl class="figures">${Object.keys(HEALTH).map((health) => {
1319
+ const [cls, label] = HEALTH[health];
1320
+ const n = jobs.filter((j) => j.health === health).length;
1321
+ return `<div class="${n === 0 ? "zero" : cls}"><dt><i class="sq ${cls}" aria-hidden="true"></i>${label}</dt><dd>${n}</dd></div>`;
1322
+ }).join("")}</dl>`;
1323
+ }
1324
+ function dashboardPage(jobs, runsByJob, now, base, checkedAt, lanes = jobs.slice(0, BOARD_LANES).map((job) => ({ job, runs: runsByJob.get(job.name) ?? [], complete: true }))) {
1325
+ const attention = jobs.filter((j) => j.health !== "healthy").length;
1326
+ const headline = jobs.length === 0 ? "No jobs yet." : attention === 0 ? `${jobs.length === 1 ? "The one job is" : `All ${jobs.length} jobs are`} healthy.` : `${jobs.length} job${jobs.length === 1 ? "" : "s"}, <b>${attention} needing attention</b>.`;
753
1327
  const rows = jobs.map((job) => {
754
1328
  const last = job.lastRun;
1329
+ const d = job.definition;
755
1330
  return `<tr>
756
- <td class="name"><a href="${h(base)}/jobs/${encodeURIComponent(job.name)}">${h(job.name)}</a>${job.definition.description ? `<div class="muted" style="font-weight:400;font-size:13px;white-space:normal">${h(job.definition.description)}</div>` : ""}</td>
757
- <td>${healthPill(job)}</td>
758
- <td class="mono nowrap">${h(job.definition.schedule ?? "")}<span class="muted">${job.definition.schedule ? "" : "no schedule"}</span></td>
759
- <td class="nowrap">${last ? `${runPill(last)} ${stamp(last.startedAt, now)}${last.durationMs !== null ? ` <span class="muted">${h(formatDuration(last.durationMs))}</span>` : ""}` : `<span class="muted">never</span>`}</td>
760
- <td class="nowrap hide-sm">${stamp(job.nextExpectedAt, now)}</td>
1331
+ <td class="job"><a class="name" href="${h(base)}/jobs/${encodeURIComponent(job.name)}">${h(job.name)}</a>${d.description ? `<span class="desc">${h(d.description)}</span>` : ""}</td>
1332
+ <td class="health">${healthState(job)}</td>
1333
+ <td class="nowrap hide-sm">${d.schedule ? `${h(d.schedule)}${d.timezone ? `<span class="tz">${h(d.timezone)}</span>` : ""}` : `<span class="muted">no schedule</span>`}</td>
1334
+ <td class="nowrap last">${last ? `${runState(last)} ${stamp(last.startedAt, now)}${last.durationMs !== null ? `<span class="sub">took ${h(formatDuration(last.durationMs))}</span>` : ""}` : `<span class="muted">never</span>`}</td>
1335
+ <td class="nowrap hide-sm">${job.nextExpectedAt !== null ? `${job.nextExpectedAt < now ? `<span class="state warn">overdue</span> ` : ""}${stamp(job.nextExpectedAt, now)}<span class="sub">${h(when2(job.nextExpectedAt, now))} UTC</span>` : `<span class="muted">not scheduled</span>`}</td>
761
1336
  <td class="hide-sm">${sparkline(runsByJob.get(job.name) ?? [])}</td>
762
1337
  </tr>`;
763
1338
  }).join("\n");
1339
+ const span = { from: now - BOARD_BEHIND_MS, to: now + BOARD_AHEAD_MS, now };
764
1340
  const body = `
765
- <header>
766
- <h1><a href="${h(base)}/">CronWatch</a></h1>
1341
+ <header class="top">
1342
+ ${brand(base)}
767
1343
  <div class="actions">
768
- <span class="meta">${jobs.length} job${jobs.length === 1 ? "" : "s"}${""}</span>
769
- <form class="inline" method="post" action="${h(base)}/check"><button type="submit">Run check now</button></form>
1344
+ <span class="meta">${h(clock(now))} UTC${""}</span>
1345
+ <form class="inline" method="post" action="${h(base)}/check"><button class="primary" type="submit">Run check now</button></form>
770
1346
  </div>
771
1347
  </header>
772
- <div class="card">
773
- ${jobs.length === 0 ? `<div class="empty">No jobs yet. Declare one with <code class="mono">cw.job("name", { schedule: "0 2 * * *" })</code> and run it once.</div>` : `<table>
774
- <thead><tr><th>Job</th><th>Health</th><th>Schedule</th><th>Last run</th><th class="hide-sm">Next due</th><th class="hide-sm">Durations</th></tr></thead>
775
- <tbody>${rows}</tbody></table>`}
776
- </div>
777
- <footer>Refreshes every minute. <a href="${h(base)}/api/jobs">JSON</a></footer>`;
778
- return layout("CronWatch", body, { refresh: 60 });
779
- }
780
- function jobPage(job, runs, now, base) {
1348
+ <main>
1349
+ <section class="sec" aria-label="Health">
1350
+ <h2>Health</h2>
1351
+ <div>
1352
+ <p class="headline">${headline}</p>
1353
+ ${jobs.length ? healthFigures(jobs) : `<p class="empty">Declare one with <code>cw.job("name", { schedule: "0 2 * * *" })</code> and run it once, and it shows up here.</p>`}
1354
+ </div>
1355
+ </section>
1356
+ ${jobs.length ? `<section class="sec" aria-label="Last 24 hours">
1357
+ <h2>Last 24 hours</h2>
1358
+ <p class="lede">One lane per job. Faint ticks mark when it was due, bars the runs it recorded, as long as they took. A dashed box is a slot nothing ran in. Times are UTC.</p>
1359
+ <div class="wide">${dayTimeline(lanes, span, base, jobs.length)}</div>
1360
+ </section>
1361
+ <section class="sec" aria-label="Jobs">
1362
+ <h2>Jobs</h2>
1363
+ <p class="lede">Every job in the store. Open one for its week, its runs and their output.</p>
1364
+ <div class="wide"><table class="board">
1365
+ <thead><tr><th>Job</th><th>Health</th><th class="hide-sm">Schedule</th><th>Last run</th><th class="hide-sm">Next due</th><th class="hide-sm">Recent runs</th></tr></thead>
1366
+ <tbody>${rows}</tbody></table></div>
1367
+ </section>` : ""}
1368
+ </main>
1369
+ <footer><span>Refreshes every minute. Times are UTC.</span><a href="${h(base)}/api/jobs">JSON</a></footer>`;
1370
+ return layout("CronWatch", body, base, { refresh: 60 });
1371
+ }
1372
+ function jobPage(job, runs, now, base, complete = true) {
781
1373
  const d = job.definition;
782
1374
  const okRate = `${Math.round(job.stats.okRate * 100)}%`;
783
- const runRows = runs.map((run) => {
1375
+ const listed = runs.slice(0, 50);
1376
+ const runRows = listed.map((run) => {
784
1377
  const detail = [
785
- run.error ? `<details open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
786
- run.output ? `<details${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
1378
+ run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
1379
+ run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
787
1380
  ].join("");
788
- const metrics = Object.entries(run.metrics).map(([k, v]) => `<span class="pill mutedpill">${h(k)} ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join(" ");
789
- return `<tr>
790
- <td class="nowrap">${runPill(run)}</td>
791
- <td class="nowrap">${stamp(run.startedAt, now)}</td>
1381
+ const metrics = Object.entries(run.metrics).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
1382
+ return `<tr${detail ? ` class="has-detail"` : ""}>
1383
+ <td class="nowrap">${runState(run)}</td>
1384
+ <td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
792
1385
  <td class="nowrap">${run.durationMs !== null ? h(formatDuration(run.durationMs)) : `<span class="muted">running</span>`}</td>
793
- <td class="hide-sm">${metrics}</td>
794
- <td class="mono hide-sm muted">${h(run.trigger)}</td>
795
- </tr>${detail ? `<tr><td colspan="5" style="padding-top:0">${detail}</td></tr>` : ""}`;
1386
+ <td class="hide-sm">${metrics ? `<span class="metrics">${metrics}</span>` : ""}</td>
1387
+ <td class="hide-sm muted">${h(run.trigger)}</td>
1388
+ </tr>${detail ? `<tr class="detail"><td colspan="5">${detail}</td></tr>` : ""}`;
796
1389
  }).join("\n");
797
1390
  const silenced = job.silencedUntil !== null && job.silencedUntil > now;
1391
+ const path = `${h(base)}/jobs/${encodeURIComponent(job.name)}`;
1392
+ const parsed = parsedSchedule(job);
1393
+ const why = laneNote(job, missedAt(job, parsed, [], now), now);
798
1394
  const body = `
799
- <header>
800
- <h1><a href="${h(base)}/">CronWatch</a> <span class="muted">/</span> ${h(job.name)}</h1>
801
- <div class="actions">
802
- ${healthPill(job)}
803
- ${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>`}
804
- <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>
805
- </div>
1395
+ <header class="top">
1396
+ ${brand(base, job.name)}
1397
+ <div class="actions"><span class="meta">${h(clock(now))} UTC</span></div>
806
1398
  </header>
807
- <div class="grid">
808
- <div class="card stat"><div class="k">Last run</div><div class="v">${job.lastRun ? h(formatRelative(job.lastRun.startedAt, now)) : "never"}</div></div>
809
- <div class="card stat"><div class="k">Next due</div><div class="v">${job.nextExpectedAt ? h(formatRelative(job.nextExpectedAt, now)) : "no schedule"}</div></div>
810
- <div class="card stat"><div class="k">Success, last ${h(job.stats.runs)}</div><div class="v">${h(okRate)}</div></div>
811
- <div class="card stat"><div class="k">p50 / p95</div><div class="v">${job.stats.p50Ms !== null ? h(formatDuration(job.stats.p50Ms)) : "?"} <span class="muted">/</span> ${job.stats.p95Ms !== null ? h(formatDuration(job.stats.p95Ms)) : "?"}</div></div>
812
- </div>
813
- <div class="card" style="margin-bottom:20px">
814
- <dl>
815
- <dt>Schedule</dt><dd class="mono">${d.schedule ? h(d.schedule) + (d.timezone ? ` <span class="muted">${h(d.timezone)}</span>` : "") : `<span class="muted">none</span>`}</dd>
816
- <dt>Grace</dt><dd class="mono">${h(d.grace ?? "10m")}</dd>
817
- <dt>Timeout</dt><dd class="mono">${h(d.timeout ?? "1h")}</dd>
818
- ${d.maxDuration ? `<dt>Max duration</dt><dd class="mono">${h(d.maxDuration)}</dd>` : ""}
819
- ${d.budget ? `<dt>Budget</dt><dd class="mono">${h(Object.entries(d.budget).map(([k, v]) => `${k} \u2264 ${v}`).join(", "))}</dd>` : ""}
820
- ${d.expect ? `<dt>Expect</dt><dd class="mono">${h(d.expect)}</dd>` : ""}
1399
+ <main>
1400
+ <section class="sec intro" aria-label="Job">
1401
+ <h2>Job</h2>
1402
+ <div>
1403
+ <h1 class="jobname">${h(job.name)}</h1>
1404
+ ${d.description ? `<p class="desc">${h(d.description)}</p>` : ""}
1405
+ <p class="stateline">${healthState(job)}${why ? `<span class="why">${h(why)}</span>` : ""}</p>
1406
+ <div class="actions">
1407
+ ${silenced ? `<form class="inline" method="post" action="${path}/unsilence"><button type="submit">Unsilence (until ${h(formatRelative(job.silencedUntil, now))})</button></form>` : `<form class="inline" method="post" action="${path}/silence"><select name="for" aria-label="Silence for"><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="1d">1 day</option><option value="7d">1 week</option></select><button type="submit">Silence</button></form>`}
1408
+ <details class="confirm"><summary>Forget</summary><form class="inline" method="post" action="${path}/forget"><span>Remove this job and its runs from the store?</span> <button type="submit">Forget</button></form></details>
1409
+ </div>
1410
+ <dl class="figures">
1411
+ <div><dt>Last run</dt><dd>${job.lastRun ? h(formatRelative(job.lastRun.startedAt, now)) : "never"}</dd></div>
1412
+ <div><dt>Next due</dt><dd>${job.nextExpectedAt !== null ? h(formatRelative(job.nextExpectedAt, now)) : `<small>no schedule</small>`}</dd></div>
1413
+ <div><dt>Success, last ${h(job.stats.runs)}</dt><dd>${h(okRate)}</dd></div>
1414
+ <div><dt>p50 / p95</dt><dd>${job.stats.p50Ms !== null ? h(formatDuration(job.stats.p50Ms)) : "?"} <small>/ ${job.stats.p95Ms !== null ? h(formatDuration(job.stats.p95Ms)) : "?"}</small></dd></div>
1415
+ </dl>
1416
+ </div>
1417
+ </section>
1418
+ <section class="sec" aria-label="Last 7 days">
1419
+ <h2>Last 7 days</h2>
1420
+ <p class="lede">A lane per UTC day, today first. Faint ticks mark when the job was due, bars its runs, as long as they took.</p>
1421
+ <div class="wide">${weekTimeline(job, runs, complete, now)}</div>
1422
+ </section>
1423
+ <section class="sec" aria-label="Runs">
1424
+ <h2>Runs</h2>
1425
+ ${listed.length === 0 ? `<p class="lede">No runs yet.</p>` : `<p class="lede">The newest ${listed.length === 1 ? "run" : `${listed.length} runs`}, with any error and output.</p>
1426
+ <div class="wide"><table class="runs">
1427
+ <thead><tr><th>Status</th><th>Started</th><th>Took</th><th class="hide-sm">Metrics</th><th class="hide-sm">Trigger</th></tr></thead>
1428
+ <tbody>${runRows}</tbody></table></div>`}
1429
+ </section>
1430
+ <section class="sec" aria-label="Definition">
1431
+ <h2>Definition</h2>
1432
+ <dl class="def">
1433
+ <dt>Schedule</dt><dd>${d.schedule ? h(d.schedule) + (d.timezone ? ` <span class="muted">${h(d.timezone)}</span>` : "") : `<span class="muted">none</span>`}</dd>
1434
+ <dt>Grace</dt><dd>${h(d.grace ?? "10m")}</dd>
1435
+ <dt>Timeout</dt><dd>${h(d.timeout ?? "1h")}</dd>
1436
+ ${d.maxDuration ? `<dt>Max duration</dt><dd>${h(d.maxDuration)}</dd>` : ""}
1437
+ ${d.budget ? `<dt>Budget</dt><dd>${h(Object.entries(d.budget).map(([k, v]) => `${k} \u2264 ${v}`).join(", "))}</dd>` : ""}
1438
+ ${d.expect ? `<dt>Expect</dt><dd>${h(d.expect)}</dd>` : ""}
821
1439
  ${d.failuresBeforeAlert && d.failuresBeforeAlert > 1 ? `<dt>Alert after</dt><dd>${h(d.failuresBeforeAlert)} consecutive failures</dd>` : ""}
822
- ${d.description ? `<dt>Description</dt><dd>${h(d.description)}</dd>` : ""}
823
- ${d.tags?.length ? `<dt>Tags</dt><dd>${d.tags.map((t) => `<span class="pill mutedpill">${h(t)}</span>`).join(" ")}</dd>` : ""}
824
- ${job.open.length ? `<dt>Open</dt><dd>${job.open.map((c) => `<span class="pill warn">${h(c.replace("_", " "))}</span>`).join(" ")}</dd>` : ""}
825
- ${job.consecutiveFailures > 0 ? `<dt>Consecutive failures</dt><dd>${h(job.consecutiveFailures)}</dd>` : ""}
826
- </dl>
827
- </div>
828
- <div class="card">
829
- ${runs.length === 0 ? `<div class="empty">No runs yet.</div>` : `<table>
830
- <thead><tr><th>Status</th><th>Started</th><th>Duration</th><th class="hide-sm">Metrics</th><th class="hide-sm">Trigger</th></tr></thead>
831
- <tbody>${runRows}</tbody></table>`}
832
- </div>
833
- <footer><a href="${h(base)}/api/jobs/${encodeURIComponent(job.name)}">JSON</a></footer>`;
834
- return layout(`${job.name}: CronWatch`, body, { refresh: 60 });
835
- }
836
- function messagePage(title, message, base) {
837
- return layout(title, `<header><h1><a href="${h(base)}/">CronWatch</a></h1></header><div class="card"><div class="empty"><strong>${h(title)}</strong><br>${h(message)}</div></div>`);
1440
+ ${d.tags?.length ? `<dt>Tags</dt><dd>${d.tags.map((t) => h(t)).join(", ")}</dd>` : ""}
1441
+ ${job.open.length ? `<dt>Open</dt><dd>${job.open.map((c) => `<span class="state ${c === "failed" || c === "stuck" ? "bad" : "warn"}">${h(c.replace("_", " "))}</span>`).join("")}</dd>` : ""}
1442
+ ${job.consecutiveFailures > 0 ? `<dt>Failures in a row</dt><dd>${h(job.consecutiveFailures)}</dd>` : ""}
1443
+ </dl>
1444
+ </section>
1445
+ </main>
1446
+ <footer><span>Refreshes every minute. Times are UTC.</span><a href="${h(base)}/api/jobs/${encodeURIComponent(job.name)}">JSON</a></footer>`;
1447
+ return layout(`${job.name}: CronWatch`, body, base, { refresh: 60 });
1448
+ }
1449
+ function messagePage(title, message, base, signIn = false) {
1450
+ const form = signIn ? `<form class="signin" method="get" action="${h(base)}/"><label for="token">Token</label><input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required><button class="primary" type="submit">Sign in</button></form>` : "";
1451
+ return layout(title, `<header class="top">${brand(base)}</header><main class="message"><h1>${h(title)}</h1><p>${h(message)}</p>${form}</main>`, base);
838
1452
  }
839
1453
 
840
1454
  // src/routes/index.ts
@@ -845,7 +1459,18 @@ async function cookieValue(token) {
845
1459
  }
846
1460
  var DEFAULT_RUNS = 20;
847
1461
  var MAX_RUNS = 500;
848
- var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
1462
+ var BOARD_PAGE_RUNS = 20;
1463
+ async function boardLanes(cw, entries, now) {
1464
+ const from = now - BOARD_BEHIND_MS;
1465
+ return Promise.all(entries.slice(0, BOARD_LANES).map(async ({ job, runs }) => {
1466
+ const short = runs.length >= BOARD_PAGE_RUNS && runs[runs.length - 1].startedAt > from;
1467
+ if (!short) return { job, runs, complete: true };
1468
+ const deeper = await cw.runs(job.name, BOARD_RUNS);
1469
+ return { job, runs: deeper, complete: deeper.length < BOARD_RUNS };
1470
+ }));
1471
+ }
1472
+ var CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; worker-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
1473
+ var ASSET_CSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'";
849
1474
  var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
850
1475
  function developmentToken() {
851
1476
  const bytes = crypto.getRandomValues(new Uint8Array(32));
@@ -959,6 +1584,13 @@ function createRoutes(cw, options = {}) {
959
1584
  announced = true;
960
1585
  console.info(developmentSignInLine(publicOrigin, base, token));
961
1586
  }
1587
+ if (method === "GET" || method === "HEAD") {
1588
+ if (path === "/offline") {
1589
+ return html(messagePage("You are offline", "CronWatch shows live data from your app, so it needs a connection.", base), 200, "no-cache");
1590
+ }
1591
+ const asset = staticAsset(path, base);
1592
+ if (asset) return shell(asset, base);
1593
+ }
962
1594
  if (!token && !optedOut) {
963
1595
  return wantsHtml ? html(messagePage("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token to cw.routes()), or pass token: null to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503);
964
1596
  }
@@ -974,9 +1606,9 @@ function createRoutes(cw, options = {}) {
974
1606
  const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
975
1607
  if (!cronSecretOk && !tokenOk) {
976
1608
  if (generated) {
977
- return wantsHtml ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401);
1609
+ return wantsHtml ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base, true), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401);
978
1610
  }
979
- return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base), 401) : api({ ok: false, error: "Unauthorized" }, 401);
1611
+ return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base, true), 401) : api({ ok: false, error: "Unauthorized" }, 401);
980
1612
  }
981
1613
  if (query !== null) {
982
1614
  url.searchParams.delete("token");
@@ -996,14 +1628,18 @@ function createRoutes(cw, options = {}) {
996
1628
  }
997
1629
  const parts = decoded;
998
1630
  if (method === "GET" && path === "/") {
999
- const entries = await cw.jobsWithRuns(20);
1631
+ const entries = await cw.jobsWithRuns(BOARD_PAGE_RUNS);
1632
+ const now = cw.now();
1000
1633
  const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
1001
- return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, cw.now(), base));
1634
+ return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, now, base, null, await boardLanes(cw, entries, now)));
1002
1635
  }
1003
1636
  if (method === "GET" && parts[0] === "jobs" && parts.length === 2) {
1004
1637
  const job = await cw.jobSummary(parts[1]);
1005
1638
  if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
1006
- return html(jobPage(job, await cw.runs(job.name, 50), cw.now(), base));
1639
+ const now = cw.now();
1640
+ const limit = weekRunsLimit(job, now);
1641
+ const runs = await cw.runs(job.name, limit);
1642
+ return html(jobPage(job, runs, now, base, runs.length < limit));
1007
1643
  }
1008
1644
  if (method === "POST" && path === "/check") {
1009
1645
  await cw.check();
@@ -1101,12 +1737,18 @@ function api(body, status = 200, headers = {}) {
1101
1737
  function redirect(location, headers = {}) {
1102
1738
  return new Response(null, { status: 303, headers: { location, "cache-control": "no-store", ...SECURITY_HEADERS, ...headers } });
1103
1739
  }
1104
- function html(body, status = 200) {
1740
+ function shell(asset, base) {
1741
+ const headers = { "content-type": asset.type, "cache-control": asset.cache, ...SECURITY_HEADERS };
1742
+ if (asset.type === "image/svg+xml") headers["content-security-policy"] = ASSET_CSP;
1743
+ if (asset.worker) headers["service-worker-allowed"] = `${base}/`;
1744
+ return new Response(asset.body, { status: 200, headers });
1745
+ }
1746
+ function html(body, status = 200, cache2 = "no-store") {
1105
1747
  return new Response(body, {
1106
1748
  status,
1107
1749
  headers: {
1108
1750
  "content-type": "text/html; charset=utf-8",
1109
- "cache-control": "no-store",
1751
+ "cache-control": cache2,
1110
1752
  "content-security-policy": CSP,
1111
1753
  "x-frame-options": "DENY",
1112
1754
  ...SECURITY_HEADERS