cronwatch 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/cronwatch/alerts/webhook.rb +1 -1
- data/lib/cronwatch/http.rb +25 -1
- 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 +4 -1
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
class Web
|
|
5
|
+
# The dashboard's timelines, markup for markup the SDK's
|
|
6
|
+
# routes/timeline.ts: one lane per job (or per day, on a job's page),
|
|
7
|
+
# drawn on the server as inline SVG so the page needs no script.
|
|
8
|
+
#
|
|
9
|
+
# Every time a job was due is a faint tick, worked out from its schedule
|
|
10
|
+
# with the same functions the checks use (Schedule.fires_between and
|
|
11
|
+
# Schedule.expectation), so the lane shows the cadence the job is meant to
|
|
12
|
+
# keep. Every run it recorded is a solid mark on top, as wide as it took
|
|
13
|
+
# and coloured by how it ended. A slot the check has reported missed is a
|
|
14
|
+
# dashed box. The empty part of a lane carries a short note about anything
|
|
15
|
+
# open, and a visually hidden list says the same things in words.
|
|
16
|
+
#
|
|
17
|
+
# Every time is UTC: without script the page cannot know the viewer's zone.
|
|
18
|
+
module Timeline
|
|
19
|
+
HOUR = 3_600_000
|
|
20
|
+
DAY = 24 * HOUR
|
|
21
|
+
|
|
22
|
+
# The board's span: the last day, plus a few hours ahead so what is due soon shows.
|
|
23
|
+
BOARD_BEHIND_MS = DAY
|
|
24
|
+
BOARD_AHEAD_MS = 3 * HOUR
|
|
25
|
+
# How many jobs the board's timeline draws. The table below it lists every job.
|
|
26
|
+
BOARD_LANES = 30
|
|
27
|
+
# Runs read for a lane when the twenty the table reads start inside the
|
|
28
|
+
# span, so a frequent job's lane is not cut short. Older runs than this
|
|
29
|
+
# are shown as not loaded rather than as absent.
|
|
30
|
+
BOARD_RUNS = 200
|
|
31
|
+
# How many days a job's page draws.
|
|
32
|
+
WEEK_DAYS = 7
|
|
33
|
+
|
|
34
|
+
# Width of a lane in SVG units. Lanes stretch to fit, so strokes do not scale.
|
|
35
|
+
W = 1000
|
|
36
|
+
# A lane with more due times than this shows its cadence as a dotted line instead.
|
|
37
|
+
MAX_TICKS = 330
|
|
38
|
+
# More missed slots than this are drawn as one dashed band.
|
|
39
|
+
MAX_BOXES = 8
|
|
40
|
+
# The narrowest a missed box is drawn, in SVG units.
|
|
41
|
+
MIN_BOX = 10
|
|
42
|
+
|
|
43
|
+
MONTHS = %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec].freeze
|
|
44
|
+
WEEKDAYS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
|
|
45
|
+
STATE_CLASS = { healthy: "ok", late: "warn", failing: "bad", stuck: "bad", silenced: "muted", never_ran: "muted" }.freeze
|
|
46
|
+
|
|
47
|
+
# What one lane is drawn from: the job, its runs (any order; only those
|
|
48
|
+
# overlapping the span are drawn), and whether older runs exist that were
|
|
49
|
+
# not read (the lane then says so before its oldest run).
|
|
50
|
+
LaneInput = Struct.new(:job, :runs, :complete, keyword_init: true)
|
|
51
|
+
# Every time the job was or will be due within a span, ascending, and
|
|
52
|
+
# whether there are too many to draw one by one (`times` is then empty).
|
|
53
|
+
DueTimes = Struct.new(:times, :dense, keyword_init: true)
|
|
54
|
+
# The stretch of time a timeline draws, and the moment it was drawn.
|
|
55
|
+
Span = Struct.new(:from, :to, :now, keyword_init: true)
|
|
56
|
+
LaneParts = Struct.new(:svg, :note, :words, keyword_init: true)
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
def h(value)
|
|
61
|
+
HTML.h(value)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# toFixed(1).
|
|
65
|
+
def f(value)
|
|
66
|
+
HTML.to_fixed(value, 1)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Animation delay for a mark at x, so marks arrive in time order, left to right.
|
|
70
|
+
def delay(x, base = 80, per_unit = 0.75)
|
|
71
|
+
"--d:#{JS.round(base + ([0, x].max * per_unit))}ms"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def utc(t)
|
|
75
|
+
Time.at(t.div(1000), t % 1000, :millisecond).utc
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# "22:42", in UTC.
|
|
79
|
+
def clock(t)
|
|
80
|
+
JS.iso(t)[11, 5]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# "Sat 26 Sep", in UTC.
|
|
84
|
+
def day_label(t)
|
|
85
|
+
d = utc(t)
|
|
86
|
+
"#{WEEKDAYS[d.wday]} #{d.day} #{MONTHS[d.month - 1]}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# "22:42" on the same UTC day as `now`, otherwise "25 Sep 22:42".
|
|
90
|
+
def when_at(t, now)
|
|
91
|
+
return clock(t) if t.div(DAY) == now.div(DAY)
|
|
92
|
+
|
|
93
|
+
d = utc(t)
|
|
94
|
+
"#{d.day} #{MONTHS[d.month - 1]} #{clock(t)}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The job's schedule, parsed, or nil when it has none or it no longer parses.
|
|
98
|
+
def parsed_schedule(job)
|
|
99
|
+
schedule = job.definition.schedule
|
|
100
|
+
return nil unless HTML.truthy?(schedule)
|
|
101
|
+
|
|
102
|
+
Schedule.parse(schedule, job.definition.timezone)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def safely(fallback)
|
|
108
|
+
yield
|
|
109
|
+
rescue StandardError
|
|
110
|
+
fallback
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# When the job was due within `from` to `to`. A cron's fires come from
|
|
114
|
+
# Schedule.fires_between. An interval is due one period after each run
|
|
115
|
+
# started, and after the last run once a period for as long as nothing
|
|
116
|
+
# runs (the times a missed interval job keeps being asked for); with no
|
|
117
|
+
# run yet, from its next expected time.
|
|
118
|
+
def due_times(job, parsed, runs, from, to)
|
|
119
|
+
return DueTimes.new(times: [], dense: false) if parsed.nil?
|
|
120
|
+
|
|
121
|
+
if parsed.interval?
|
|
122
|
+
every = parsed.every_ms
|
|
123
|
+
return DueTimes.new(times: [], dense: true) if (to - from).fdiv(every) > MAX_TICKS
|
|
124
|
+
|
|
125
|
+
starts = runs.map(&:started_at).sort
|
|
126
|
+
times = []
|
|
127
|
+
starts.each do |start|
|
|
128
|
+
t = start + every
|
|
129
|
+
times << t if t >= from && t <= to
|
|
130
|
+
end
|
|
131
|
+
t = starts.empty? ? job.next_expected_at : starts.last + every
|
|
132
|
+
unless t.nil?
|
|
133
|
+
t += (from - t).fdiv(every).ceil * every if t < from
|
|
134
|
+
while t <= to
|
|
135
|
+
times << t
|
|
136
|
+
t += every
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
return DueTimes.new(times: times.uniq.sort, dense: false)
|
|
140
|
+
end
|
|
141
|
+
times = safely([]) { Schedule.fires_between(parsed, from - 1, to, MAX_TICKS) }
|
|
142
|
+
times.nil? ? DueTimes.new(times: [], dense: true) : DueTimes.new(times: times, dense: false)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The slot a missed job was due at, the one the check reported: the first
|
|
146
|
+
# fire its last run does not cover (Schedule.expectation, as on_check
|
|
147
|
+
# works it out). A job that never ran has no run to count from, so the
|
|
148
|
+
# latest due time whose grace has passed stands in. Nil when missed is
|
|
149
|
+
# not open.
|
|
150
|
+
def missed_at(job, parsed, times, now)
|
|
151
|
+
return nil if parsed.nil? || !open?(job, :missed)
|
|
152
|
+
|
|
153
|
+
grace = safely(0) { Evaluate.grace_ms(job.definition) }
|
|
154
|
+
last = job.last_run&.started_at
|
|
155
|
+
return safely(nil) { Schedule.expectation(parsed, last, last, grace)&.due_at } unless last.nil?
|
|
156
|
+
|
|
157
|
+
past = times.select { |t| t + grace < now }
|
|
158
|
+
past.last
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def open?(job, condition)
|
|
162
|
+
job.open.any? { |c| c.to_s == condition.to_s }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def tone_of(run, job, now)
|
|
166
|
+
status = run.status.to_s
|
|
167
|
+
return safely(false) { Evaluate.stuck?(job.definition, run, now) } ? "stuck" : "running" if status == "running"
|
|
168
|
+
return "bad" if status == "failed"
|
|
169
|
+
return "timeout" if status == "timeout"
|
|
170
|
+
|
|
171
|
+
latest = !job.last_run.nil? && job.last_run.id == run.id
|
|
172
|
+
latest && (open?(job, :over_budget) || open?(job, :slow)) ? "warn" : "ok"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def timeout_text(job)
|
|
176
|
+
safely("configured") { Duration.format(Evaluate.timeout_ms(job.definition)) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# One run, as its tooltip says it.
|
|
180
|
+
def describe_run(run, tone, job, now)
|
|
181
|
+
at = "#{when_at(run.started_at, now)} UTC"
|
|
182
|
+
return "running since #{at}, #{Duration.format(now - run.started_at)} so far" if tone == "running"
|
|
183
|
+
return "running since #{at}, past its #{timeout_text(job)} timeout" if tone == "stuck"
|
|
184
|
+
|
|
185
|
+
took = run.duration_ms.nil? ? "" : ", took #{Duration.format(run.duration_ms)}"
|
|
186
|
+
extra = tone == "warn" ? (open?(job, :over_budget) ? ", over budget" : ", slow") : ""
|
|
187
|
+
"#{run.status} at #{at}#{took}#{extra}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The metrics of the job's last run that went over their ceilings.
|
|
191
|
+
def over_ceilings(job)
|
|
192
|
+
metrics = job.last_run&.metrics || {}
|
|
193
|
+
HTML.entries(job.definition.budget).select do |k, limit|
|
|
194
|
+
value = metrics.key?(k) ? metrics[k] : metrics[k.to_sym]
|
|
195
|
+
(value.nil? ? -Float::INFINITY : value) > limit
|
|
196
|
+
end.map(&:first)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# What is worth saying about the job in a few words, or nil when all is well.
|
|
200
|
+
def lane_note(job, missed, now)
|
|
201
|
+
last = job.last_run
|
|
202
|
+
status = last&.status&.to_s
|
|
203
|
+
return "silenced until #{when_at(job.silenced_until, now)}" if !job.silenced_until.nil? && job.silenced_until > now
|
|
204
|
+
return missed.nil? ? "overdue, nothing ran" : "due #{when_at(missed, now)}, nothing ran" if open?(job, :missed)
|
|
205
|
+
|
|
206
|
+
if status == "running"
|
|
207
|
+
stuck = safely(false) { Evaluate.stuck?(job.definition, last, now) }
|
|
208
|
+
return stuck ? "running since #{when_at(last.started_at, now)}, past its #{timeout_text(job)} timeout" : "running since #{when_at(last.started_at, now)}"
|
|
209
|
+
end
|
|
210
|
+
if status == "failed"
|
|
211
|
+
return "failed at #{when_at(last.started_at, now)}#{job.consecutive_failures > 1 ? ", #{job.consecutive_failures} in a row" : ""}"
|
|
212
|
+
end
|
|
213
|
+
return "timed out at #{when_at(last.started_at, now)}" if status == "timeout"
|
|
214
|
+
return "stuck" if open?(job, :stuck)
|
|
215
|
+
|
|
216
|
+
if open?(job, :over_budget) && last
|
|
217
|
+
over = over_ceilings(job)
|
|
218
|
+
return "went over budget#{over.empty? ? "" : " on #{over.join(" and ")}"} at #{when_at(last.started_at, now)}"
|
|
219
|
+
end
|
|
220
|
+
return "slow: took #{Duration.format(last.duration_ms)}" if open?(job, :slow) && !last&.duration_ms.nil?
|
|
221
|
+
return "failing" if open?(job, :failed)
|
|
222
|
+
return "no runs yet, first due #{when_at(job.next_expected_at, now)}" if last.nil? && !job.next_expected_at.nil?
|
|
223
|
+
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Sorted by start, keeping the given order for equal starts, as Array#sort does in JavaScript.
|
|
228
|
+
def by_start(runs)
|
|
229
|
+
runs.each_with_index.sort_by { |r, i| [r.started_at, i] }.map(&:first)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def lane(input, span, now_in_lane:, label:)
|
|
233
|
+
job = input.job
|
|
234
|
+
from = span.from
|
|
235
|
+
to = span.to
|
|
236
|
+
now = span.now
|
|
237
|
+
x = ->(t) { [W.to_f, [0.0, (t - from).fdiv(to - from) * W].max].min }
|
|
238
|
+
parsed = parsed_schedule(job)
|
|
239
|
+
due = due_times(job, parsed, input.runs, from, to)
|
|
240
|
+
missed = missed_at(job, parsed, due.times, now)
|
|
241
|
+
grace = safely(0) { Evaluate.grace_ms(job.definition) }
|
|
242
|
+
name = label
|
|
243
|
+
busy = []
|
|
244
|
+
inside = now_in_lane && now > from && now < to
|
|
245
|
+
|
|
246
|
+
s = +%(<svg class="marks" viewBox="0 0 #{W} 24" preserveAspectRatio="none" aria-hidden="true" focusable="false">)
|
|
247
|
+
s << %(<rect class="ahead" x="#{f(x.call(now))}" y="0" width="#{f(W - x.call(now))}" height="24"/>) if inside
|
|
248
|
+
s << %(<line class="base" x1="0" y1="12" x2="#{W}" y2="12"/>)
|
|
249
|
+
|
|
250
|
+
in_span = by_start(input.runs.select { |r| r.started_at <= to && (r.finished_at || now) >= from })
|
|
251
|
+
unless input.complete
|
|
252
|
+
oldest = input.runs.map(&:started_at).min
|
|
253
|
+
if !oldest.nil? && oldest > from
|
|
254
|
+
s << %(<rect class="unloaded" x="0" y="4" width="#{f(x.call(oldest))}" height="16"><title>#{h("#{name}: runs before #{when_at(oldest, now)} UTC are not loaded here")}</title></rect>)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if due.dense
|
|
259
|
+
s << %(<line class="cadence" x1="0" y1="12" x2="#{W}" y2="12"><title>#{h("#{name}: due #{job.definition.schedule}, too often to mark each time")}</title></line>)
|
|
260
|
+
end
|
|
261
|
+
due.times.each do |t|
|
|
262
|
+
tx = x.call(t)
|
|
263
|
+
s << %(<line class="tick#{t > now ? " ahead" : ""}" x1="#{f(tx)}" y1="6" x2="#{f(tx)}" y2="18" style="#{delay(tx, 0, 0.45)}"/>)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Missed slots: the reported one and every later one whose grace has run out.
|
|
267
|
+
if !missed.nil? && missed <= to
|
|
268
|
+
slots = due.dense ? [] : due.times.select { |t| t >= missed && t + grace < now }
|
|
269
|
+
slots.unshift(missed) if !slots.include?(missed) && missed >= from
|
|
270
|
+
title = h("#{name}: due #{when_at(missed, now)} UTC, nothing started#{slots.length > 1 ? " (#{slots.length} slots in this span)" : ""}")
|
|
271
|
+
if due.dense || slots.length > MAX_BOXES
|
|
272
|
+
x1 = x.call([missed, from].max)
|
|
273
|
+
x2 = [x.call(now), x1 + MIN_BOX].max
|
|
274
|
+
s << %(<rect class="missed" x="#{f(x1)}" y="5" width="#{f(x2 - x1)}" height="14" style="#{delay(x1)}"><title>#{title}</title></rect>)
|
|
275
|
+
busy << [x1, x2]
|
|
276
|
+
else
|
|
277
|
+
slots.each do |t|
|
|
278
|
+
next if t < from
|
|
279
|
+
|
|
280
|
+
x1 = x.call(t)
|
|
281
|
+
width = [x.call(t + grace) - x1, MIN_BOX].max
|
|
282
|
+
s << %(<rect class="missed" x="#{f(x1)}" y="5" width="#{f(width)}" height="14" style="#{delay(x1)}"><title>#{title}</title></rect>)
|
|
283
|
+
busy << [x1, x1 + width]
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
in_span.each do |run|
|
|
289
|
+
tone = tone_of(run, job, now)
|
|
290
|
+
# A zero-width rect is not drawn at all; its stroke gives short runs their width.
|
|
291
|
+
x1 = x.call(run.started_at)
|
|
292
|
+
x2 = [x.call(run.finished_at || now), x1 + 0.5].max
|
|
293
|
+
s << %(<rect class="run #{tone}" x="#{f(x1)}" y="5" width="#{f(x2 - x1)}" height="14" style="#{delay(x1)}"><title>#{h("#{name}: #{describe_run(run, tone, job, now)}")}</title></rect>)
|
|
294
|
+
busy << [x1, x2]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
s << %(<line class="nowline" x1="#{f(x.call(now))}" y1="0" x2="#{f(x.call(now))}" y2="24"/>) if inside
|
|
298
|
+
s << "</svg>"
|
|
299
|
+
|
|
300
|
+
# The note goes wherever the lane is actually empty, so it never sits on
|
|
301
|
+
# the marks it describes; it is cut short with an ellipsis when narrow.
|
|
302
|
+
text = lane_note(job, missed, now)
|
|
303
|
+
note = ""
|
|
304
|
+
if text
|
|
305
|
+
now_x = x.call(now)
|
|
306
|
+
lo = busy.empty? ? now_x : busy.map(&:first).min
|
|
307
|
+
hi = busy.empty? ? now_x : busy.map(&:last).max
|
|
308
|
+
right = W - hi >= lo
|
|
309
|
+
room = right ? W - hi : lo
|
|
310
|
+
if room > 90
|
|
311
|
+
edge = right ? hi + 14 : lo - 14
|
|
312
|
+
place = right ? "left:#{f(edge / 10.0)}%" : "right:#{f(100 - (edge / 10.0))}%"
|
|
313
|
+
note = %(<span class="note#{right ? "" : " before"}" style="#{place};max-width:#{f((room - 18) / 10.0)}%">#{h(text)}</span>)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
LaneParts.new(svg: s, note: note, words: words(job, in_span, due, missed, span, text))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# The lane in words, for anyone who cannot see it.
|
|
321
|
+
def words(job, runs, due, missed, span, note)
|
|
322
|
+
parts = []
|
|
323
|
+
if due.dense
|
|
324
|
+
parts << "due #{job.definition.schedule}"
|
|
325
|
+
elsif HTML.truthy?(job.definition.schedule)
|
|
326
|
+
n = due.times.count { |t| t <= span.now }
|
|
327
|
+
parts << "due #{n.zero? ? "no times" : n == 1 ? "once" : "#{n} times"} so far"
|
|
328
|
+
end
|
|
329
|
+
ok = runs.count { |r| r.status.to_s == "ok" }
|
|
330
|
+
summary =
|
|
331
|
+
if runs.empty? then ""
|
|
332
|
+
elsif ok == runs.length then ok == 1 ? ", ok" : ", all ok"
|
|
333
|
+
elsif ok.positive? then ", #{ok} ok"
|
|
334
|
+
else ""
|
|
335
|
+
end
|
|
336
|
+
parts << "#{runs.length} #{runs.length == 1 ? "run" : "runs"} recorded#{summary}"
|
|
337
|
+
runs.select { |r| %w[failed timeout].include?(r.status.to_s) }.last(5).each do |r|
|
|
338
|
+
parts << "#{r.status} at #{when_at(r.started_at, span.now)} UTC after #{Duration.format(r.duration_ms || 0)}"
|
|
339
|
+
end
|
|
340
|
+
parts << "due at #{when_at(missed, span.now)} UTC and nothing started" unless missed.nil?
|
|
341
|
+
parts << note if note && !note.match?(/\A(due |failed at|timed out)/)
|
|
342
|
+
parts.join("; ")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Grid lines and hour labels every `step`, on UTC boundaries.
|
|
346
|
+
def hours(span, step, now_label)
|
|
347
|
+
x = ->(t) { (t - span.from).fdiv(span.to - span.from) * W }
|
|
348
|
+
now_x = x.call(span.now)
|
|
349
|
+
lines = +""
|
|
350
|
+
labels = +""
|
|
351
|
+
t = span.from.fdiv(step).ceil * step
|
|
352
|
+
while t <= span.to
|
|
353
|
+
gx = x.call(t)
|
|
354
|
+
lines << %(<i class="gl" style="left:#{f(gx / 10)}%"></i>)
|
|
355
|
+
near_now = now_label && (gx - now_x).abs < 70
|
|
356
|
+
unless gx < 25 || gx > W - 25 || near_now
|
|
357
|
+
minor = (JS.round(t.fdiv(HOUR)) % 6) != 0
|
|
358
|
+
# On a phone the track is too narrow for a label this close to "now"; CSS hides it there.
|
|
359
|
+
near = now_label && (gx - now_x).abs < 170
|
|
360
|
+
cls = [("minor" if minor), ("near" if near)].compact.join(" ")
|
|
361
|
+
labels << %(<span class="#{cls}" style="left:#{f(gx / 10)}%">#{clock(t)}</span>)
|
|
362
|
+
end
|
|
363
|
+
t += step
|
|
364
|
+
end
|
|
365
|
+
if now_label && span.now >= span.from && span.now <= span.to
|
|
366
|
+
labels << %(<span class="nowlabel" style="left:#{f(now_x / 10)}%">now #{clock(span.now)}</span>)
|
|
367
|
+
end
|
|
368
|
+
[lines, labels]
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# The key under a timeline: a small sample of each mark and what it means.
|
|
372
|
+
def legend
|
|
373
|
+
key = ->(inner) { %(<svg class="key" viewBox="0 0 16 12" aria-hidden="true" focusable="false">#{inner}</svg>) }
|
|
374
|
+
box = ->(cls) { key.call(%(<rect class="#{cls}" x="2" y="1" width="12" height="10"/>)) }
|
|
375
|
+
items = [
|
|
376
|
+
[key.call(%(<line class="tick" x1="8" y1="1" x2="8" y2="11"/>)), "due"],
|
|
377
|
+
[box.call("run ok"), "ran"],
|
|
378
|
+
[box.call("run bad"), "failed"],
|
|
379
|
+
[box.call("run timeout"), "timed out"],
|
|
380
|
+
[box.call("run warn"), "over budget or slow"],
|
|
381
|
+
[box.call("run running"), "running"],
|
|
382
|
+
[box.call("missed"), "missed"],
|
|
383
|
+
]
|
|
384
|
+
%(<p class="legend" aria-hidden="true">#{items.map { |k, label| "<span>#{k}#{label}</span>" }.join}</p>)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def state_class(job)
|
|
388
|
+
STATE_CLASS.fetch(job.health.to_sym)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# The board's timeline: one lane per job across `span`, with a shared now
|
|
392
|
+
# line and the first BOARD_LANES jobs only. `total` is how many jobs there
|
|
393
|
+
# are in all, for the note when some are left out.
|
|
394
|
+
def day_timeline(lanes, span, base, total)
|
|
395
|
+
lines, labels = hours(span, 3 * HOUR, true)
|
|
396
|
+
now_x = (span.now - span.from).fdiv(span.to - span.from) * 100
|
|
397
|
+
rows = lanes.map do |input|
|
|
398
|
+
job = input.job
|
|
399
|
+
parts = lane(input, span, now_in_lane: false, label: job.name)
|
|
400
|
+
schedule = job.definition.schedule.nil? ? "no schedule" : job.definition.schedule
|
|
401
|
+
[
|
|
402
|
+
%(<li class="lane"><div class="who"><i class="sq #{state_class(job)}" aria-hidden="true"></i><a class="name" href="#{h(base)}/jobs/#{HTML.encode_uri_component(job.name)}">#{h(job.name)}</a><span class="sched">#{h(schedule)}</span></div><div class="track">#{parts.svg}#{parts.note}</div></li>),
|
|
403
|
+
"<li>#{h("#{job.name} (#{schedule}): #{parts.words}.")}</li>",
|
|
404
|
+
]
|
|
405
|
+
end
|
|
406
|
+
more = total > lanes.length ? %(<p class="more">Showing the first #{lanes.length} of #{total} jobs here; the table below lists them all.</p>) : ""
|
|
407
|
+
<<~HTML.chomp
|
|
408
|
+
<figure class="timeline day">
|
|
409
|
+
<div class="axis" aria-hidden="true"><span></span><div class="hours">#{labels}</div></div>
|
|
410
|
+
<div class="field">
|
|
411
|
+
<div class="under" aria-hidden="true"><span></span><div>#{lines}<i class="future" style="left:#{f(now_x)}%"></i></div></div>
|
|
412
|
+
<ol class="lanes">#{rows.map(&:first).join}</ol>
|
|
413
|
+
<div class="over" aria-hidden="true"><span></span><div><i class="now" style="left:#{f(now_x)}%"></i></div></div>
|
|
414
|
+
</div>
|
|
415
|
+
#{legend}#{more}
|
|
416
|
+
<ul class="vh">#{rows.map(&:last).join}</ul>
|
|
417
|
+
</figure>
|
|
418
|
+
HTML
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# A job's page: its last WEEK_DAYS UTC days, today first, one lane each.
|
|
422
|
+
# `complete` is false when the runs read do not reach back over the week.
|
|
423
|
+
def week_timeline(job, runs, complete, now)
|
|
424
|
+
today = now.div(DAY) * DAY
|
|
425
|
+
oldest = runs.map(&:started_at).min
|
|
426
|
+
lines, labels = hours(Span.new(from: today, to: today + DAY, now: now), 3 * HOUR, false)
|
|
427
|
+
rows = (0...WEEK_DAYS).map do |i|
|
|
428
|
+
from = today - (i * DAY)
|
|
429
|
+
span = Span.new(from: from, to: from + DAY, now: now)
|
|
430
|
+
day_runs = runs.select { |r| r.started_at < span.to && (r.finished_at || now) >= from }
|
|
431
|
+
known = complete || (!oldest.nil? && oldest <= from)
|
|
432
|
+
label = i.zero? ? "today" : day_label(from)
|
|
433
|
+
parts = lane(LaneInput.new(job: job, runs: runs, complete: known), span, now_in_lane: i.zero?, label: "#{job.name}, #{label}")
|
|
434
|
+
count = "#{day_runs.length} #{day_runs.length == 1 ? "run" : "runs"}"
|
|
435
|
+
[
|
|
436
|
+
%(<li class="lane#{i.zero? ? " today" : ""}"><div class="who"><span class="name">#{h(i.zero? ? "Today, #{day_label(from)[4..]}" : day_label(from))}</span><span class="sched">#{h(count)}</span></div><div class="track">#{parts.svg}#{i.zero? ? parts.note : ""}</div></li>),
|
|
437
|
+
"<li>#{h("#{i.zero? ? "Today" : day_label(from)}: #{parts.words}.")}</li>",
|
|
438
|
+
]
|
|
439
|
+
end
|
|
440
|
+
<<~HTML.chomp
|
|
441
|
+
<figure class="timeline week">
|
|
442
|
+
<div class="axis" aria-hidden="true"><span></span><div class="hours">#{labels}</div></div>
|
|
443
|
+
<div class="field">
|
|
444
|
+
<div class="under" aria-hidden="true"><span></span><div>#{lines}</div></div>
|
|
445
|
+
<ol class="lanes">#{rows.map(&:first).join}</ol>
|
|
446
|
+
</div>
|
|
447
|
+
#{legend}
|
|
448
|
+
<ul class="vh">#{rows.map(&:last).join}</ul>
|
|
449
|
+
</figure>
|
|
450
|
+
HTML
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# How many runs a job's page reads so its week is drawn in full: roughly
|
|
454
|
+
# how often the schedule was due over the week, with room to spare, from
|
|
455
|
+
# 50 (what the run list shows) to 500 (the most runs returns).
|
|
456
|
+
def week_runs_limit(job, now)
|
|
457
|
+
parsed = parsed_schedule(job)
|
|
458
|
+
return 50 if parsed.nil?
|
|
459
|
+
|
|
460
|
+
from = (now.div(DAY) * DAY) - ((WEEK_DAYS - 1) * DAY)
|
|
461
|
+
span = now + DAY - from
|
|
462
|
+
# A cron's fires over one day, times the week: close enough, and cheap.
|
|
463
|
+
expected =
|
|
464
|
+
if parsed.interval? then span.fdiv(parsed.every_ms)
|
|
465
|
+
else
|
|
466
|
+
due = due_times(job, parsed, [], now - DAY, now)
|
|
467
|
+
due.dense ? Float::INFINITY : (due.times.length * span).fdiv(DAY)
|
|
468
|
+
end
|
|
469
|
+
wanted = expected * 1.2
|
|
470
|
+
return 500 unless wanted.finite?
|
|
471
|
+
|
|
472
|
+
(wanted.ceil + 10).clamp(50, 500)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
data/lib/cronwatch/web.rb
CHANGED
|
@@ -13,6 +13,9 @@ rescue LoadError => e
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
require "cronwatch" unless defined?(Cronwatch::Client)
|
|
16
|
+
require_relative "web/icons"
|
|
17
|
+
require_relative "web/pwa"
|
|
16
18
|
require_relative "web/html"
|
|
19
|
+
require_relative "web/timeline"
|
|
17
20
|
require_relative "web/app"
|
|
18
21
|
require_relative "web/origin"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cronwatch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon C. Phillips
|
|
@@ -89,7 +89,10 @@ files:
|
|
|
89
89
|
- lib/cronwatch/web.rb
|
|
90
90
|
- lib/cronwatch/web/app.rb
|
|
91
91
|
- lib/cronwatch/web/html.rb
|
|
92
|
+
- lib/cronwatch/web/icons.rb
|
|
92
93
|
- lib/cronwatch/web/origin.rb
|
|
94
|
+
- lib/cronwatch/web/pwa.rb
|
|
95
|
+
- lib/cronwatch/web/timeline.rb
|
|
93
96
|
- lib/cronwatch/zone.rb
|
|
94
97
|
- lib/generators/cronwatch/install/install_generator.rb
|
|
95
98
|
homepage: https://cronwatch.dev
|