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.
- checksums.yaml +4 -4
- data/lib/cronwatch/schedule.rb +28 -0
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +59 -9
- data/lib/cronwatch/web/html.rb +395 -140
- data/lib/cronwatch/web/icons.rb +19 -0
- data/lib/cronwatch/web/pwa.rb +137 -0
- data/lib/cronwatch/web/timeline.rb +476 -0
- data/lib/cronwatch/web.rb +3 -0
- metadata +10 -3
data/lib/cronwatch/web/html.rb
CHANGED
|
@@ -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.
|
|
6
|
-
# script
|
|
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{
|
|
11
|
-
@media(prefers-color-scheme:dark){:root{--
|
|
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(--
|
|
14
|
-
a{color:inherit
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.
|
|
24
|
-
.
|
|
25
|
-
.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.
|
|
29
|
-
form.inline{display:inline}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
.
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
silenced: %w[
|
|
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
|
|
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
|
-
|
|
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><
|
|
272
|
+
<body><div class="sheet">#{body}</div></body>
|
|
120
273
|
</html>
|
|
121
274
|
HTML
|
|
122
275
|
end
|
|
123
276
|
|
|
124
|
-
def
|
|
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) }
|
|
127
|
-
|
|
128
|
-
%(<span class="
|
|
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
|
|
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="
|
|
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.
|
|
303
|
+
points = runs.first(20).reverse
|
|
142
304
|
return "" if points.length < 2
|
|
143
305
|
|
|
144
|
-
|
|
306
|
+
bar = 4
|
|
307
|
+
gap = 1.5
|
|
145
308
|
hgt = 22
|
|
146
|
-
max = [*points.map
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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)
|
|
160
|
-
%(<
|
|
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) ? %(<
|
|
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? ? "" : %(
|
|
171
|
-
"#{
|
|
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="
|
|
178
|
-
<td>#{
|
|
179
|
-
<td class="
|
|
180
|
-
<td class="nowrap">#{last_cell}</td>
|
|
181
|
-
<td class="nowrap hide-sm">#{
|
|
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
|
-
|
|
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
|
-
|
|
398
|
+
""
|
|
191
399
|
else
|
|
192
|
-
<<~
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
417
|
+
<header class="top">
|
|
418
|
+
#{brand(base)}
|
|
202
419
|
<div class="actions">
|
|
203
|
-
<span class="meta">#{
|
|
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
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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"
|
|
455
|
+
detail_row = detail.empty? ? "" : %(<tr class="detail"><td colspan="5">#{detail}</td></tr>)
|
|
228
456
|
<<~ROW.chomp
|
|
229
|
-
<tr>
|
|
230
|
-
<td class="nowrap">#{
|
|
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="
|
|
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="#{
|
|
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="#{
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
<<~
|
|
264
|
-
<
|
|
265
|
-
|
|
266
|
-
<
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
281
|
-
|
|
282
|
-
<
|
|
283
|
-
<div
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
#{
|
|
296
|
-
#{
|
|
297
|
-
#{job.
|
|
298
|
-
|
|
299
|
-
</
|
|
300
|
-
</
|
|
301
|
-
<
|
|
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
|
-
|
|
310
|
-
|
|
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
|