cronwatch 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.
@@ -2,51 +2,188 @@
2
2
 
3
3
  module Cronwatch
4
4
  class Web
5
- # The dashboard's pages, markup for markup the SDK's routes/html.ts. No
6
- # script anywhere: the pages refresh themselves and the forget button
7
- # confirms with a <details>.
5
+ # The dashboard's pages, markup for markup the SDK's routes/html.ts. The
6
+ # one script, app.js, only registers the service worker: the pages refresh
7
+ # themselves and the forget button confirms with a <details>.
8
+ #
9
+ # Set like cronwatch.dev: a printed sheet on grey paper, a serif for what a
10
+ # person reads, a mono for what a machine printed, neutral greys, and
11
+ # colour only for the states CronWatch reports. The page loads nothing but
12
+ # its own app shell (its CSP is default-src 'none' plus 'self' for the
13
+ # script, the manifest, the worker and images), so the fonts are system
14
+ # stacks that echo the site's Newsreader and IBM Plex Mono, and use them
15
+ # when they are installed.
16
+ #
17
+ # Installed as an app (display-mode: standalone) the header stays at the
18
+ # top as the app's bar, and the page keeps clear of notches and the home
19
+ # indicator with the safe-area insets (the viewport is viewport-fit=cover).
20
+ #
21
+ # Motion is CSS only and says something: marks arrive in time order, the
22
+ # now line drops in last, and open problems (a missed slot, a running bar)
23
+ # breathe slowly. prefers-reduced-motion turns all of it off.
8
24
  module HTML
9
25
  CSS = "\n" + <<~'CSS'
