cronwatch 0.11.0 → 0.12.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/README.md +4 -3
- data/lib/cronwatch/alerts/datadog.rb +2 -1
- data/lib/cronwatch/alerts/discord.rb +2 -1
- data/lib/cronwatch/alerts/honeybadger.rb +1 -1
- data/lib/cronwatch/alerts/provider.rb +1 -1
- data/lib/cronwatch/alerts/slack.rb +1 -1
- data/lib/cronwatch/client.rb +16 -1
- data/lib/cronwatch/evaluate.rb +52 -4
- data/lib/cronwatch/format.rb +11 -0
- data/lib/cronwatch/monitored.rb +1 -1
- data/lib/cronwatch/pg_cron.rb +1 -1
- data/lib/cronwatch/types.rb +9 -6
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/html.rb +7 -4
- data/lib/cronwatch/web/pwa.rb +18 -2
- data/lib/cronwatch/web/timeline.rb +22 -3
- data/lib/generators/cronwatch/install/install_generator.rb +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d07fd66287ac18a9275761ad9f47f458677fb59a1a561127a41347489a56fcd
|
|
4
|
+
data.tar.gz: debcbaa164095d25d0362d477da3b3ce3a90ca4cf03a990a934c2cb3bacd6671
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f2ea67fe28637a9e9adf510927de1fd37a84d67376bbcffb1b6d8e64ed377ec3b0f6965a74bf57d4deb18c187e6a22b7c55460ed68198cd22ded70dcb59abd3c
|
|
7
|
+
data.tar.gz: 703d25d89a40768fd392f6564f7f8a686958847426dba51e56da19ff77a0ed8174a6cd1f68cbd15d3bcbde164f4fcf90c084dbd9eae8c6d8ecefbf7b406365e8
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cronwatch
|
|
2
2
|
|
|
3
|
-
Cron and scheduled-job monitoring that lives inside your Ruby or Rails app. Wrap a job once; every run is recorded in a database you already have, and you are told when a run is missed, fails, gets stuck, runs slow
|
|
3
|
+
Cron and scheduled-job monitoring that lives inside your Ruby or Rails app. Wrap a job once; every run is recorded in a database you already have, and you are told when a run is missed, fails, gets stuck, runs slow, goes over budget or quietly does nothing. No server to run, no account to make.
|
|
4
4
|
|
|
5
5
|
This is the Ruby port of [`@cronwatch/sdk`](https://www.npmjs.com/package/@cronwatch/sdk): the same rules, the same alert text and the same stored rows, so a Ruby process can share one database with a Node, Python, PHP, Go or Rust process, and [`@cronwatch/mcp`](https://www.npmjs.com/package/@cronwatch/mcp) works against any of them.
|
|
6
6
|
|
|
@@ -39,12 +39,13 @@ CW = Cronwatch.new(
|
|
|
39
39
|
|
|
40
40
|
NIGHTLY = CW.job("nightly-report",
|
|
41
41
|
schedule: "0 2 * * *", timezone: "UTC", grace: "15m", timeout: "30m",
|
|
42
|
-
expect: "Report written", budget: { cost: 2 })
|
|
42
|
+
expect: "Report written", budget: { cost: 2 }, floor: { pages: 1 })
|
|
43
43
|
|
|
44
44
|
NIGHTLY.run do |job|
|
|
45
45
|
path = build_report
|
|
46
46
|
job.log("Report written:", path) # kept with the run, shown in alerts
|
|
47
|
-
job.metric(:cost, 1.2) # watched against budgets and baselines
|
|
47
|
+
job.metric(:cost, 1.2) # watched against budgets, floors and baselines
|
|
48
|
+
job.metric(:pages, 12) # fewer than 1 page is under the floor
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
CW.start_checking # checks for missed and stuck runs every minute, in a background thread
|
|
@@ -9,7 +9,8 @@ module Cronwatch
|
|
|
9
9
|
# job:<name> and alert:<type>, then `tags`.
|
|
10
10
|
class Datadog
|
|
11
11
|
ALERT_TYPE = {
|
|
12
|
-
missed: "error", failed: "error", stuck: "error", slow: "warning", over_budget: "warning",
|
|
12
|
+
missed: "error", failed: "error", stuck: "error", slow: "warning", over_budget: "warning", under_floor: "warning",
|
|
13
|
+
recovered: "success",
|
|
13
14
|
}.freeze
|
|
14
15
|
private_constant :ALERT_TYPE
|
|
15
16
|
|
|
@@ -6,7 +6,8 @@ module Cronwatch
|
|
|
6
6
|
# Integrations, Webhooks).
|
|
7
7
|
class Discord
|
|
8
8
|
COLOR = {
|
|
9
|
-
missed: 0xb7791f, failed: 0xc62828, stuck: 0xc62828, slow: 0xb7791f, over_budget: 0xb7791f,
|
|
9
|
+
missed: 0xb7791f, failed: 0xc62828, stuck: 0xc62828, slow: 0xb7791f, over_budget: 0xb7791f, under_floor: 0xb7791f,
|
|
10
|
+
recovered: 0x1f8a4c,
|
|
10
11
|
}.freeze
|
|
11
12
|
|
|
12
13
|
# The longest embed description Discord takes. The title (under 256)
|
|
@@ -9,7 +9,7 @@ module Cronwatch
|
|
|
9
9
|
class Honeybadger
|
|
10
10
|
CLASS = {
|
|
11
11
|
missed: "CronWatch::Missed", failed: "CronWatch::Failed", stuck: "CronWatch::Stuck", slow: "CronWatch::Slow",
|
|
12
|
-
over_budget: "CronWatch::OverBudget", recovered: "CronWatch::Recovered",
|
|
12
|
+
over_budget: "CronWatch::OverBudget", under_floor: "CronWatch::UnderFloor", recovered: "CronWatch::Recovered",
|
|
13
13
|
}.freeze
|
|
14
14
|
private_constant :CLASS
|
|
15
15
|
|
|
@@ -9,7 +9,7 @@ module Cronwatch
|
|
|
9
9
|
class Slack
|
|
10
10
|
EMOJI = {
|
|
11
11
|
missed: ":hourglass_flowing_sand:", failed: ":x:", stuck: ":no_entry:", slow: ":turtle:",
|
|
12
|
-
over_budget: ":moneybag:", recovered: ":white_check_mark:",
|
|
12
|
+
over_budget: ":moneybag:", under_floor: ":chart_with_downwards_trend:", recovered: ":white_check_mark:",
|
|
13
13
|
}.freeze
|
|
14
14
|
private_constant :EMOJI
|
|
15
15
|
|
data/lib/cronwatch/client.rb
CHANGED
|
@@ -659,7 +659,7 @@ module Cronwatch
|
|
|
659
659
|
fields = {}
|
|
660
660
|
@defaults.each { |k, v| fields[k] = v }
|
|
661
661
|
options.each do |k, v|
|
|
662
|
-
fields[k.to_sym] = v.is_a?(Hash) && k.to_sym
|
|
662
|
+
fields[k.to_sym] = v.is_a?(Hash) && %i[budget floor].include?(k.to_sym) ? v.transform_keys(&:to_s) : v
|
|
663
663
|
end
|
|
664
664
|
fields[:name] = name
|
|
665
665
|
JobDefinition.new(fields)
|
|
@@ -700,6 +700,21 @@ module Cronwatch
|
|
|
700
700
|
raise ArgumentError, "job \"#{name}\": budget.#{metric} must be a finite number, 0 or more (got #{js_string(ceiling)})"
|
|
701
701
|
end
|
|
702
702
|
end
|
|
703
|
+
unless definition.floor.nil?
|
|
704
|
+
raise ArgumentError, "job \"#{name}\": floor must be an object of { metric: floor }" unless definition.floor.is_a?(Hash)
|
|
705
|
+
|
|
706
|
+
definition.floor.each do |metric, floor|
|
|
707
|
+
unless floor.is_a?(Numeric) && floor.real? && JS.finite?(floor)
|
|
708
|
+
raise ArgumentError, "job \"#{name}\": floor.#{metric} must be a finite number (got #{js_string(floor)})"
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
ceiling = definition.budget.is_a?(Hash) ? definition.budget[metric.to_s] : nil
|
|
712
|
+
next if ceiling.nil? || floor <= ceiling
|
|
713
|
+
|
|
714
|
+
raise ArgumentError,
|
|
715
|
+
"job \"#{name}\": floor.#{metric} (#{js_string(floor)}) is above budget.#{metric} (#{js_string(ceiling)}), so every run would alert"
|
|
716
|
+
end
|
|
717
|
+
end
|
|
703
718
|
expect = definition.expect
|
|
704
719
|
return if expect.nil? || expect.is_a?(String) || expect.is_a?(Regexp) || expect.respond_to?(:call)
|
|
705
720
|
|
data/lib/cronwatch/evaluate.rb
CHANGED
|
@@ -83,8 +83,8 @@ module Cronwatch
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
# A stored state with every field present, or a fresh one. State written
|
|
86
|
-
# by an older version lacks the newer fields. `sending`
|
|
87
|
-
#
|
|
86
|
+
# by an older version lacks the newer fields. `sending` and `under_floor`
|
|
87
|
+
# are the exceptions: each is there only while it holds something.
|
|
88
88
|
#
|
|
89
89
|
# Read leniently, since a foreign, hand-edited or damaged row must affect
|
|
90
90
|
# only its own job, and the next write puts it right: a state that is not
|
|
@@ -92,13 +92,15 @@ module Cronwatch
|
|
|
92
92
|
# number (anything but an object reads as {}); `silenced_until` and
|
|
93
93
|
# `last_alert_at` that are not numbers read as nil; `pending_recovery`
|
|
94
94
|
# keeps only its conditions (strings as stored), and `undelivered` only
|
|
95
|
-
# its entries that are objects (a list of neither shape reads as [])
|
|
96
|
-
#
|
|
95
|
+
# its entries that are objects (a list of neither shape reads as []),
|
|
96
|
+
# and `under_floor` only its strings (absent when none). Unknown fields
|
|
97
|
+
# are kept as written.
|
|
97
98
|
def normalize_state(state, job)
|
|
98
99
|
state = JobState.from_h(state)
|
|
99
100
|
return empty_state(job) if state.nil?
|
|
100
101
|
|
|
101
102
|
sending = state.sending
|
|
103
|
+
under_floor = state.under_floor.is_a?(Array) ? state.under_floor.grep(String) : []
|
|
102
104
|
open = state.open.is_a?(Hash) ? state.open.select { |_, at| json_number?(at) } : {}
|
|
103
105
|
pending = state.pending_recovery.is_a?(Array) ? state.pending_recovery.grep(Symbol) : []
|
|
104
106
|
undelivered = state.undelivered.is_a?(Array) ? state.undelivered.grep(Alert) : []
|
|
@@ -112,6 +114,7 @@ module Cronwatch
|
|
|
112
114
|
undelivered: undelivered,
|
|
113
115
|
version: state.version,
|
|
114
116
|
sending: sending.is_a?(Array) && !sending.empty? ? sending.dup : nil,
|
|
117
|
+
under_floor: under_floor.empty? ? nil : under_floor,
|
|
115
118
|
# Fields a newer release wrote, carried through every write.
|
|
116
119
|
extra: state.extra,
|
|
117
120
|
)
|
|
@@ -283,6 +286,42 @@ module Cronwatch
|
|
|
283
286
|
breaches
|
|
284
287
|
end
|
|
285
288
|
|
|
289
|
+
# The metrics of a successful run that fell below their floor. A metric
|
|
290
|
+
# with a floor breaches when it reports less than that floor. One without
|
|
291
|
+
# breaches when it reports 0 or less and either it did so on the job's
|
|
292
|
+
# last successful run too (`previous`, the metrics under their floor
|
|
293
|
+
# then), or the earlier successful runs that reported it (at least five,
|
|
294
|
+
# the newest twenty) all reported more than 0. So a job that keeps writing
|
|
295
|
+
# nothing stays under its floor however long it goes on, and a metric that
|
|
296
|
+
# is always 0 never alerts.
|
|
297
|
+
def floor_breaches(definition, run, history, previous = [])
|
|
298
|
+
breaches = []
|
|
299
|
+
floor = definition.floor
|
|
300
|
+
metrics = run.metrics || {}
|
|
301
|
+
JS.object_keys(metrics).each do |metric|
|
|
302
|
+
value = metrics[metric]
|
|
303
|
+
limit = floor&.[](metric.to_s)
|
|
304
|
+
unless limit.nil?
|
|
305
|
+
breaches << { metric: metric.to_s, value: value, limit: limit, basis: "floor" } if value < limit
|
|
306
|
+
next
|
|
307
|
+
end
|
|
308
|
+
next if value.positive?
|
|
309
|
+
|
|
310
|
+
if previous.include?(metric.to_s)
|
|
311
|
+
breaches << { metric: metric.to_s, value: value, limit: 0, basis: "0 or less on the run before too" }
|
|
312
|
+
next
|
|
313
|
+
end
|
|
314
|
+
past = history.select { |r| r.status == :ok && (r.metrics || {})[metric].is_a?(Numeric) }
|
|
315
|
+
.first(BASELINE_WINDOW).map { |r| r.metrics[metric] }
|
|
316
|
+
next if past.length < BASELINE_MIN_RUNS || !past.all?(&:positive?)
|
|
317
|
+
|
|
318
|
+
lowest = past.min
|
|
319
|
+
breaches << { metric: metric.to_s, value: value, limit: lowest,
|
|
320
|
+
basis: "the last #{past.length} runs all reported more than 0, the lowest #{format_number(lowest)}" }
|
|
321
|
+
end
|
|
322
|
+
breaches
|
|
323
|
+
end
|
|
324
|
+
|
|
286
325
|
# Whether `history` (newest first) holds a full baseline window of successful runs.
|
|
287
326
|
def full_baseline?(history)
|
|
288
327
|
history.count { |r| r.status == :ok } >= BASELINE_WINDOW
|
|
@@ -364,6 +403,15 @@ module Cronwatch
|
|
|
364
403
|
close_condition(next_state, :over_budget)
|
|
365
404
|
end
|
|
366
405
|
|
|
406
|
+
short = floor_breaches(definition, run, history, next_state.under_floor || [])
|
|
407
|
+
if short.any?
|
|
408
|
+
next_state.under_floor = short.map { |b| b[:metric] }
|
|
409
|
+
alerts << AlertDraft.new(type: :under_floor, run: run, details: { breaches: short }) if open_condition(next_state, :under_floor, now)
|
|
410
|
+
else
|
|
411
|
+
next_state.under_floor = nil
|
|
412
|
+
close_condition(next_state, :under_floor)
|
|
413
|
+
end
|
|
414
|
+
|
|
367
415
|
pending = next_state.pending_recovery || []
|
|
368
416
|
if pending.any? && open_conditions(next_state).empty?
|
|
369
417
|
alerts << AlertDraft.new(type: :recovered, run: run, details: { after: pending.dup })
|
data/lib/cronwatch/format.rb
CHANGED
|
@@ -77,6 +77,17 @@ module Cronwatch
|
|
|
77
77
|
end
|
|
78
78
|
lines << "Started #{at_time(run.started_at, now)}." if run
|
|
79
79
|
"#{name} went over budget"
|
|
80
|
+
when :under_floor
|
|
81
|
+
details[:breaches].each do |b|
|
|
82
|
+
value = Evaluate.format_number(b[:value])
|
|
83
|
+
lines << if b[:basis] == "floor"
|
|
84
|
+
"#{b[:metric]}: #{value}, below the floor of #{Evaluate.format_number(b[:limit])}."
|
|
85
|
+
else
|
|
86
|
+
"#{b[:metric]}: #{value} (#{b[:basis]})."
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
lines << "Started #{at_time(run.started_at, now)}." if run
|
|
90
|
+
"#{name} fell short"
|
|
80
91
|
when :recovered
|
|
81
92
|
if details[:reason]&.to_sym == :unscheduled
|
|
82
93
|
since = details[:since]
|
data/lib/cronwatch/monitored.rb
CHANGED
|
@@ -192,7 +192,7 @@ module Cronwatch
|
|
|
192
192
|
# The class side: `cronwatch` and what it declares.
|
|
193
193
|
module ClassMethods
|
|
194
194
|
# Monitor this job. Takes the options of Cronwatch::Client#job (schedule,
|
|
195
|
-
# timezone, grace, timeout, max_duration, budget, expect,
|
|
195
|
+
# timezone, grace, timeout, max_duration, budget, floor, expect,
|
|
196
196
|
# failures_before_alert, description, tags) and name:, which defaults to
|
|
197
197
|
# the class name without "Job", dasherized. schedule: :from_scheduler
|
|
198
198
|
# takes the schedule (and its timezone) from the class's entry in Solid
|
data/lib/cronwatch/pg_cron.rb
CHANGED
|
@@ -86,7 +86,7 @@ module Cronwatch
|
|
|
86
86
|
UTC = /\A(gmt|utc|z)\z/i
|
|
87
87
|
SCHEDULE_ONLY = %i[schedule timezone].freeze
|
|
88
88
|
# The options of a definition that are declared again, without its schedule, for a name no longer in use.
|
|
89
|
-
UNSCHEDULED = %i[description tags grace timeout max_duration budget failures_before_alert].freeze
|
|
89
|
+
UNSCHEDULED = %i[description tags grace timeout max_duration budget floor failures_before_alert].freeze
|
|
90
90
|
DESCRIBED = /\Apg_cron job (\d+) in /
|
|
91
91
|
private_constant :BACKFILL, :COLUMNS, :DESCRIBED, :JOBS_SQL, :MAX_PAGES, :NEWEST_SQL, :PAGE, :REBOOT, :RUNS_SQL,
|
|
92
92
|
:SCHEDULE_ONLY, :SECONDS, :SETTING_SQL, :UNSCHEDULED, :UTC
|
data/lib/cronwatch/types.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Cronwatch
|
|
4
|
-
CONDITIONS = %i[missed failed stuck slow over_budget].freeze
|
|
4
|
+
CONDITIONS = %i[missed failed stuck slow over_budget under_floor].freeze
|
|
5
5
|
RUN_STATUSES = %i[running ok failed timeout].freeze
|
|
6
6
|
|
|
7
7
|
# Ruby names are snake_case symbols; everything that leaves the process
|
|
@@ -74,7 +74,7 @@ module Cronwatch
|
|
|
74
74
|
|
|
75
75
|
FIELDS = {
|
|
76
76
|
name: "name", schedule: "schedule", timezone: "timezone", grace: "grace", timeout: "timeout",
|
|
77
|
-
max_duration: "maxDuration", budget: "budget", expect: "expect",
|
|
77
|
+
max_duration: "maxDuration", budget: "budget", floor: "floor", expect: "expect",
|
|
78
78
|
failures_before_alert: "failuresBeforeAlert", description: "description", tags: "tags",
|
|
79
79
|
}.freeze
|
|
80
80
|
OPTIONS = (FIELDS.keys - [:name]).freeze
|
|
@@ -386,7 +386,7 @@ module Cronwatch
|
|
|
386
386
|
# when there are none. An open condition this version does not know stays
|
|
387
387
|
# in `open` the same way, under its own name.
|
|
388
388
|
JobState = Struct.new(:job, :open, :consecutive_failures, :silenced_until, :last_alert_at, :pending_recovery,
|
|
389
|
-
:undelivered, :version, :sending, :extra, keyword_init: true) do
|
|
389
|
+
:undelivered, :version, :sending, :under_floor, :extra, keyword_init: true) do
|
|
390
390
|
include Serializable
|
|
391
391
|
|
|
392
392
|
def self.from_h(hash)
|
|
@@ -397,6 +397,7 @@ module Cronwatch
|
|
|
397
397
|
pending = Naming.fetch(hash, "pendingRecovery")
|
|
398
398
|
undelivered = Naming.fetch(hash, "undelivered")
|
|
399
399
|
sending = Naming.fetch(hash, "sending")
|
|
400
|
+
under_floor = Naming.fetch(hash, "underFloor")
|
|
400
401
|
extra = hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v unless JobState::KNOWN.include?(k.to_s) }
|
|
401
402
|
new(
|
|
402
403
|
extra: extra.empty? ? nil : extra,
|
|
@@ -409,6 +410,7 @@ module Cronwatch
|
|
|
409
410
|
undelivered: undelivered.is_a?(Array) ? undelivered.map { |a| a.is_a?(Hash) ? Alert.from_h(a) : a } : undelivered,
|
|
410
411
|
version: Naming.fetch(hash, "version"),
|
|
411
412
|
sending: sending.is_a?(Array) ? sending.map { |entry| JobState.sending_entry(entry) } : sending,
|
|
413
|
+
under_floor: under_floor,
|
|
412
414
|
)
|
|
413
415
|
end
|
|
414
416
|
|
|
@@ -434,8 +436,8 @@ module Cronwatch
|
|
|
434
436
|
|
|
435
437
|
# pendingRecovery, undelivered and version are left out when unset, as in
|
|
436
438
|
# state written before they existed. The version comes after them, where
|
|
437
|
-
# the SDK's spread of a normalized state puts it,
|
|
438
|
-
# only while it holds an entry.
|
|
439
|
+
# the SDK's spread of a normalized state puts it, then `sending` and
|
|
440
|
+
# `underFloor`, each only while it holds an entry.
|
|
439
441
|
def to_h
|
|
440
442
|
out = {
|
|
441
443
|
"job" => job, "open" => open.is_a?(Hash) ? open.transform_keys(&:to_s) : (open || {}),
|
|
@@ -454,6 +456,7 @@ module Cronwatch
|
|
|
454
456
|
entry.is_a?(Hash) ? entry.transform_values { |v| v.is_a?(Alert) ? v.to_h : v } : entry
|
|
455
457
|
end
|
|
456
458
|
end
|
|
459
|
+
out["underFloor"] = under_floor.dup if under_floor.is_a?(Array) && !under_floor.empty?
|
|
457
460
|
extra&.each { |key, value| out[key] = value unless out.key?(key) || JobState::KNOWN.include?(key) }
|
|
458
461
|
out
|
|
459
462
|
end
|
|
@@ -461,7 +464,7 @@ module Cronwatch
|
|
|
461
464
|
|
|
462
465
|
# The fields this version reads from a stored state; the rest are kept in `extra`.
|
|
463
466
|
# @api private
|
|
464
|
-
JobState::KNOWN = %w[job open consecutiveFailures silencedUntil lastAlertAt pendingRecovery undelivered version sending].freeze
|
|
467
|
+
JobState::KNOWN = %w[job open consecutiveFailures silencedUntil lastAlertAt pendingRecovery undelivered version sending underFloor].freeze
|
|
465
468
|
|
|
466
469
|
JobStats = Struct.new(:runs, :ok_rate, :p50_ms, :p95_ms, keyword_init: true) do
|
|
467
470
|
include Serializable
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/html.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Cronwatch
|
|
4
4
|
class Web
|
|
5
5
|
# The dashboard's pages, markup for markup the SDK's routes/html.ts. The
|
|
6
|
-
# one script, app.js,
|
|
6
|
+
# one script, app.js, registers the service worker and toggles the theme: the pages refresh
|
|
7
7
|
# themselves and the forget button confirms with a <details>.
|
|
8
8
|
#
|
|
9
9
|
# Set like cronwatch.dev: a printed sheet on grey paper, a serif for what a
|
|
@@ -26,7 +26,8 @@ module Cronwatch
|
|
|
26
26
|
module HTML
|
|
27
27
|
CSS = "\n" + <<~'CSS'
|
|
28
28
|
: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}
|
|
29
|
-
@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}}
|
|
29
|
+
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--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}}
|
|
30
|
+
:root[data-theme=light]{color-scheme:light}:root[data-theme=dark]{color-scheme:dark;--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}
|
|
30
31
|
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
|
31
32
|
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}
|
|
32
33
|
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}
|
|
@@ -251,7 +252,7 @@ module Cronwatch
|
|
|
251
252
|
|
|
252
253
|
# A page. `base` is where the dashboard is mounted ("" at the root): the
|
|
253
254
|
# head links the web app manifest, the icons and app.js, the one script,
|
|
254
|
-
# which
|
|
255
|
+
# which registers the service worker and toggles the theme (Web::PWA). Everything works
|
|
255
256
|
# without it.
|
|
256
257
|
def layout(title, body, base, refresh: nil)
|
|
257
258
|
b = h(base)
|
|
@@ -289,7 +290,7 @@ module Cronwatch
|
|
|
289
290
|
%(<p class="brand">#{home}<span class="slash" aria-hidden="true">/</span><span class="crumb">#{name_html(crumb)}</span></p>)
|
|
290
291
|
end
|
|
291
292
|
|
|
292
|
-
# The job's health, with any open condition it does not already say (over budget, slow) after it.
|
|
293
|
+
# The job's health, with any open condition it does not already say (over budget, under floor, slow) after it.
|
|
293
294
|
def health_state(job)
|
|
294
295
|
cls, label = HEALTH.fetch(job.health.to_sym)
|
|
295
296
|
extras = job.open.map(&:to_sym).reject { |c| SHOWN_BY_HEALTH.include?(c) }
|
|
@@ -494,6 +495,7 @@ module Cronwatch
|
|
|
494
495
|
%(<span class="muted">none</span>)
|
|
495
496
|
end
|
|
496
497
|
budget = truthy?(d.budget) ? entries(d.budget).map { |k, v| "#{k} ≤ #{text(v)}" }.join(", ") : nil
|
|
498
|
+
floor = truthy?(d.floor) ? entries(d.floor).map { |k, v| "#{k} ≥ #{text(v)}" }.join(", ") : nil
|
|
497
499
|
tags = d.tags
|
|
498
500
|
failures = d.failures_before_alert
|
|
499
501
|
open = job.open.map do |c|
|
|
@@ -553,6 +555,7 @@ module Cronwatch
|
|
|
553
555
|
<dt>Timeout</dt><dd>#{h(d.timeout.nil? ? "1h" : d.timeout)}</dd>
|
|
554
556
|
#{truthy?(d.max_duration) ? %(<dt>Max duration</dt><dd>#{h(d.max_duration)}</dd>) : ""}
|
|
555
557
|
#{budget ? %(<dt>Budget</dt><dd>#{h(budget)}</dd>) : ""}
|
|
558
|
+
#{floor ? %(<dt>Floor</dt><dd>#{h(floor)}</dd>) : ""}
|
|
556
559
|
#{truthy?(d.expect) ? %(<dt>Expect</dt><dd>#{h(d.expect)}</dd>) : ""}
|
|
557
560
|
#{truthy?(failures) && failures > 1 ? %(<dt>Alert after</dt><dd>#{h(failures)} consecutive failures</dd>) : ""}
|
|
558
561
|
#{tags.is_a?(Array) && tags.any? ? %(<dt>Tags</dt><dd>#{tags.map { |t| h(t) }.join(", ")}</dd>) : ""}
|
data/lib/cronwatch/web/pwa.rb
CHANGED
|
@@ -16,11 +16,27 @@ module Cronwatch
|
|
|
16
16
|
THEME_COLOR = "#ffffff"
|
|
17
17
|
THEME_COLOR_DARK = "#111113"
|
|
18
18
|
|
|
19
|
-
# Registers the service worker, and
|
|
20
|
-
#
|
|
19
|
+
# Registers the service worker, and switches between light and dark on
|
|
20
|
+
# Cmd+Shift+D (Ctrl+Shift+D elsewhere), keeping the choice in
|
|
21
|
+
# localStorage. The page works the same without it. Its own URL gives the base, so it is the same
|
|
21
22
|
# text wherever the dashboard is mounted.
|
|
22
23
|
APP_JS = <<~'JS'
|
|
23
24
|
"use strict";
|
|
25
|
+
(function () {
|
|
26
|
+
var root = document.documentElement;
|
|
27
|
+
try {
|
|
28
|
+
var stored = localStorage.getItem("cronwatch-theme");
|
|
29
|
+
if (stored === "light" || stored === "dark") root.setAttribute("data-theme", stored);
|
|
30
|
+
} catch (e) {}
|
|
31
|
+
document.addEventListener("keydown", function (event) {
|
|
32
|
+
if (!(event.metaKey || event.ctrlKey) || !event.shiftKey || event.altKey || event.code !== "KeyD") return;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
var shown = root.getAttribute("data-theme") || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
|
35
|
+
var next = shown === "dark" ? "light" : "dark";
|
|
36
|
+
root.setAttribute("data-theme", next);
|
|
37
|
+
try { localStorage.setItem("cronwatch-theme", next); } catch (e) {}
|
|
38
|
+
});
|
|
39
|
+
})();
|
|
24
40
|
(function () {
|
|
25
41
|
var script = document.currentScript;
|
|
26
42
|
if (!script || !("serviceWorker" in navigator)) return;
|
|
@@ -178,7 +178,7 @@ module Cronwatch
|
|
|
178
178
|
return "timeout" if status == "timeout"
|
|
179
179
|
|
|
180
180
|
latest = !job.last_run.nil? && job.last_run.id == run.id
|
|
181
|
-
latest && (open?(job, :over_budget) || open?(job, :slow)) ? "warn" : "ok"
|
|
181
|
+
latest && (open?(job, :over_budget) || open?(job, :under_floor) || open?(job, :slow)) ? "warn" : "ok"
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
def timeout_text(job)
|
|
@@ -192,7 +192,12 @@ module Cronwatch
|
|
|
192
192
|
return "running since #{at}, past its #{timeout_text(job)} timeout" if tone == "stuck"
|
|
193
193
|
|
|
194
194
|
took = run.duration_ms.nil? ? "" : ", took #{Duration.format(run.duration_ms)}"
|
|
195
|
-
extra =
|
|
195
|
+
extra =
|
|
196
|
+
if tone != "warn" then ""
|
|
197
|
+
elsif open?(job, :over_budget) then ", over budget"
|
|
198
|
+
elsif open?(job, :under_floor) then ", under floor"
|
|
199
|
+
else ", slow"
|
|
200
|
+
end
|
|
196
201
|
"#{run.status} at #{at}#{took}#{extra}"
|
|
197
202
|
end
|
|
198
203
|
|
|
@@ -205,6 +210,16 @@ module Cronwatch
|
|
|
205
210
|
end.map(&:first)
|
|
206
211
|
end
|
|
207
212
|
|
|
213
|
+
# The metrics of the job's last run under their floors, or at 0 or less without one.
|
|
214
|
+
def under_floors(job)
|
|
215
|
+
floors = HTML.entries(job.definition.floor).to_h
|
|
216
|
+
metrics = job.last_run&.metrics || {}
|
|
217
|
+
JS.object_keys(metrics).select do |k|
|
|
218
|
+
value = metrics[k]
|
|
219
|
+
floors.key?(k.to_s) ? value < floors[k.to_s] : value <= 0
|
|
220
|
+
end.map(&:to_s)
|
|
221
|
+
end
|
|
222
|
+
|
|
208
223
|
# What is worth saying about the job in a few words, or nil when all is well.
|
|
209
224
|
def lane_note(job, missed, now)
|
|
210
225
|
last = job.last_run
|
|
@@ -226,6 +241,10 @@ module Cronwatch
|
|
|
226
241
|
over = over_ceilings(job)
|
|
227
242
|
return "went over budget#{over.empty? ? "" : " on #{over.join(" and ")}"} at #{when_at(last.started_at, now)}"
|
|
228
243
|
end
|
|
244
|
+
if open?(job, :under_floor) && last
|
|
245
|
+
under = under_floors(job)
|
|
246
|
+
return "fell short#{under.empty? ? "" : " on #{under.join(" and ")}"} at #{when_at(last.started_at, now)}"
|
|
247
|
+
end
|
|
229
248
|
return "slow: took #{Duration.format(last.duration_ms)}" if open?(job, :slow) && !last&.duration_ms.nil?
|
|
230
249
|
return "failing" if open?(job, :failed)
|
|
231
250
|
return "no runs yet, first due #{when_at(job.next_expected_at, now)}" if last.nil? && !job.next_expected_at.nil?
|
|
@@ -386,7 +405,7 @@ module Cronwatch
|
|
|
386
405
|
[box.call("run ok"), "ran"],
|
|
387
406
|
[box.call("run bad"), "failed"],
|
|
388
407
|
[box.call("run timeout"), "timed out"],
|
|
389
|
-
[box.call("run warn"), "over budget or slow"],
|
|
408
|
+
[box.call("run warn"), "over budget, under floor or slow"],
|
|
390
409
|
[box.call("run running"), "running"],
|
|
391
410
|
[box.call("missed"), "missed"],
|
|
392
411
|
]
|
|
@@ -126,8 +126,8 @@ module Cronwatch
|
|
|
126
126
|
<<~RUBY
|
|
127
127
|
# frozen_string_literal: true
|
|
128
128
|
|
|
129
|
-
# CronWatch: told when a scheduled job is missed, failed, stuck, slow
|
|
130
|
-
# over budget. https://cronwatch.dev/docs/
|
|
129
|
+
# CronWatch: told when a scheduled job is missed, failed, stuck, slow,
|
|
130
|
+
# over budget or under its floor. https://cronwatch.dev/docs/
|
|
131
131
|
#
|
|
132
132
|
# Monitor a job by including Cronwatch::ActiveJob and declaring its schedule:
|
|
133
133
|
#
|