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.
@@ -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,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: fugit
@@ -26,6 +27,7 @@ dependencies:
26
27
  description: 'Wrap a job, run a check, get told when it is missed, failed, stuck,
27
28
  slow or over budget. The Ruby port of @cronwatch/sdk: same rules, same alerts, same
28
29
  stored rows.'
30
+ email:
29
31
  executables: []
30
32
  extensions: []
31
33
  extra_rdoc_files: []
@@ -89,7 +91,10 @@ files:
89
91
  - lib/cronwatch/web.rb
90
92
  - lib/cronwatch/web/app.rb
91
93
  - lib/cronwatch/web/html.rb
94
+ - lib/cronwatch/web/icons.rb
92
95
  - lib/cronwatch/web/origin.rb
96
+ - lib/cronwatch/web/pwa.rb
97
+ - lib/cronwatch/web/timeline.rb
93
98
  - lib/cronwatch/zone.rb
94
99
  - lib/generators/cronwatch/install/install_generator.rb
95
100
  homepage: https://cronwatch.dev
@@ -102,6 +107,7 @@ metadata:
102
107
  bug_tracker_uri: https://github.com/phillips-jon/cronwatch/issues
103
108
  documentation_uri: https://cronwatch.dev/docs/ruby/
104
109
  rubygems_mfa_required: 'true'
110
+ post_install_message:
105
111
  rdoc_options: []
106
112
  require_paths:
107
113
  - lib
@@ -116,7 +122,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
116
122
  - !ruby/object:Gem::Version
117
123
  version: '0'
118
124
  requirements: []
119
- rubygems_version: 3.6.9
125
+ rubygems_version: 3.0.3.1
126
+ signing_key:
120
127
  specification_version: 4
121
128
  summary: Cron and scheduled-job monitoring that lives inside your app.
122
129
  test_files: []