10
- :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}
11
- @media(prefers-color-scheme:dark){:root{--bg:#121311;--fg:#ecece6;--muted:#9a9a91;--line:#2a2b27;--card:#1a1b18;--pill:#24251f}}
26
+ :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}
27
+ @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}}
12
28
  *{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
13
- body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 var(--sans)}
14
- a{color:inherit}main{max-width:1080px;margin:0 auto;padding:24px 16px 64px}
15
- header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:20px}
16
- header h1{font-size:18px;margin:0;letter-spacing:-.01em}header h1 a{text-decoration:none}
17
- header .meta{color:var(--muted);font-size:13px}
18
- .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
19
- table{width:100%;border-collapse:collapse;font-size:14px}
20
- 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}
21
- td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
22
- tr:last-child td{border-bottom:0}
23
- .name{font-weight:600;white-space:nowrap}.name a{text-decoration:none}.name a:hover{text-decoration:underline}
24
- .mono{font-family:var(--mono);font-size:13px}.muted{color:var(--muted)}.nowrap{white-space:nowrap}
25
- .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}
26
- .pill::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
27
- .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.info{color:var(--info)}.mutedpill{color:var(--muted)}
28
- .spark{display:block}
29
- form.inline{display:inline}
30
- 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}
31
- button:hover{border-color:var(--muted)}
32
- .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
33
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px}
34
- .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}
35
- 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}
36
- details summary{cursor:pointer;color:var(--muted);font-size:13px}details{margin-top:6px}
37
- 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)}
38
- 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}
39
- .empty{padding:40px 16px;text-align:center;color:var(--muted)}
40
- 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}
41
- footer{margin-top:28px;color:var(--muted);font-size:12px}
42
- @media(max-width:720px){.hide-sm{display:none}main{padding:16px 16px 48px}}
29
+ 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}
30
+ 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}
31
+ :focus-visible{outline:2px solid var(--ink);outline-offset:2px}
32
+ code,pre,.mono{font-family:var(--mono)}
33
+ .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}
34
+ .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)}
35
+ .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)}
36
+ .brand{display:flex;align-items:center;gap:10px;margin:0;font:600 18px/1.2 var(--serif);letter-spacing:-.01em;min-width:0}
37
+ .brand a{display:inline-flex;align-items:center;gap:10px;text-decoration:none}.brand svg{width:24px;height:24px;flex:none;color:var(--ink)}
38
+ .brand .crumb{font:500 15px/1.2 var(--mono);color:var(--body);overflow-wrap:anywhere}.brand .slash{color:var(--rule-2);font-weight:400}
39
+ .actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
40
+ .meta{font:400 12px/1.4 var(--mono);color:var(--muted)}
41
+ 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}
42
+ select{padding:7px 8px}
43
+ button:hover,select:hover,details.confirm>summary:hover{border-color:var(--muted)}
44
+ button.primary{background:var(--ink);border-color:var(--ink);color:var(--sheet)}button.primary:hover{opacity:.86}
45
+ form.inline{display:inline-flex;align-items:center;gap:6px;margin:0}
46
+ details.confirm{display:inline-flex;align-items:center;gap:8px;margin:0}details.confirm>summary{list-style:none;display:inline-block}
47
+ details.confirm>summary::-webkit-details-marker{display:none}details.confirm[open]>summary{border-color:var(--muted)}
48
+ details.confirm form{margin-left:8px;font-size:14px;color:var(--muted)}
49
+ .sec{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px 40px;padding:30px 0;border-top:1px solid var(--rule)}
50
+ .top+main>.sec:first-child{border-top:0}
51
+ .sec>h2{margin:0;font:500 11px/1.5 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-top:5px}
52
+ .sec>.wide{grid-column:1/-1;min-width:0}
53
+ .lede{margin:0;color:var(--muted);font-size:16px;max-width:64ch;text-wrap:pretty}
54
+ .headline{margin:0;font:400 clamp(24px,3.2vw,32px)/1.2 var(--serif);letter-spacing:-.01em;text-wrap:balance}
55
+ .headline b{font-weight:600}
56
+ .state{font:500 12.5px/1.4 var(--mono);white-space:nowrap}.state+.state::before{content:" \00b7 ";color:var(--muted);font-weight:400}
57
+ .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.muted{color:var(--muted)}.info{color:var(--ink)}
58
+ .sq{display:inline-block;width:8px;height:8px;border-radius:1.5px;background:currentColor;margin-right:7px;vertical-align:1px;flex:none}
59
+ .sq.muted{background:none;box-shadow:inset 0 0 0 1.5px var(--tick)}
60
+ .figures{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:0;margin:22px 0 0;border-top:1px solid var(--rule)}
61
+ .figures>div{display:flex;flex-direction:column-reverse;justify-content:flex-end;gap:4px;padding:14px 16px 2px 0}
62
+ .figures dt{font:500 11px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center}
63
+ .figures dd{margin:0;font:400 30px/1.1 var(--serif);font-variant-numeric:tabular-nums;color:var(--ink)}
64
+ .figures dd small{font-size:17px;color:var(--muted)}
65
+ .figures .zero dd{color:var(--rule-2)}
66
+ .figures .bad dd{color:var(--bad)}.figures .warn dd{color:var(--warn)}
67
+ .stateline{margin:12px 0 0;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px}
68
+ .stateline .why{font-style:italic;color:var(--muted)}
69
+ .jobname{margin:0;font:500 clamp(22px,3vw,28px)/1.2 var(--mono);letter-spacing:-.01em;overflow-wrap:anywhere}
70
+ .desc{margin:6px 0 0;color:var(--body);max-width:64ch}
71
+ .intro .actions{margin-top:18px}
72
+ table{width:100%;border-collapse:collapse}
73
+ 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}
74
+ 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)}
75
+ tbody tr:last-child td{border-bottom:0}
76
+ td.job{font-family:var(--serif);font-size:15px;min-width:180px}
77
+ td.job .name{font:500 13.5px/1.5 var(--mono);color:var(--ink)}
78
+ td.job .desc{display:block;margin:2px 0 0;font-size:14px;color:var(--muted);line-height:1.4}
79
+ td .tz,td .sub{display:block;color:var(--muted);font-size:11.5px}
80
+ .nowrap{white-space:nowrap}
81
+ .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}
82
+ .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}
83
+ .metrics{display:flex;flex-wrap:wrap;gap:2px 14px}.metrics .k{color:var(--muted)}
84
+ 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}
85
+ 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)}
86
+ details.out.error>summary{color:var(--bad)}
87
+ dl.def{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 28px;margin:0}
88
+ dl.def dt{font:500 11px/1.9 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
89
+ 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)}
90
+ .empty{padding:28px 0 8px;color:var(--muted);max-width:60ch}
91
+ .empty code{font-size:.86em;color:var(--ink)}
92
+ .message{padding:clamp(56px,12vh,120px) 0;text-align:center}
93
+ .message h1{margin:0;font:400 clamp(28px,4vw,40px)/1.15 var(--serif);letter-spacing:-.015em}
94
+ .message p{margin:14px auto 0;max-width:52ch;color:var(--muted);text-wrap:pretty}
95
+ .signin{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px;margin:28px auto 0;max-width:420px}
96
+ .signin label{font:500 11px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
97
+ .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}
98
+ 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)}
99
+ .timeline{margin:18px 0 0}
100
+ .timeline .axis,.timeline .under,.timeline .over,.timeline .lane{display:grid;grid-template-columns:var(--who) minmax(0,1fr)}
101
+ .timeline .hours{position:relative;height:22px;font:400 11px/1 var(--mono);color:var(--muted);letter-spacing:.04em}
102
+ .timeline .hours span{position:absolute;top:2px;transform:translateX(-50%);white-space:nowrap}
103
+ .timeline .hours .nowlabel{color:var(--ink);font-weight:500;animation:cw-fade .5s 1s both}
104
+ .timeline .field{position:relative}
105
+ .timeline .under,.timeline .over{position:absolute;inset:0;pointer-events:none}
106
+ .timeline .under>div,.timeline .over>div{position:relative}
107
+ .timeline .gl{position:absolute;top:0;bottom:0;width:1px;background:var(--rule)}
108
+ .timeline .future{position:absolute;top:0;bottom:0;right:0;background:var(--sunk)}
109
+ .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}
110
+ .timeline .lanes{position:relative;list-style:none;margin:0;padding:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
111
+ .timeline .lane{align-items:center;min-height:40px}
112
+ .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}
113
+ .timeline .who .sched{grid-column:2}
114
+ .timeline.week .who{display:flex;align-items:baseline;gap:10px}
115
+ .timeline .who .name{font:500 13px/1.35 var(--mono);color:var(--ink);overflow-wrap:anywhere}
116
+ .timeline .who .sched{font:400 11px/1.35 var(--mono);color:var(--muted);white-space:nowrap}
117
+ .timeline .track{position:relative;height:24px}
118
+ .timeline .marks{display:block;width:100%;height:24px;overflow:visible}
119
+ .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}
120
+ .timeline .note.before{text-align:right}
121
+ .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)}
122
+ .timeline .legend span{display:inline-flex;align-items:center;gap:7px}
123
+ .timeline .more{margin:10px 0 0;padding-left:var(--who);font-size:14px;color:var(--muted);font-style:italic}
124
+ .key{width:16px;height:12px;overflow:visible}
125
+ .marks *{vector-effect:non-scaling-stroke}
126
+ svg .base{stroke:var(--rule);stroke-width:1}
127
+ svg .tick{stroke:var(--tick);stroke-width:1.5}svg .tick.ahead{stroke-dasharray:2 2;opacity:.75}
128
+ svg .cadence{stroke:var(--tick);stroke-width:2;stroke-dasharray:1 3}
129
+ svg .run{stroke-width:2;stroke-linejoin:round}
130
+ svg .run.ok{fill:var(--ok);stroke:var(--ok)}
131
+ svg .run.bad{fill:var(--bad);stroke:var(--bad)}
132
+ svg .run.timeout{fill:var(--bad);fill-opacity:.28;stroke:var(--bad);stroke-width:1.5}
133
+ svg .run.warn{fill:var(--warn);stroke:var(--warn)}
134
+ svg .run.running{fill:none;stroke:var(--ink);stroke-width:1.5}
135
+ svg .run.stuck{fill:var(--bad);fill-opacity:.12;stroke:var(--bad);stroke-width:1.5}
136
+ svg .missed{fill:none;stroke:var(--bad);stroke-width:1.5;stroke-dasharray:3 2.5}
137
+ svg .unloaded{fill:var(--sunk)}
138
+ svg .ahead{fill:var(--sunk)}
139
+ svg .nowline{stroke:var(--ink);stroke-width:1.5}
140
+ .marks .tick{animation:cw-fade .4s var(--d,0ms) both}
141
+ .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}
142
+ .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}
143
+ .figures>div{animation:cw-rise .5s cubic-bezier(.2,.8,.2,1) both}
144
+ .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}
145
+ @keyframes cw-fade{from{opacity:0}}
146
+ @keyframes cw-grow{from{opacity:0;transform:scaleX(0)}}
147
+ @keyframes cw-drop{from{opacity:0;transform:scaleY(0)}}
148
+ @keyframes cw-rise{from{opacity:0;transform:translateY(4px)}}
149
+ @keyframes cw-breathe{to{opacity:.38}}
150
+ body{padding:0 env(safe-area-inset-right) 0 env(safe-area-inset-left)}
151
+ footer{padding-bottom:calc(40px + env(safe-area-inset-bottom))}
152
+ @media(display-mode:standalone){
153
+ .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}
154
+ .message{padding-top:clamp(40px,8vh,80px)}
155
+ }
156
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}
157
+ @media(max-width:760px){
158
+ .sec{grid-template-columns:minmax(0,1fr);gap:10px;padding:24px 0}
159
+ .hide-sm{display:none}
160
+ :root{--who:0px}
161
+ .timeline .lane{grid-template-columns:minmax(0,1fr);padding:6px 0 8px}
162
+ .timeline .who{padding:0 0 4px}
163
+ .timeline .who .name{background:var(--sheet);padding-right:4px}
164
+ .timeline .hours .minor,.timeline .hours .near{display:none}
165
+ .timeline .note{font-size:13px}
166
+ td.job{min-width:0}
167
+ table.board thead{display:none}
168
+ table.board tr{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:14px;padding:12px 0;border-bottom:1px solid var(--rule)}
169
+ table.board tbody tr:last-child{border-bottom:0}
170
+ table.board td{border:0;padding:0}
171
+ table.board td.last{grid-column:1/-1;margin-top:4px;white-space:normal}
172
+ table.board td.last .sub{display:inline;margin-left:8px}
173
+ .runs td.nowrap{white-space:normal}
174
+ dl.def{grid-template-columns:minmax(0,1fr);gap:0}dl.def dd{margin-bottom:10px}
175
+ }
43
176
  CSS
