@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.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/html.ts
690
+ // src/routes/escape.ts
669
691
  function escapeHtml(value) {
670
692
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAMAAADDpiTIAAAAM1BMVEUUFBciIiUwMDI+PkBMTE5oaGmEhISRkZKtra27u7vJycjX19bl5ePz8/FaWlufn592dnaDKSoUAAARgElEQVR42u2d63bjqBKFuclyHI+T93/Kbnec6MLlrMzpzpqeaQFCtihgf38z0xbFpiiKAhgDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMqGt9ty/tV4x5yDAJporeBf/OtP7gvrIID6EOfhaRIi6r+19vBxfLMQQCWos5vF+nY62/E3DQEUPu5HKTf9C8b0dfuCegXA1UmLu/xLVr1rBwGU5faFknf9B422GgIohAs3j2iWk+4KAbTa+9VqoC4BqGfz6AY5edMQAEmk7MQuP2RnYyAAanRK7fhrWs8QAKX1vtpp8P/TDWgLAVCZ+fN0haghGpDFt+DC+0xZGteJ0wgBZO5+IWRO68nSJVD2FHDR+QVs1BUeINvoF/m/QpTtBcoVgOopdP9PCbBiVwSlTgHyTMvk4s1AADvSH8h90jRCAHvRHQTBr7LTDAHs4v0PiuiX6clAAA/n0BP+uHGCAB4c+x9or1vMpCGAtoK/woNB3uLw/zz80Q+OnRi7sWfG3hk/jp+HRhp0AiUJYPPs76y1n52/tHnEP0UghNhqlJIigXIEsC34t8bGH/YR50HILSvNgpYDxQigO6Z3h0053CHOo0iX3DBDAHflJTHza0z/I71cgP+VfLJIfIcA7uj++6RumI3ZvmMgpOySpDcaCOBe7r9PONt5zxL+pMMGbpwhgFzR/2zubfwuwQ+UsBooQACrp39zuD5ir1hcprUTUQGBAHkBiH5lKP7IWt3V9cd6tBDANpP3q9bjD6/WX3sCwY4aAtgSfa1awpl5j7Cr61bNBJx2zagg/XWva/pf8/ddwu75na8Z1e6V9gqbdPi/Qp5a3lZvw11Yd+ikfF77P476uOLyEccoJwQoTwEr9n4TivP5ofu6J3CeVmcL1xxJoLxDzGvof9t9Wz+T/5ZcSknavM6iAgXQFUD88j8l3/IfdaX0UXyGim5CQBbf//Og79D/TPL1M7XRPNJ+7omqDxCF97/lQ8LCv/vD7HJI2PKxA4/9zhdMAY/o/7TJlT//qdnulrRvHBuqEJ0FaE4Bkek/k7jf9ufsMk9brhkbd0bRcQMB3HdQjUNi2vfpz35PpG3e2ZlFbVdIkgqgKIC44NomV11dlmaGY2KkZqzkUbY2EEDM+jrKo4of9u6NTi7ltPNTTPygTgMEEB6fUR07vKf/wuJmzobrgEcXMw24I7nVILlzlipmKG2rtxOPWBPPNqZu0Slqu8PU8gAiZv4X7wQnU/MeY8uemsGpTQHHiA8a37f9xmJJh9u2nTxG+FMuNDyAh5eIKYmTLbWcItYC6gUewLMADH+OHT62/sqjPABjY8R6kFh5AKkgsAsHAMSrLLUN17D2jtJ5AUpTgAz3//xBvMrWfoR7t5cQwJ8NE3SfYmDkGYIm5ZQuuSEkxpdg/0+3+8w0D4sB/h8IBIsEBKHqADoeoAs691IuXwlXKNkOAviPKwqe/x+LuXwn/KVHCQH8ewW4fVwRSgiMm5vbmgCC979MRd3AF/xadYAAfrNHv3lMFRYH9AoCWOERRXE3MY9iY5ObWgb2gah4vt3z1x68DPxSgN+2gkaFGAkPEJoQ9cAKJHRegcad1yQEEOh/W+iLLKFtiwME8NMQcpshqRISrjxAACxiD4gX+zyjDiS3KewKERBAYByMBb/Kdh3JTwL5BdD5YyExsYKZ/PZVHQQQGAXmOyua74a4C8gugECZbPFv8/obIPrWBRCIhAdTugDMsGkFVL0Azv6vm1nxBC6SObctAOVd4tvvrAK++9uomhZAV3cAENOMrmUBXLzyH3UdAtBeBahLwwLwTvFmYpXgP3Y+tysAvwOopv8DTcnrArIKwOviJ12PAPSUbIaaBXDxrYHtyCrCu6EpL40KwDv5SVYVkmoUkFEA3ghgvtYlgOtMNArIKICpkQiQdmvzCcC7FVpqEVBiTNOpBgXw7LNWdQ6AsckmGqNSAQifPTpWIb5GWdGcAHxOz3yrUQDfTKI56hRAR9Ea+TTftSYA3+t7+lqnAK6elJ/oGhMAydHQotfLJACp2nMAfhegZFsCaNEB+JvWlgA8ljDXegVwNeR0n0cAyvOzM6sYT+OEakgAnsSXrVsANsko1QnANOoAvM0z7Qjg4jk1q+sWgKd5/NKMADz9L2zdAvAl/XkzAvA4uxurnBuxOSCHADwzgNG1C0AbWnNADgF4XN2BVc+B1hyQQwAeV3etXwBXWnNABgEonrRMriYMXF4JctWEAAS1pTAdByja8ACLf3FzCwKYXYJpKhKA5z0NyZrAYwDegAA8KndtCMAxQi5gfwGcml4DBJp5akAAy6memTXCnGCcagQgRNtrAH9DhaheAGcGAZgk81QigOUjcsa2IgBrEsxTiwAkHICvqbJhAfTtCKBvVwCehe6PdgTwg04mYG8BLAc52rUjAKfJRIF7C2C5ly1rCJtgoDoEMCME8Dd2rlwAy7/31pIA3grokMf83OJ2l21rClhsLRdVC+CMLECoueeqBTAgBgw1d6haAE+Lfzm2JYBjgolqEMCEGDDU3KlqASz+nGttCnDUe+QhLIe4jfW/p8GCVywAgRgw3GBRsweAAMIN5k0KwLUmAAcBQAAQAKaA7AJQ8AD/YGtZvnPW6rI8ABEBOBICuEs9lu2+xWmFYwqoMgQQ5lltcgFNCqCqEEA8vW4JAuABysd08ADr2lZbPdgxPAv0DQpguWlDZQKIuPp5oLEMICKA6vJAKqgA16AAWqKUd4+IeIBTfQIIWfYED1A3Eh6gbTgEsIJbewK4QQAAAvjFrs+l7JN3dpSaDA+wpmtK+hUIIIF98s4GAlgxKN73/IzrHoNThyaadxq+o8kgcI8z+KU8fULEA+y7aJ4eP8Z0UGS8QQ+w3LJ9j4a6x9/GF3YyxxangMWm7Xw/4vzoE5hDeAYYaSwfiAhg77zp+FgFyIgog0h1lKIhgP1vrLX9w0RnR71l6DUpgP13TmZ96B7yq7aLu/ESHuA3a/DdE2duHC/jna9kWnEwhEMAv9sjR+Y05wslVMrjiASB7SWkBASQOQjIDDwABAABYApoTQAWAgg32NbsASwEEGqwrdkDMDJ3JGfvfyoHpXe2+2HxL+e2BHBOMNFD2Pn4gltMPI1tXRe+bIjB1OwBjogCQ809spoF8EbFFeVGJpioiiBweUO4KRcgyNyavbfZLaJAf2P3vi1pbwEs35swtiSAMcFAdQiAIwr0N5ZXLoDlEEc1tB/EFZUYcHcBeOpl/mpHAH8lGagKAXiOzDUUBIwJ5qlfAA1lAmTDAlg+miubCQOFTDBPLQJ4Y3ABMsk8lQjA80YwBJDhBeX93e7yCqhrRQBdgnEyeKNHuYDlRh7bWAhclv/00YAH8Cx0G0kFcTpZgBwCcIbOGigPHgO4BgTgUTlvIgrwnEnNcK9MBgHYxheCMsk0NXmAZT/XNZALEstuzrXhAXxD4NL0GiCHA8whAE+kM9UvgCnJMFUJwHNRo1S1979aHubu2ogAfK7uuXYBPBMLgbMIwOPqbOVhoLC0ZoA8AvBd1lv5HOBpXpYZIFMppsfZVZ4L6ojNAJl+1fO0Kne25v73CODdtuMBfHepd406AJvnfvFMMZfnKlVZcTLoIpNMUqEADCNnicy6z7UVmmn7xXkqQEW1dSEXz3DTU1MewLvxObfoAHK9MJJLALMnDFSVRgEXTxLAzo0JgORoaNHrCYrWkK819v+rpKj5bALwJv2rjAJ8jRLZsl/5irA82UDGK6wPPfgyXO+2OQ/gf1itr25TUPhO/c35op6Mhj4k/7FIB0C0tTnPYjz5tn75tar+v/g2+/UHa9ED+Ld9KgsCTLIhqg0CGRuVR36c1ySB3ufszBtr0wP4q38OFdUGqUOyGWr2AGz0Xgoi6skGHH3t1DfWqgfwT36ympXAQVKNAHKfxvO7AGXrqA5T3gvA8zqA3Pdz+r18X4cD6DeYoHYBaO/vi5ca+v/F38bMW5+5D2Rr7wzoKigR7vwmvrm2BeC49wuUcYX3v3zy/nnKvdTJvuky2rrDAH8DbPbyRwJ3cnjzIOKp7ArRF75F/k0IwPqviHVF7wr4MwBM51c3AQ/gusAqulwfcAmYd3AQwOcYDyTDi80HqUDDRgLVrxQqb6aAky+1PEgEIlhD4UIcEradthmS7AJAbGp2M1MAY9afDGBClHhS4BjY5ZlmCODLGSr/YJEFLgZfAuPfDAwCiF0JMFdcfVAwcBktBPCPSSC0EpCFKaAPFTOMRMpdqNzOa0IvBsmiEkKHUNyqqcxpZK5nDk0CTBWkgGD/U0gBUROAC9VGqmJmgT7c/2SWNXRyLLPYPq4Kmf8p1btSeqXlFHRH4nsJ679geG/e6XwtpScabPD9aFdARugY9GRucBBAWhjApLS0S4TEMVzlPVJSMalHWmz4kIxQpMsE1TFs0JHUgRdiL7W9RPQu4XPDlwj3RCyOIfZM0xj1gjTV5eAhwpr6jUEAmwJBxhTRraGXCGPawUEA/kAw4qicUwRDQfkUM50O1LwXuWIbHWNGeSJ3p3h3ihlLnNwyluBzva9Rg4RYLPUStTaR3xgEEBFLRaV8LaHltIorWxwJvopH8bFWw6O8aUdmNXA4Ro2jieKriCRf641TAFM0kkLqGBeQTCTXLjSfazZPUVG+6AjsEPfHuEBa3EiamjOavESObSvz5gUvJnIdRXUjk+qD7WOcD2CciYw5AdELXnb/k/UA8T4gY3AdX6FCt5CBrgAiCmu+5oEux/r6dY7Ook10jzVIugKIXAv8/djk/tsDFxk/eAj3P2UBMBN/h6Zg8rSnlS9CxCfRKeZ/ihAAM6f4+G5PCazqfiZvlG3MGWkuqyJ8M+9RbNN1q0YN8XvviQsgNsv+FQ7O+rHJQaG6dR80Ei9jpS4AJvqVl2mL2+NMrp5XykuP1G83IS+ANQmBXzPB4foIs4vLtDZiKuAcQwECSDkRNJt7RwOdXF+DQjr8L0gArOvXf6aT7n7R14WbhC8YS3jwoAgBMNknLVdnY7bPBULKpPozMxZxlrUMASQEAr+6wfQ/0jeL+F+jTMyUFHGMsSABsO6Y/L9q27+t1484jyL9NZ+hlPduihEAk5tekbLGHqNVIM6DkFvqpfVUzGUW5QjgDvcDOGutc9YtzQmcC86FEFuNUkL0X6IAmDrcZ+viUwT94NiJsRt7Zuyd8eP42fl3+dfNVNKthkUJYE2NQDamsq40lGUJwFhB++JgMxb22mFhHoD6TUElzf5FeoC/V/acqhPQQ3l3GpfnAT5zAgeKErBTiW/dFikAksHgVObDJrJMARjT07ohQNwKfeq6UA/wmRTo6Lwvr2ddqhlFsQLQH1RuW9D8o9j+L9gDfHLR+acwo64lm7BsATB2mfNOBLq7lm3A0gXA2GXKd1/QfLiWbr7yBZBQq3u3yF+Xb7waBLC+Wv8OPPwEAgSwjk7tGQxoPVdit2oE8HmT+E5uwM7GVGO1igTwdzRgHt0gJ2uY+WsVQGoJf3zvu2tl9qpOAI/TQIW9X6kAPucCoe6bIzTa6iotVakAPlumTvo+QaFV79pVayZWM+KcfLLn18g3KYdKIABS08HZzQml/s52/E1Xb50GBPDTFwxPU2RFsbWHj2Pd4749AfxsreBf/HvAf2FdUyZhrcL5V+Mdc44BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKBI/gcIFJ1QCWwwGAAAAABJRU5ErkJggg==";
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{--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}
675
- @media(prefers-color-scheme:dark){:root{--bg:#121311;--fg:#ecece6;--muted:#9a9a91;--line:#2a2b27;--card:#1a1b18;--pill:#24251f}}
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(--bg);color:var(--fg);font:15px/1.5 var(--sans)}
678
- a{color:inherit}main{max-width:1080px;margin:0 auto;padding:24px 16px 64px}
679
- header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:20px}
680
- header h1{font-size:18px;margin:0;letter-spacing:-.01em}header h1 a{text-decoration:none}
681
- header .meta{color:var(--muted);font-size:13px}
682
- .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
683
- table{width:100%;border-collapse:collapse;font-size:14px}
684
- 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}
685
- td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
686
- tr:last-child td{border-bottom:0}
687
- .name{font-weight:600;white-space:nowrap}.name a{text-decoration:none}.name a:hover{text-decoration:underline}
688
- .mono{font-family:var(--mono);font-size:13px}.muted{color:var(--muted)}.nowrap{white-space:nowrap}
689
- .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}
690
- .pill::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
691
- .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.info{color:var(--info)}.mutedpill{color:var(--muted)}
692
- .spark{display:block}
693
- form.inline{display:inline}
694
- 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}
695
- button:hover{border-color:var(--muted)}
696
- .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
697
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px}
698
- .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}
699
- 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}
700
- details summary{cursor:pointer;color:var(--muted);font-size:13px}details{margin-top:6px}
701
- 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)}
702
- 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}
703
- .empty{padding:40px 16px;text-align:center;color:var(--muted)}
704
- 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}
705
- footer{margin-top:28px;color:var(--muted);font-size:12px}
706
- @media(max-width:720px){.hide-sm{display:none}main{padding:16px 16px 48px}}
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
- function layout(title, body, options = {}) {
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><main>${body}</main></body>
1275
+ <body><div class="sheet">${body}</div></body>
720
1276
  </html>`;
721
1277
  }
722
- function healthPill(job) {
723
- const map = {
724
- healthy: ["ok", "healthy"],
725
- late: ["warn", "late"],
726
- failing: ["bad", "failing"],
727
- stuck: ["bad", "stuck"],
728
- silenced: ["mutedpill", "silenced"],
729
- never_ran: ["info", "never ran"]
730
- };
731
- const [cls, label] = map[job.health];
732
- const extras = job.open.filter((c) => !["missed", "failed", "stuck"].includes(c)).map((c) => c.replace("_", " "));
733
- return `<span class="pill ${cls}">${label}</span>${extras.length ? ` <span class="pill warn">${h(extras.join(", "))}</span>` : ""}`;
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 runPill(run) {
1295
+ function runState(run) {
736
1296
  const cls = run.status === "ok" ? "ok" : run.status === "running" ? "info" : "bad";
737
- return `<span class="pill ${cls}">${h(run.status)}</span>`;
1297
+ return `<span class="state ${cls}">${h(run.status)}</span>`;
738
1298
  }
739
1299
  function sparkline(runs) {
740
- const points = [...runs].reverse().filter((r) => r.durationMs !== null).slice(-20);
1300
+ const points = [...runs].slice(0, 20).reverse();
741
1301
  if (points.length < 2) return "";
742
- const w = 96, hgt = 22;
743
- const max = Math.max(...points.map((r) => r.durationMs), 1);
744
- const step = w / (points.length - 1);
745
- const path = points.map((r, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(hgt - 2 - r.durationMs / max * (hgt - 4)).toFixed(1)}`).join(" ");
746
- 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("");
747
- 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>`;
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().replace("T", " ").slice(0, 19);
752
- return `<span class="nowrap" title="${iso} UTC">${h(formatRelative(at, now))}</span>`;
753
- }
754
- function dashboardPage(jobs, runsByJob, now, base, checkedAt) {
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="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>
759
- <td>${healthPill(job)}</td>
760
- <td class="mono nowrap">${h(job.definition.schedule ?? "")}<span class="muted">${job.definition.schedule ? "" : "no schedule"}</span></td>
761
- <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>
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
- <h1><a href="${h(base)}/">CronWatch</a></h1>
1343
+ <header class="top">
1344
+ ${brand(base)}
769
1345
  <div class="actions">
770
- <span class="meta">${jobs.length} job${jobs.length === 1 ? "" : "s"}${""}</span>
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
- <div class="card">
775
- ${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>
776
- <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>
777
- <tbody>${rows}</tbody></table>`}
778
- </div>
779
- <footer>Refreshes every minute. <a href="${h(base)}/api/jobs">JSON</a></footer>`;
780
- return layout("CronWatch", body, { refresh: 60 });
781
- }
782
- function jobPage(job, runs, now, base) {
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 runRows = runs.map((run) => {
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="pill mutedpill">${h(k)} ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join(" ");
791
- return `<tr>
792
- <td class="nowrap">${runPill(run)}</td>
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="mono hide-sm muted">${h(run.trigger)}</td>
797
- </tr>${detail ? `<tr><td colspan="5" style="padding-top:0">${detail}</td></tr>` : ""}`;
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
- <h1><a href="${h(base)}/">CronWatch</a> <span class="muted">/</span> ${h(job.name)}</h1>
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
- <div class="grid">
810
- <div class="card stat"><div class="k">Last run</div><div class="v">${job.lastRun ? h(formatRelative(job.lastRun.startedAt, now)) : "never"}</div></div>
811
- <div class="card stat"><div class="k">Next due</div><div class="v">${job.nextExpectedAt ? h(formatRelative(job.nextExpectedAt, now)) : "no schedule"}</div></div>
812
- <div class="card stat"><div class="k">Success, last ${h(job.stats.runs)}</div><div class="v">${h(okRate)}</div></div>
813
- <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>
814
- </div>
815
- <div class="card" style="margin-bottom:20px">
816
- <dl>
817
- <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>
818
- <dt>Grace</dt><dd class="mono">${h(d.grace ?? "10m")}</dd>
819
- <dt>Timeout</dt><dd class="mono">${h(d.timeout ?? "1h")}</dd>
820
- ${d.maxDuration ? `<dt>Max duration</dt><dd class="mono">${h(d.maxDuration)}</dd>` : ""}
821
- ${d.budget ? `<dt>Budget</dt><dd class="mono">${h(Object.entries(d.budget).map(([k, v]) => `${k} \u2264 ${v}`).join(", "))}</dd>` : ""}
822
- ${d.expect ? `<dt>Expect</dt><dd class="mono">${h(d.expect)}</dd>` : ""}
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.description ? `<dt>Description</dt><dd>${h(d.description)}</dd>` : ""}
825
- ${d.tags?.length ? `<dt>Tags</dt><dd>${d.tags.map((t) => `<span class="pill mutedpill">${h(t)}</span>`).join(" ")}</dd>` : ""}
826
- ${job.open.length ? `<dt>Open</dt><dd>${job.open.map((c) => `<span class="pill warn">${h(c.replace("_", " "))}</span>`).join(" ")}</dd>` : ""}
827
- ${job.consecutiveFailures > 0 ? `<dt>Consecutive failures</dt><dd>${h(job.consecutiveFailures)}</dd>` : ""}
828
- </dl>
829
- </div>
830
- <div class="card">
831
- ${runs.length === 0 ? `<div class="empty">No runs yet.</div>` : `<table>
832
- <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>
833
- <tbody>${runRows}</tbody></table>`}
834
- </div>
835
- <footer><a href="${h(base)}/api/jobs/${encodeURIComponent(job.name)}">JSON</a></footer>`;
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 CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
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(20);
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, cw.now(), base));
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
- return html(jobPage(job, await cw.runs(job.name, 50), cw.now(), base));
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 html(body, status = 200) {
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": "no-store",
1753
+ "cache-control": cache2,
1112
1754
  "content-security-policy": CSP,
1113
1755
  "x-frame-options": "DENY",
1114
1756
  ...SECURITY_HEADERS