44
177
 
178
+ # The clock face from cronwatch.dev, in the text colour.
179
+ 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>'
180
+
181
+ # In the order the board counts them, the ones needing attention first.
45
182
  HEALTH = {
46
- healthy: %w[ok healthy], late: %w[warn late], failing: %w[bad failing], stuck: %w[bad stuck],
47
- silenced: %w[mutedpill silenced], never_ran: ["info", "never ran"],
183
+ failing: %w[bad failing], stuck: %w[bad stuck], late: %w[warn late], healthy: %w[ok healthy],
184
+ silenced: %w[muted silenced], never_ran: ["muted", "never ran"],
48
185
  }.freeze
49
- # Conditions the health pill already says; the others get a pill of their own.
186
+ # Conditions the health state already says; the others are named after it.
50
187
  SHOWN_BY_HEALTH = %i[missed failed stuck].freeze
51
188
  # What encodeURIComponent leaves alone.
52
189
  URI_UNRESERVED = /[A-Za-z0-9\-_.!~*'()]/
@@ -104,145 +241,237 @@ module Cronwatch
104
241
  JS.object_keys(hash).map { |k| [k.to_s, hash[k]] }
105
242
  end
106
243
 
107
- def layout(title, body, refresh: nil)
244
+ # A page. `base` is where the dashboard is mounted ("" at the root): the
245
+ # head links the web app manifest, the icons and app.js, the one script,
246
+ # which only registers the service worker (Web::PWA). Everything works
247
+ # without it.
248
+ def layout(title, body, base, refresh: nil)
249
+ b = h(base)
108
250
  <<~HTML.chomp
109
251
  <!doctype html>
110
252
  <html lang="en">
111
253
  <head>
112
254
  <meta charset="utf-8">
113
- <meta name="viewport" content="width=device-width,initial-scale=1">
255
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
114
256
  <meta name="robots" content="noindex,nofollow">
257
+ <meta name="color-scheme" content="light dark">
115
258
  #{truthy?(refresh) ? "<meta http-equiv=\"refresh\" content=\"#{text(refresh)}\">" : ""}
116
259
  <title>#{h(title)}</title>
260
+ <meta name="theme-color" content="#{PWA::THEME_COLOR}" media="(prefers-color-scheme: light)">
261
+ <meta name="theme-color" content="#{PWA::THEME_COLOR_DARK}" media="(prefers-color-scheme: dark)">
262
+ <meta name="mobile-web-app-capable" content="yes">
263
+ <meta name="apple-mobile-web-app-capable" content="yes">
264
+ <meta name="apple-mobile-web-app-title" content="CronWatch">
265
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
266
+ <link rel="manifest" href="#{b}/manifest.webmanifest">
267
+ <link rel="icon" href="#{b}/icons/icon.svg" type="image/svg+xml">
268
+ <link rel="apple-touch-icon" href="#{b}/icons/apple-touch-icon.png">
269
+ <script src="#{b}/app.js" defer></script>
117
270
  <style>#{CSS}</style>
118
271
  </head>
119
- <body><main>#{body}</main></body>
272
+ <body><div class="sheet">#{body}</div></body>
120
273
  </html>
121
274
  HTML
122
275
  end
123
276
 
124
- def health_pill(job)
277
+ def brand(base, crumb = nil)
278
+ home = %(<a href="#{h(base)}/">#{MARK}<span>CronWatch</span></a>)
279
+ return %(<p class="brand">#{home}</p>) if crumb.nil?
280
+
281
+ %(<p class="brand">#{home}<span class="slash" aria-hidden="true">/</span><span class="crumb">#{h(crumb)}</span></p>)
282
+ end
283
+
284
+ # The job's health, with any open condition it does not already say (over budget, slow) after it.
285
+ def health_state(job)
125
286
  cls, label = HEALTH.fetch(job.health.to_sym)
126
- extras = job.open.map(&:to_sym).reject { |c| SHOWN_BY_HEALTH.include?(c) }.map { |c| c.to_s.sub("_", " ") }
127
- extra = extras.empty? ? "" : %( <span class="pill warn">#{h(extras.join(", "))}</span>)
128
- %(<span class="pill #{cls}">#{label}</span>#{extra})
287
+ extras = job.open.map(&:to_sym).reject { |c| SHOWN_BY_HEALTH.include?(c) }
288
+ .map { |c| %(<span class="state warn">#{h(c.to_s.sub("_", " "))}</span>) }.join
289
+ %(<span class="state #{cls}"><i class="sq #{cls}" aria-hidden="true"></i>#{label}</span>#{extras})
129
290
  end
130
291
 
131
- def run_pill(run)
292
+ def run_state(run)
132
293
  status = run.status.to_s
133
294
  cls = if status == "ok" then "ok"
134
295
  elsif status == "running" then "info"
135
296
  else "bad"
136
297
  end
137
- %(<span class="pill #{cls}">#{h(status)}</span>)
298
+ %(<span class="state #{cls}">#{h(status)}</span>)
138
299
  end
139
300
 
301
+ # The last twenty runs, oldest first, as bars as tall as they took; grey unless something went wrong.
140
302
  def sparkline(runs)
141
- points = runs.reverse.reject { |r| r.duration_ms.nil? }.last(20)
303
+ points = runs.first(20).reverse
142
304
  return "" if points.length < 2
143
305
 
144
- w = 96
306
+ bar = 4
307
+ gap = 1.5
145
308
  hgt = 22
146
- max = [*points.map(&:duration_ms), 1].max
147
- step = w.fdiv(points.length - 1)
148
- y = ->(r) { to_fixed(hgt - 2 - (r.duration_ms.fdiv(max) * (hgt - 4)), 1) }
149
- path = points.each_with_index.map { |r, i| "#{i.zero? ? "M" : "L"}#{to_fixed(i * step, 1)},#{y.call(r)}" }.join(" ")
150
- dots = points.each_with_index.map do |r, i|
151
- r.status.to_s == "ok" ? "" : %(<circle cx="#{to_fixed(i * step, 1)}" cy="#{y.call(r)}" r="2.2" fill="var(--bad)"/>)
309
+ max = [*points.map { |r| r.duration_ms || 0 }, 1].max
310
+ bars = points.each_with_index.map do |r, i|
311
+ x = to_fixed(i * (bar + gap), 1)
312
+ status = r.status.to_s
313
+ next %(<rect class="running" x="#{x}" y="#{text(hgt - 6.5)}" width="#{bar - 1}" height="6"/>) if status == "running"
314
+
315
+ tall = [status == "ok" ? 2 : 6, (r.duration_ms || 0).fdiv(max) * hgt].max
316
+ cls = status == "ok" ? "" : %( class="bad")
317
+ %(<rect#{cls} x="#{x}" y="#{to_fixed(hgt - tall, 1)}" width="#{bar}" height="#{to_fixed(tall, 1)}" rx=".5"/>)
152
318
  end.join
153
- %(<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>)
319
+ w = (points.length * (bar + gap)) - gap
320
+ %(<svg class="spark" width="#{to_fixed(w, 1)}" height="#{hgt}" viewBox="0 0 #{to_fixed(w, 1)} #{hgt}" aria-hidden="true" focusable="false">#{bars}</svg>)
154
321
  end
155
322
 
156
323
  def stamp(at, now)
157
324
  return %(<span class="muted">never</span>) if at.nil?
158
325
 
159
- iso = JS.iso(at.to_i).sub("T", " ")[0, 19]
160
- %(<span class="nowrap" title="#{iso} UTC">#{h(Duration.relative(at, now))}</span>)
326
+ iso = JS.iso(at.to_i)
327
+ %(<time class="nowrap" datetime="#{iso}" title="#{iso.sub("T", " ")[0, 19]} UTC">#{h(Duration.relative(at, now))}</time>)
328
+ end
329
+
330
+ # Counts by health, the ones needing attention first; a zero is set faint
331
+ # rather than left out, so the row keeps its shape.
332
+ def health_figures(jobs)
333
+ cells = HEALTH.map do |health, (cls, label)|
334
+ n = jobs.count { |j| j.health.to_sym == health }
335
+ %(<div class="#{n.zero? ? "zero" : cls}"><dt><i class="sq #{cls}" aria-hidden="true"></i>#{label}</dt><dd>#{n}</dd></div>)
336
+ end
337
+ %(<dl class="figures">#{cells.join}</dl>)
161
338
  end
162
339
 
163
- def dashboard_page(jobs, runs_by_job, now, base, checked_at)
340
+ def dashboard_page(jobs, runs_by_job, now, base, checked_at, lanes = nil)
341
+ lanes ||= jobs.first(Timeline::BOARD_LANES).map do |job|
342
+ Timeline::LaneInput.new(job: job, runs: runs_by_job[job.name] || [], complete: true)
343
+ end
344
+ attention = jobs.count { |j| j.health.to_sym != :healthy }
345
+ headline =
346
+ if jobs.empty? then "No jobs yet."
347
+ elsif attention.zero? then "#{jobs.length == 1 ? "The one job is" : "All #{jobs.length} jobs are"} healthy."
348
+ else "#{jobs.length} job#{jobs.length == 1 ? "" : "s"}, <b>#{attention} needing attention</b>."
349
+ end
350
+
164
351
  rows = jobs.map do |job|
165
352
  last = job.last_run
166
353
  d = job.definition
167
- description = truthy?(d.description) ? %(<div class="muted" style="font-weight:400;font-size:13px;white-space:normal">#{h(d.description)}</div>) : ""
354
+ description = truthy?(d.description) ? %(<span class="desc">#{h(d.description)}</span>) : ""
355
+ schedule =
356
+ if truthy?(d.schedule)
357
+ "#{h(d.schedule)}#{truthy?(d.timezone) ? %(<span class="tz">#{h(d.timezone)}</span>) : ""}"
358
+ else
359
+ %(<span class="muted">no schedule</span>)
360
+ end
168
361
  last_cell =
169
362
  if last
170
- took = last.duration_ms.nil? ? "" : %( <span class="muted">#{h(Duration.format(last.duration_ms))}</span>)
171
- "#{run_pill(last)} #{stamp(last.started_at, now)}#{took}"
363
+ took = last.duration_ms.nil? ? "" : %(<span class="sub">took #{h(Duration.format(last.duration_ms))}</span>)
364
+ "#{run_state(last)} #{stamp(last.started_at, now)}#{took}"
172
365
  else
173
366
  %(<span class="muted">never</span>)
174
367
  end
368
+ next_at = job.next_expected_at
369
+ next_cell =
370
+ if next_at.nil?
371
+ %(<span class="muted">not scheduled</span>)
372
+ else
373
+ overdue = next_at < now ? %(<span class="state warn">overdue</span> ) : ""
374
+ %(#{overdue}#{stamp(next_at, now)}<span class="sub">#{h(Timeline.when_at(next_at, now))} UTC</span>)
375
+ end
175
376
  <<~ROW.chomp
176
377
  <tr>
177
- <td class="name"><a href="#{h(base)}/jobs/#{encode_uri_component(job.name)}">#{h(job.name)}</a>#{description}</td>
178
- <td>#{health_pill(job)}</td>
179
- <td class="mono nowrap">#{h(d.schedule.nil? ? "" : d.schedule)}<span class="muted">#{truthy?(d.schedule) ? "" : "no schedule"}</span></td>
180
- <td class="nowrap">#{last_cell}</td>
181
- <td class="nowrap hide-sm">#{stamp(job.next_expected_at, now)}</td>
378
+ <td class="job"><a class="name" href="#{h(base)}/jobs/#{encode_uri_component(job.name)}">#{h(job.name)}</a>#{description}</td>
379
+ <td class="health">#{health_state(job)}</td>
380
+ <td class="nowrap hide-sm">#{schedule}</td>
381
+ <td class="nowrap last">#{last_cell}</td>
382
+ <td class="nowrap hide-sm">#{next_cell}</td>
182
383
  <td class="hide-sm">#{sparkline(runs_by_job[job.name] || [])}</td>
183
384
  </tr>
184
385
  ROW
185
386
  end.join("\n")
186
387
 
388
+ span = Timeline::Span.new(from: now - Timeline::BOARD_BEHIND_MS, to: now + Timeline::BOARD_AHEAD_MS, now: now)
187
389
  checked = truthy?(checked_at) ? ", checked #{h(Duration.relative(checked_at, now))}" : ""
188
- table =
390
+ figures =
391
+ if jobs.empty?
392
+ %(<p class="empty">Declare one with <code>CW.job("name", schedule: "0 2 * * *")</code> and run it once, and it shows up here.</p>)
393
+ else
394
+ health_figures(jobs)
395
+ end
396
+ sections =
189
397
  if jobs.empty?
190
- %(<div class="empty">No jobs yet. Declare one with <code class="mono">CW.job("name", schedule: "0 2 * * *")</code> and run it once.</div>)
398
+ ""
191
399
  else
192
- <<~TABLE.chomp
193
- <table>
194
- <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>
195
- <tbody>#{rows}</tbody></table>
196
- TABLE
400
+ <<~SECTIONS.chomp
401
+ <section class="sec" aria-label="Last 24 hours">
402
+ <h2>Last 24 hours</h2>
403
+ <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>
404
+ <div class="wide">#{Timeline.day_timeline(lanes, span, base, jobs.length)}</div>
405
+ </section>
406
+ <section class="sec" aria-label="Jobs">
407
+ <h2>Jobs</h2>
408
+ <p class="lede">Every job in the store. Open one for its week, its runs and their output.</p>
409
+ <div class="wide"><table class="board">
410
+ <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>
411
+ <tbody>#{rows}</tbody></table></div>
412
+ </section>
413
+ SECTIONS
197
414
  end
198
415
  body = <<~BODY.chomp
199
416
 
200
- <header>
201
- <h1><a href="#{h(base)}/">CronWatch</a></h1>
417
+ <header class="top">
418
+ #{brand(base)}
202
419
  <div class="actions">
203
- <span class="meta">#{jobs.length} job#{jobs.length == 1 ? "" : "s"}#{checked}</span>
204
- <form class="inline" method="post" action="#{h(base)}/check"><button type="submit">Run check now</button></form>
420
+ <span class="meta">#{h(Timeline.clock(now))} UTC#{checked}</span>
421
+ <form class="inline" method="post" action="#{h(base)}/check"><button class="primary" type="submit">Run check now</button></form>
205
422
  </div>
206
423
  </header>
207
- <div class="card">
208
- #{table}
209
- </div>
210
- <footer>Refreshes every minute. <a href="#{h(base)}/api/jobs">JSON</a></footer>
424
+ <main>
425
+ <section class="sec" aria-label="Health">
426
+ <h2>Health</h2>
427
+ <div>
428
+ <p class="headline">#{headline}</p>
429
+ #{figures}
430
+ </div>
431
+ </section>
432
+ #{sections}
433
+ </main>
434
+ <footer><span>Refreshes every minute. Times are UTC.</span><a href="#{h(base)}/api/jobs">JSON</a></footer>
211
435
  BODY
212
- layout("CronWatch", body, refresh: 60)
436
+ layout("CronWatch", body, base, refresh: 60)
213
437
  end
214
438
 
215
- def job_page(job, runs, now, base)
439
+ # One job: its state and figures, its last seven days, its runs with
440
+ # their output, and its definition. `complete` is false when `runs` does
441
+ # not reach back over the whole week (the run list shows the newest fifty).
442
+ def job_page(job, runs, now, base, complete = true)
216
443
  d = job.definition
217
444
  ok_rate = "#{JS.number(JS.round(job.stats.ok_rate * 100))}%"
218
- run_rows = runs.map do |run|
445
+ listed = runs.first(50)
446
+ run_rows = listed.map do |run|
219
447
  detail = [
220
- truthy?(run.error) ? %(<details open><summary>error</summary><pre>#{h(run.error)}</pre></details>) : "",
221
- truthy?(run.output) ? %(<details#{run.status.to_s == "ok" ? "" : " open"}><summary>output</summary><pre>#{h(run.output)}</pre></details>) : "",
448
+ truthy?(run.error) ? %(<details class="out error" open><summary>error</summary><pre>#{h(run.error)}</pre></details>) : "",
449
+ truthy?(run.output) ? %(<details class="out"#{run.status.to_s == "ok" ? "" : " open"}><summary>output</summary><pre>#{h(run.output)}</pre></details>) : "",
222
450
  ].join
223
451
  metrics = entries(run.metrics).map do |k, v|
224
- %(<span class="pill mutedpill">#{h(k)} #{h(JS.integer?(v) ? v : to_fixed(v, 4))}</span>)
225
- end.join(" ")
452
+ %(<span><span class="k">#{h(k)}</span> #{h(JS.integer?(v) ? v : to_fixed(v, 4))}</span>)
453
+ end.join
226
454
  took = run.duration_ms.nil? ? %(<span class="muted">running</span>) : h(Duration.format(run.duration_ms))
227
- detail_row = detail.empty? ? "" : %(<tr><td colspan="5" style="padding-top:0">#{detail}</td></tr>)
455
+ detail_row = detail.empty? ? "" : %(<tr class="detail"><td colspan="5">#{detail}</td></tr>)
228
456
  <<~ROW.chomp
229
- <tr>
230
- <td class="nowrap">#{run_pill(run)}</td>
231
- <td class="nowrap">#{stamp(run.started_at, now)}</td>
457
+ <tr#{detail.empty? ? "" : ' class="has-detail"'}>
458
+ <td class="nowrap">#{run_state(run)}</td>
459
+ <td class="nowrap">#{h(Timeline.when_at(run.started_at, now))} <span class="muted">UTC</span><span class="sub">#{stamp(run.started_at, now)}</span></td>
232
460
  <td class="nowrap">#{took}</td>
233
- <td class="hide-sm">#{metrics}</td>
234
- <td class="mono hide-sm muted">#{h(run.trigger)}</td>
461
+ <td class="hide-sm">#{metrics.empty? ? "" : %(<span class="metrics">#{metrics}</span>)}</td>
462
+ <td class="hide-sm muted">#{h(run.trigger)}</td>
235
463
  </tr>#{detail_row}
236
464
  ROW
237
465
  end.join("\n")
238
466
 
239
- name = encode_uri_component(job.name)
240
467
  silenced = !job.silenced_until.nil? && job.silenced_until > now
468
+ path = "#{h(base)}/jobs/#{encode_uri_component(job.name)}"
469
+ why = Timeline.lane_note(job, Timeline.missed_at(job, Timeline.parsed_schedule(job), [], now), now)
241
470
  silence_form =
242
471
  if silenced
243
- %(<form class="inline" method="post" action="#{h(base)}/jobs/#{name}/unsilence"><button type="submit">Unsilence (until #{h(Duration.relative(job.silenced_until, now))})</button></form>)
472
+ %(<form class="inline" method="post" action="#{path}/unsilence"><button type="submit">Unsilence (until #{h(Duration.relative(job.silenced_until, now))})</button></form>)
244
473
  else
245
- %(<form class="inline" method="post" action="#{h(base)}/jobs/#{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>)
474
+ %(<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>)
246
475
  end
247
476
  stats = job.stats
248
477
  p50 = stats.p50_ms.nil? ? "?" : h(Duration.format(stats.p50_ms))
@@ -256,58 +485,84 @@ module Cronwatch
256
485
  budget = truthy?(d.budget) ? entries(d.budget).map { |k, v| "#{k} ≤ #{text(v)}" }.join(", ") : nil
257
486
  tags = d.tags
258
487
  failures = d.failures_before_alert
259
- runs_table =
260
- if runs.empty?
261
- %(<div class="empty">No runs yet.</div>)
488
+ open = job.open.map do |c|
489
+ %(<span class="state #{%w[failed stuck].include?(c.to_s) ? "bad" : "warn"}">#{h(c.to_s.sub("_", " "))}</span>)
490
+ end.join
491
+ runs_section =
492
+ if listed.empty?
493
+ %(<p class="lede">No runs yet.</p>)
262
494
  else
263
- <<~TABLE.chomp
264
- <table>
265
- <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>
266
- <tbody>#{run_rows}</tbody></table>
267
- TABLE
495
+ <<~RUNS.chomp
496
+ <p class="lede">The newest #{listed.length == 1 ? "run" : "#{listed.length} runs"}, with any error and output.</p>
497
+ <div class="wide"><table class="runs">
498
+ <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>
499
+ <tbody>#{run_rows}</tbody></table></div>
500
+ RUNS
268
501
  end
269
502
 
270
503
  body = <<~BODY.chomp
271
504
 
272
- <header>
273
- <h1><a href="#{h(base)}/">CronWatch</a> <span class="muted">/</span> #{h(job.name)}</h1>
274
- <div class="actions">
275
- #{health_pill(job)}
276
- #{silence_form}
277
- <details class="confirm"><summary>Forget</summary><form class="inline" method="post" action="#{h(base)}/jobs/#{name}/forget"><span class="muted">Remove this job and its runs from the store?</span> <button type="submit">Forget</button></form></details>
278
- </div>
505
+ <header class="top">
506
+ #{brand(base, job.name)}
507
+ <div class="actions"><span class="meta">#{h(Timeline.clock(now))} UTC</span></div>
279
508
  </header>
280
- <div class="grid">
281
- <div class="card stat"><div class="k">Last run</div><div class="v">#{job.last_run ? h(Duration.relative(job.last_run.started_at, now)) : "never"}</div></div>
282
- <div class="card stat"><div class="k">Next due</div><div class="v">#{truthy?(job.next_expected_at) ? h(Duration.relative(job.next_expected_at, now)) : "no schedule"}</div></div>
283
- <div class="card stat"><div class="k">Success, last #{h(stats.runs)}</div><div class="v">#{h(ok_rate)}</div></div>
284
- <div class="card stat"><div class="k">p50 / p95</div><div class="v">#{p50} <span class="muted">/</span> #{p95}</div></div>
285
- </div>
286
- <div class="card" style="margin-bottom:20px">
287
- <dl>
288
- <dt>Schedule</dt><dd class="mono">#{schedule}</dd>
289
- <dt>Grace</dt><dd class="mono">#{h(d.grace.nil? ? "10m" : d.grace)}</dd>
290
- <dt>Timeout</dt><dd class="mono">#{h(d.timeout.nil? ? "1h" : d.timeout)}</dd>
291
- #{truthy?(d.max_duration) ? %(<dt>Max duration</dt><dd class="mono">#{h(d.max_duration)}</dd>) : ""}
292
- #{budget ? %(<dt>Budget</dt><dd class="mono">#{h(budget)}</dd>) : ""}
293
- #{truthy?(d.expect) ? %(<dt>Expect</dt><dd class="mono">#{h(d.expect)}</dd>) : ""}
509
+ <main>
510
+ <section class="sec intro" aria-label="Job">
511
+ <h2>Job</h2>
512
+ <div>
513
+ <h1 class="jobname">#{h(job.name)}</h1>
514
+ #{truthy?(d.description) ? %(<p class="desc">#{h(d.description)}</p>) : ""}
515
+ <p class="stateline">#{health_state(job)}#{why ? %(<span class="why">#{h(why)}</span>) : ""}</p>
516
+ <div class="actions">
517
+ #{silence_form}
518
+ <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>
519
+ </div>
520
+ <dl class="figures">
521
+ <div><dt>Last run</dt><dd>#{job.last_run ? h(Duration.relative(job.last_run.started_at, now)) : "never"}</dd></div>
522
+ <div><dt>Next due</dt><dd>#{job.next_expected_at.nil? ? "<small>no schedule</small>" : h(Duration.relative(job.next_expected_at, now))}</dd></div>
523
+ <div><dt>Success, last #{h(stats.runs)}</dt><dd>#{h(ok_rate)}</dd></div>
524
+ <div><dt>p50 / p95</dt><dd>#{p50} <small>/ #{p95}</small></dd></div>
525
+ </dl>
526
+ </div>
527
+ </section>
528
+ <section class="sec" aria-label="Last 7 days">
529
+ <h2>Last 7 days</h2>
530
+ <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>
531
+ <div class="wide">#{Timeline.week_timeline(job, runs, complete, now)}</div>
532
+ </section>
533
+ <section class="sec" aria-label="Runs">
534
+ <h2>Runs</h2>
535
+ #{runs_section}
536
+ </section>
537
+ <section class="sec" aria-label="Definition">
538
+ <h2>Definition</h2>
539
+ <dl class="def">
540
+ <dt>Schedule</dt><dd>#{schedule}</dd>
541
+ <dt>Grace</dt><dd>#{h(d.grace.nil? ? "10m" : d.grace)}</dd>
542
+ <dt>Timeout</dt><dd>#{h(d.timeout.nil? ? "1h" : d.timeout)}</dd>
543
+ #{truthy?(d.max_duration) ? %(<dt>Max duration</dt><dd>#{h(d.max_duration)}</dd>) : ""}
544
+ #{budget ? %(<dt>Budget</dt><dd>#{h(budget)}</dd>) : ""}
545
+ #{truthy?(d.expect) ? %(<dt>Expect</dt><dd>#{h(d.expect)}</dd>) : ""}
294
546
  #{truthy?(failures) && failures > 1 ? %(<dt>Alert after</dt><dd>#{h(failures)} consecutive failures</dd>) : ""}
295
- #{truthy?(d.description) ? %(<dt>Description</dt><dd>#{h(d.description)}</dd>) : ""}
296
- #{tags.is_a?(Array) && tags.any? ? %(<dt>Tags</dt><dd>#{tags.map { |t| %(<span class="pill mutedpill">#{h(t)}</span>) }.join(" ")}</dd>) : ""}
297
- #{job.open.any? ? %(<dt>Open</dt><dd>#{job.open.map { |c| %(<span class="pill warn">#{h(c.to_s.sub("_", " "))}</span>) }.join(" ")}</dd>) : ""}
298
- #{job.consecutive_failures.positive? ? %(<dt>Consecutive failures</dt><dd>#{h(job.consecutive_failures)}</dd>) : ""}
299
- </dl>
300
- </div>
301
- <div class="card">
302
- #{runs_table}
303
- </div>
304
- <footer><a href="#{h(base)}/api/jobs/#{name}">JSON</a></footer>
547
+ #{tags.is_a?(Array) && tags.any? ? %(<dt>Tags</dt><dd>#{tags.map { |t| h(t) }.join(", ")}</dd>) : ""}
548
+ #{job.open.any? ? %(<dt>Open</dt><dd>#{open}</dd>) : ""}
549
+ #{job.consecutive_failures.positive? ? %(<dt>Failures in a row</dt><dd>#{h(job.consecutive_failures)}</dd>) : ""}
550
+ </dl>
551
+ </section>
552
+ </main>
553
+ <footer><span>Refreshes every minute. Times are UTC.</span><a href="#{h(base)}/api/jobs/#{encode_uri_component(job.name)}">JSON</a></footer>
305
554
  BODY
306
- layout("#{job.name}: CronWatch", body, refresh: 60)
555
+ layout("#{job.name}: CronWatch", body, base, refresh: 60)
307
556
  end
308
557
 
309
- def message_page(title, message, base)
310
- 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>))
558
+ # A page with one message. With `sign_in`, a form under it takes the
559
+ # token and sends it as ?token=, which the app moves into the cookie:
560
+ # the way in where there is no address bar to open a link with, such as
561
+ # an app on an iPhone's home screen, which keeps its cookies apart from
562
+ # Safari's.
563
+ def message_page(title, message, base, sign_in: false)
564
+ form = sign_in ? %(<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>) : ""
565
+ layout(title, %(<header class="top">#{brand(base)}</header><main class="message"><h1>#{h(title)}</h1><p>#{h(message)}</p>#{form}</main>), base)
311
566
  end
312
567
  end
313
568
  end