cronwatch 0.3.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +268 -0
- data/lib/cronwatch/abort_signal.rb +45 -0
- data/lib/cronwatch/active_record.rb +11 -0
- data/lib/cronwatch/alerts/console.rb +22 -0
- data/lib/cronwatch/alerts/custom.rb +26 -0
- data/lib/cronwatch/alerts/discord.rb +52 -0
- data/lib/cronwatch/alerts/slack.rb +58 -0
- data/lib/cronwatch/alerts/webhook.rb +46 -0
- data/lib/cronwatch/client.rb +925 -0
- data/lib/cronwatch/cron_pattern.rb +277 -0
- data/lib/cronwatch/duration.rb +77 -0
- data/lib/cronwatch/environment.rb +29 -0
- data/lib/cronwatch/evaluate.rb +345 -0
- data/lib/cronwatch/flight.rb +42 -0
- data/lib/cronwatch/format.rb +89 -0
- data/lib/cronwatch/http.rb +52 -0
- data/lib/cronwatch/job.rb +144 -0
- data/lib/cronwatch/js.rb +188 -0
- data/lib/cronwatch/monitored.rb +259 -0
- data/lib/cronwatch/output.rb +199 -0
- data/lib/cronwatch/rails/active_job.rb +55 -0
- data/lib/cronwatch/rails/check_job.rb +32 -0
- data/lib/cronwatch/rails/railtie.rb +37 -0
- data/lib/cronwatch/rails/tasks.rb +12 -0
- data/lib/cronwatch/rails.rb +35 -0
- data/lib/cronwatch/schedule.rb +191 -0
- data/lib/cronwatch/scheduler.rb +763 -0
- data/lib/cronwatch/serialize.rb +51 -0
- data/lib/cronwatch/sidekiq.rb +129 -0
- data/lib/cronwatch/stats.rb +23 -0
- data/lib/cronwatch/stores/active_record.rb +397 -0
- data/lib/cronwatch/stores/memory.rb +163 -0
- data/lib/cronwatch/ticker.rb +59 -0
- data/lib/cronwatch/triage/anthropic.rb +134 -0
- data/lib/cronwatch/types.rb +341 -0
- data/lib/cronwatch/version.rb +6 -0
- data/lib/cronwatch/walker.rb +137 -0
- data/lib/cronwatch/web/app.rb +484 -0
- data/lib/cronwatch/web/html.rb +314 -0
- data/lib/cronwatch/web.rb +17 -0
- data/lib/cronwatch/zone.rb +72 -0
- data/lib/cronwatch.rb +96 -0
- data/lib/generators/cronwatch/install/install_generator.rb +176 -0
- metadata +104 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
class Web
|
|
5
|
+
# The dashboard's pages, markup for markup the SDK's routes/html.ts. No
|
|
6
|
+
# script anywhere: the pages refresh themselves and the forget button
|
|
7
|
+
# confirms with a <details>.
|
|
8
|
+
module HTML
|
|
9
|
+
CSS = "\n" + <<~'CSS'
|
|
10
|
+
:root{--bg:#fbfbf9;--fg:#1b1b18;--muted:#6b6b64;--line:#e6e5df;--card:#fff;--ok:#1f8a4c;--warn:#b7791f;--bad:#c62828;--info:#2b5fb3;--pill:#f1f0ea;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
|
|
11
|
+
@media(prefers-color-scheme:dark){:root{--bg:#121311;--fg:#ecece6;--muted:#9a9a91;--line:#2a2b27;--card:#1a1b18;--pill:#24251f}}
|
|
12
|
+
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
|
13
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 var(--sans)}
|
|
14
|
+
a{color:inherit}main{max-width:1080px;margin:0 auto;padding:24px 16px 64px}
|
|
15
|
+
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:20px}
|
|
16
|
+
header h1{font-size:18px;margin:0;letter-spacing:-.01em}header h1 a{text-decoration:none}
|
|
17
|
+
header .meta{color:var(--muted);font-size:13px}
|
|
18
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
|
19
|
+
table{width:100%;border-collapse:collapse;font-size:14px}
|
|
20
|
+
th{text-align:left;font-weight:600;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em;padding:10px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
|
|
21
|
+
td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
22
|
+
tr:last-child td{border-bottom:0}
|
|
23
|
+
.name{font-weight:600;white-space:nowrap}.name a{text-decoration:none}.name a:hover{text-decoration:underline}
|
|
24
|
+
.mono{font-family:var(--mono);font-size:13px}.muted{color:var(--muted)}.nowrap{white-space:nowrap}
|
|
25
|
+
.pill{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border-radius:999px;background:var(--pill);font-size:12px;font-weight:600;white-space:nowrap}
|
|
26
|
+
.pill::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
|
|
27
|
+
.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.info{color:var(--info)}.mutedpill{color:var(--muted)}
|
|
28
|
+
.spark{display:block}
|
|
29
|
+
form.inline{display:inline}
|
|
30
|
+
button,select{font:inherit;font-size:13px;padding:5px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);cursor:pointer}
|
|
31
|
+
button:hover{border-color:var(--muted)}
|
|
32
|
+
.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
33
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px}
|
|
34
|
+
.stat{padding:12px 14px}.stat .k{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.stat .v{font-size:20px;font-weight:600;margin-top:2px}
|
|
35
|
+
pre{margin:0;padding:10px 12px;background:var(--pill);border-radius:8px;font:12.5px/1.45 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:320px;overflow:auto}
|
|
36
|
+
details summary{cursor:pointer;color:var(--muted);font-size:13px}details{margin-top:6px}
|
|
37
|
+
details.confirm{margin:0}details.confirm summary{list-style:none;display:inline-block;font-size:13px;padding:5px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg)}
|
|
38
|
+
details.confirm summary::-webkit-details-marker{display:none}details.confirm[open] summary{border-color:var(--muted)}details.confirm form{margin-left:8px;font-size:13px}
|
|
39
|
+
.empty{padding:40px 16px;text-align:center;color:var(--muted)}
|
|
40
|
+
dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:0;padding:14px 16px;font-size:14px}dt{color:var(--muted)}dd{margin:0}
|
|
41
|
+
footer{margin-top:28px;color:var(--muted);font-size:12px}
|
|
42
|
+
@media(max-width:720px){.hide-sm{display:none}main{padding:16px 16px 48px}}
|
|
43
|
+
CSS
|
|
44
|
+
|
|
45
|
+
HEALTH = {
|
|
46
|
+
healthy: %w[ok healthy], late: %w[warn late], failing: %w[bad failing], stuck: %w[bad stuck],
|
|
47
|
+
silenced: %w[mutedpill silenced], never_ran: ["info", "never ran"],
|
|
48
|
+
}.freeze
|
|
49
|
+
# Conditions the health pill already says; the others get a pill of their own.
|
|
50
|
+
SHOWN_BY_HEALTH = %i[missed failed stuck].freeze
|
|
51
|
+
# What encodeURIComponent leaves alone.
|
|
52
|
+
URI_UNRESERVED = /[A-Za-z0-9\-_.!~*'()]/
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# escapeHtml: String(value ?? "") with & < > " ' escaped.
|
|
57
|
+
def h(value)
|
|
58
|
+
text(value).gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """).gsub("'", "'")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# String(value), the way a template literal writes it.
|
|
62
|
+
def text(value)
|
|
63
|
+
case value
|
|
64
|
+
when nil then ""
|
|
65
|
+
when String then value
|
|
66
|
+
when Symbol then value.to_s
|
|
67
|
+
when Numeric then JS.number(value)
|
|
68
|
+
when Array then value.map { |v| v.nil? ? "" : text(v) }.join(",")
|
|
69
|
+
when Hash then "[object Object]"
|
|
70
|
+
else value.to_s
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# JavaScript truthiness, for the template's `x ? a : b`.
|
|
75
|
+
def truthy?(value)
|
|
76
|
+
return false if value.nil? || value == false || value == ""
|
|
77
|
+
return false if value.is_a?(Numeric) && (value.zero? || (value.is_a?(Float) && value.nan?))
|
|
78
|
+
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# encodeURIComponent.
|
|
83
|
+
def encode_uri_component(value)
|
|
84
|
+
text(value).each_char.map do |c|
|
|
85
|
+
URI_UNRESERVED.match?(c) ? c : c.bytes.map { |b| format("%%%02X", b) }.join
|
|
86
|
+
end.join
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Number.prototype.toFixed: the nearest `digits`-place decimal to the
|
|
90
|
+
# exact value of the double, halves away from zero.
|
|
91
|
+
def to_fixed(value, digits)
|
|
92
|
+
return JS.number(value) if !JS.finite?(value) || value.abs >= 1e21
|
|
93
|
+
|
|
94
|
+
scaled = (Rational(value.abs) * (10**digits)).round(half: :up).to_s
|
|
95
|
+
scaled = scaled.rjust(digits + 1, "0") if digits.positive?
|
|
96
|
+
out = digits.positive? ? "#{scaled[0...-digits]}.#{scaled[-digits..]}" : scaled
|
|
97
|
+
value.negative? ? "-#{out}" : out
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Object.entries: integer-like keys first, ascending, then the rest in insertion order.
|
|
101
|
+
def entries(hash)
|
|
102
|
+
return [] if hash.nil?
|
|
103
|
+
|
|
104
|
+
JS.object_keys(hash).map { |k| [k.to_s, hash[k]] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def layout(title, body, refresh: nil)
|
|
108
|
+
<<~HTML.chomp
|
|
109
|
+
<!doctype html>
|
|
110
|
+
<html lang="en">
|
|
111
|
+
<head>
|
|
112
|
+
<meta charset="utf-8">
|
|
113
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
114
|
+
<meta name="robots" content="noindex,nofollow">
|
|
115
|
+
#{truthy?(refresh) ? "<meta http-equiv=\"refresh\" content=\"#{text(refresh)}\">" : ""}
|
|
116
|
+
<title>#{h(title)}</title>
|
|
117
|
+
<style>#{CSS}</style>
|
|
118
|
+
</head>
|
|
119
|
+
<body><main>#{body}</main></body>
|
|
120
|
+
</html>
|
|
121
|
+
HTML
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def health_pill(job)
|
|
125
|
+
cls, label = HEALTH.fetch(job.health.to_sym)
|
|
126
|
+
extras = job.open.map(&:to_sym).reject { |c| SHOWN_BY_HEALTH.include?(c) }.map { |c| c.to_s.sub("_", " ") }
|
|
127
|
+
extra = extras.empty? ? "" : %( <span class="pill warn">#{h(extras.join(", "))}</span>)
|
|
128
|
+
%(<span class="pill #{cls}">#{label}</span>#{extra})
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def run_pill(run)
|
|
132
|
+
status = run.status.to_s
|
|
133
|
+
cls = if status == "ok" then "ok"
|
|
134
|
+
elsif status == "running" then "info"
|
|
135
|
+
else "bad"
|
|
136
|
+
end
|
|
137
|
+
%(<span class="pill #{cls}">#{h(status)}</span>)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def sparkline(runs)
|
|
141
|
+
points = runs.reverse.reject { |r| r.duration_ms.nil? }.last(20)
|
|
142
|
+
return "" if points.length < 2
|
|
143
|
+
|
|
144
|
+
w = 96
|
|
145
|
+
hgt = 22
|
|
146
|
+
max = [*points.map(&:duration_ms), 1].max
|
|
147
|
+
step = w.fdiv(points.length - 1)
|
|
148
|
+
y = ->(r) { to_fixed(hgt - 2 - (r.duration_ms.fdiv(max) * (hgt - 4)), 1) }
|
|
149
|
+
path = points.each_with_index.map { |r, i| "#{i.zero? ? "M" : "L"}#{to_fixed(i * step, 1)},#{y.call(r)}" }.join(" ")
|
|
150
|
+
dots = points.each_with_index.map do |r, i|
|
|
151
|
+
r.status.to_s == "ok" ? "" : %(<circle cx="#{to_fixed(i * step, 1)}" cy="#{y.call(r)}" r="2.2" fill="var(--bad)"/>)
|
|
152
|
+
end.join
|
|
153
|
+
%(<svg class="spark" width="#{w}" height="#{hgt}" viewBox="0 0 #{w} #{hgt}" aria-hidden="true"><path d="#{path}" fill="none" stroke="var(--muted)" stroke-width="1.5"/>#{dots}</svg>)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def stamp(at, now)
|
|
157
|
+
return %(<span class="muted">never</span>) if at.nil?
|
|
158
|
+
|
|
159
|
+
iso = JS.iso(at.to_i).sub("T", " ")[0, 19]
|
|
160
|
+
%(<span class="nowrap" title="#{iso} UTC">#{h(Duration.relative(at, now))}</span>)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def dashboard_page(jobs, runs_by_job, now, base, checked_at)
|
|
164
|
+
rows = jobs.map do |job|
|
|
165
|
+
last = job.last_run
|
|
166
|
+
d = job.definition
|
|
167
|
+
description = truthy?(d.description) ? %(<div class="muted" style="font-weight:400;font-size:13px;white-space:normal">#{h(d.description)}</div>) : ""
|
|
168
|
+
last_cell =
|
|
169
|
+
if last
|
|
170
|
+
took = last.duration_ms.nil? ? "" : %( <span class="muted">#{h(Duration.format(last.duration_ms))}</span>)
|
|
171
|
+
"#{run_pill(last)} #{stamp(last.started_at, now)}#{took}"
|
|
172
|
+
else
|
|
173
|
+
%(<span class="muted">never</span>)
|
|
174
|
+
end
|
|
175
|
+
<<~ROW.chomp
|
|
176
|
+
<tr>
|
|
177
|
+
<td class="name"><a href="#{h(base)}/jobs/#{encode_uri_component(job.name)}">#{h(job.name)}</a>#{description}</td>
|
|
178
|
+
<td>#{health_pill(job)}</td>
|
|
179
|
+
<td class="mono nowrap">#{h(d.schedule.nil? ? "" : d.schedule)}<span class="muted">#{truthy?(d.schedule) ? "" : "no schedule"}</span></td>
|
|
180
|
+
<td class="nowrap">#{last_cell}</td>
|
|
181
|
+
<td class="nowrap hide-sm">#{stamp(job.next_expected_at, now)}</td>
|
|
182
|
+
<td class="hide-sm">#{sparkline(runs_by_job[job.name] || [])}</td>
|
|
183
|
+
</tr>
|
|
184
|
+
ROW
|
|
185
|
+
end.join("\n")
|
|
186
|
+
|
|
187
|
+
checked = truthy?(checked_at) ? ", checked #{h(Duration.relative(checked_at, now))}" : ""
|
|
188
|
+
table =
|
|
189
|
+
if jobs.empty?
|
|
190
|
+
%(<div class="empty">No jobs yet. Declare one with <code class="mono">CW.job("name", schedule: "0 2 * * *")</code> and run it once.</div>)
|
|
191
|
+
else
|
|
192
|
+
<<~TABLE.chomp
|
|
193
|
+
<table>
|
|
194
|
+
<thead><tr><th>Job</th><th>Health</th><th>Schedule</th><th>Last run</th><th class="hide-sm">Next due</th><th class="hide-sm">Durations</th></tr></thead>
|
|
195
|
+
<tbody>#{rows}</tbody></table>
|
|
196
|
+
TABLE
|
|
197
|
+
end
|
|
198
|
+
body = <<~BODY.chomp
|
|
199
|
+
|
|
200
|
+
<header>
|
|
201
|
+
<h1><a href="#{h(base)}/">CronWatch</a></h1>
|
|
202
|
+
<div class="actions">
|
|
203
|
+
<span class="meta">#{jobs.length} job#{jobs.length == 1 ? "" : "s"}#{checked}</span>
|
|
204
|
+
<form class="inline" method="post" action="#{h(base)}/check"><button type="submit">Run check now</button></form>
|
|
205
|
+
</div>
|
|
206
|
+
</header>
|
|
207
|
+
<div class="card">
|
|
208
|
+
#{table}
|
|
209
|
+
</div>
|
|
210
|
+
<footer>Refreshes every minute. <a href="#{h(base)}/api/jobs">JSON</a></footer>
|
|
211
|
+
BODY
|
|
212
|
+
layout("CronWatch", body, refresh: 60)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def job_page(job, runs, now, base)
|
|
216
|
+
d = job.definition
|
|
217
|
+
ok_rate = "#{JS.number(JS.round(job.stats.ok_rate * 100))}%"
|
|
218
|
+
run_rows = runs.map do |run|
|
|
219
|
+
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>) : "",
|
|
222
|
+
].join
|
|
223
|
+
metrics = entries(run.metrics).map do |k, v|
|
|
224
|
+
%(<span class="pill mutedpill">#{h(k)} #{h(JS.integer?(v) ? v : to_fixed(v, 4))}</span>)
|
|
225
|
+
end.join(" ")
|
|
226
|
+
took = run.duration_ms.nil? ? %(<span class="muted">running</span>) : h(Duration.format(run.duration_ms))
|
|
227
|
+
detail_row = detail.empty? ? "" : %(<tr><td colspan="5" style="padding-top:0">#{detail}</td></tr>)
|
|
228
|
+
<<~ROW.chomp
|
|
229
|
+
<tr>
|
|
230
|
+
<td class="nowrap">#{run_pill(run)}</td>
|
|
231
|
+
<td class="nowrap">#{stamp(run.started_at, now)}</td>
|
|
232
|
+
<td class="nowrap">#{took}</td>
|
|
233
|
+
<td class="hide-sm">#{metrics}</td>
|
|
234
|
+
<td class="mono hide-sm muted">#{h(run.trigger)}</td>
|
|
235
|
+
</tr>#{detail_row}
|
|
236
|
+
ROW
|
|
237
|
+
end.join("\n")
|
|
238
|
+
|
|
239
|
+
name = encode_uri_component(job.name)
|
|
240
|
+
silenced = !job.silenced_until.nil? && job.silenced_until > now
|
|
241
|
+
silence_form =
|
|
242
|
+
if silenced
|
|
243
|
+
%(<form class="inline" method="post" action="#{h(base)}/jobs/#{name}/unsilence"><button type="submit">Unsilence (until #{h(Duration.relative(job.silenced_until, now))})</button></form>)
|
|
244
|
+
else
|
|
245
|
+
%(<form class="inline" method="post" action="#{h(base)}/jobs/#{name}/silence"><select name="for"><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="1d">1 day</option><option value="7d">1 week</option></select> <button type="submit">Silence</button></form>)
|
|
246
|
+
end
|
|
247
|
+
stats = job.stats
|
|
248
|
+
p50 = stats.p50_ms.nil? ? "?" : h(Duration.format(stats.p50_ms))
|
|
249
|
+
p95 = stats.p95_ms.nil? ? "?" : h(Duration.format(stats.p95_ms))
|
|
250
|
+
schedule =
|
|
251
|
+
if truthy?(d.schedule)
|
|
252
|
+
h(d.schedule) + (truthy?(d.timezone) ? %( <span class="muted">#{h(d.timezone)}</span>) : "")
|
|
253
|
+
else
|
|
254
|
+
%(<span class="muted">none</span>)
|
|
255
|
+
end
|
|
256
|
+
budget = truthy?(d.budget) ? entries(d.budget).map { |k, v| "#{k} ≤ #{text(v)}" }.join(", ") : nil
|
|
257
|
+
tags = d.tags
|
|
258
|
+
failures = d.failures_before_alert
|
|
259
|
+
runs_table =
|
|
260
|
+
if runs.empty?
|
|
261
|
+
%(<div class="empty">No runs yet.</div>)
|
|
262
|
+
else
|
|
263
|
+
<<~TABLE.chomp
|
|
264
|
+
<table>
|
|
265
|
+
<thead><tr><th>Status</th><th>Started</th><th>Duration</th><th class="hide-sm">Metrics</th><th class="hide-sm">Trigger</th></tr></thead>
|
|
266
|
+
<tbody>#{run_rows}</tbody></table>
|
|
267
|
+
TABLE
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
body = <<~BODY.chomp
|
|
271
|
+
|
|
272
|
+
<header>
|
|
273
|
+
<h1><a href="#{h(base)}/">CronWatch</a> <span class="muted">/</span> #{h(job.name)}</h1>
|
|
274
|
+
<div class="actions">
|
|
275
|
+
#{health_pill(job)}
|
|
276
|
+
#{silence_form}
|
|
277
|
+
<details class="confirm"><summary>Forget</summary><form class="inline" method="post" action="#{h(base)}/jobs/#{name}/forget"><span class="muted">Remove this job and its runs from the store?</span> <button type="submit">Forget</button></form></details>
|
|
278
|
+
</div>
|
|
279
|
+
</header>
|
|
280
|
+
<div class="grid">
|
|
281
|
+
<div class="card stat"><div class="k">Last run</div><div class="v">#{job.last_run ? h(Duration.relative(job.last_run.started_at, now)) : "never"}</div></div>
|
|
282
|
+
<div class="card stat"><div class="k">Next due</div><div class="v">#{truthy?(job.next_expected_at) ? h(Duration.relative(job.next_expected_at, now)) : "no schedule"}</div></div>
|
|
283
|
+
<div class="card stat"><div class="k">Success, last #{h(stats.runs)}</div><div class="v">#{h(ok_rate)}</div></div>
|
|
284
|
+
<div class="card stat"><div class="k">p50 / p95</div><div class="v">#{p50} <span class="muted">/</span> #{p95}</div></div>
|
|
285
|
+
</div>
|
|
286
|
+
<div class="card" style="margin-bottom:20px">
|
|
287
|
+
<dl>
|
|
288
|
+
<dt>Schedule</dt><dd class="mono">#{schedule}</dd>
|
|
289
|
+
<dt>Grace</dt><dd class="mono">#{h(d.grace.nil? ? "10m" : d.grace)}</dd>
|
|
290
|
+
<dt>Timeout</dt><dd class="mono">#{h(d.timeout.nil? ? "1h" : d.timeout)}</dd>
|
|
291
|
+
#{truthy?(d.max_duration) ? %(<dt>Max duration</dt><dd class="mono">#{h(d.max_duration)}</dd>) : ""}
|
|
292
|
+
#{budget ? %(<dt>Budget</dt><dd class="mono">#{h(budget)}</dd>) : ""}
|
|
293
|
+
#{truthy?(d.expect) ? %(<dt>Expect</dt><dd class="mono">#{h(d.expect)}</dd>) : ""}
|
|
294
|
+
#{truthy?(failures) && failures > 1 ? %(<dt>Alert after</dt><dd>#{h(failures)} consecutive failures</dd>) : ""}
|
|
295
|
+
#{truthy?(d.description) ? %(<dt>Description</dt><dd>#{h(d.description)}</dd>) : ""}
|
|
296
|
+
#{tags.is_a?(Array) && tags.any? ? %(<dt>Tags</dt><dd>#{tags.map { |t| %(<span class="pill mutedpill">#{h(t)}</span>) }.join(" ")}</dd>) : ""}
|
|
297
|
+
#{job.open.any? ? %(<dt>Open</dt><dd>#{job.open.map { |c| %(<span class="pill warn">#{h(c.to_s.sub("_", " "))}</span>) }.join(" ")}</dd>) : ""}
|
|
298
|
+
#{job.consecutive_failures.positive? ? %(<dt>Consecutive failures</dt><dd>#{h(job.consecutive_failures)}</dd>) : ""}
|
|
299
|
+
</dl>
|
|
300
|
+
</div>
|
|
301
|
+
<div class="card">
|
|
302
|
+
#{runs_table}
|
|
303
|
+
</div>
|
|
304
|
+
<footer><a href="#{h(base)}/api/jobs/#{name}">JSON</a></footer>
|
|
305
|
+
BODY
|
|
306
|
+
layout("#{job.name}: CronWatch", body, refresh: 60)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def message_page(title, message, base)
|
|
310
|
+
layout(title, %(<header><h1><a href="#{h(base)}/">CronWatch</a></h1></header><div class="card"><div class="empty"><strong>#{h(title)}</strong><br>#{h(message)}</div></div>))
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The dashboard and JSON API, as a Rack app. Needs the rack gem; the Rails
|
|
4
|
+
# integration loads this file itself.
|
|
5
|
+
#
|
|
6
|
+
# require "cronwatch/web"
|
|
7
|
+
# run Cronwatch::Web.new(CW)
|
|
8
|
+
begin
|
|
9
|
+
require "rack"
|
|
10
|
+
rescue LoadError => e
|
|
11
|
+
raise LoadError, "cronwatch/web needs the rack gem: add `gem \"rack\"` to your Gemfile " \
|
|
12
|
+
"(Rails apps already have it) (#{e.message})"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require "cronwatch" unless defined?(Cronwatch::Client)
|
|
16
|
+
require_relative "web/html"
|
|
17
|
+
require_relative "web/app"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fugit"
|
|
4
|
+
|
|
5
|
+
module Cronwatch
|
|
6
|
+
# IANA zones through TZInfo (which Fugit brings, by way of et-orbi), or the
|
|
7
|
+
# process's own zone when none is named, as JavaScript's Date does.
|
|
8
|
+
module Zone
|
|
9
|
+
@zones = {}
|
|
10
|
+
@names = nil
|
|
11
|
+
@lock = Mutex.new
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# A TZInfo zone. Names are matched without regard to case, as Intl does.
|
|
16
|
+
def get(name)
|
|
17
|
+
@lock.synchronize do
|
|
18
|
+
@zones[name] ||= begin
|
|
19
|
+
TZInfo::Timezone.get(name)
|
|
20
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
21
|
+
@names ||= TZInfo::Timezone.all_identifiers.to_h { |id| [id.downcase, id] }
|
|
22
|
+
canonical = @names[name.to_s.downcase]
|
|
23
|
+
raise ArgumentError, "timezone \"#{name}\" is not an IANA timezone" unless canonical
|
|
24
|
+
|
|
25
|
+
TZInfo::Timezone.get(canonical)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def valid?(name)
|
|
31
|
+
get(name)
|
|
32
|
+
true
|
|
33
|
+
rescue ArgumentError, TZInfo::InvalidTimezoneIdentifier, TZInfo::InvalidDataSource
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Seconds the wall clock is ahead of UTC at epoch second `sec`.
|
|
38
|
+
def offset(sec, timezone)
|
|
39
|
+
return Time.at(sec).utc_offset if timezone.nil?
|
|
40
|
+
|
|
41
|
+
get(timezone).period_for_utc(Time.at(sec).utc).utc_total_offset
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The wall clock at epoch second `sec`: [year, month, day, hour, minute, second].
|
|
45
|
+
def wall(sec, timezone)
|
|
46
|
+
t = Time.at(sec + offset(sec, timezone)).utc
|
|
47
|
+
[t.year, t.month, t.day, t.hour, t.min, t.sec]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def civil_seconds(wall)
|
|
51
|
+
Time.utc(*wall).to_i
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Croner's fromTZ: the instant a wall-clock time names. A time that falls in
|
|
55
|
+
# a spring-forward gap is moved forward by the gap; a time that occurs
|
|
56
|
+
# twice (fall back) is the earlier of the two. Returns epoch seconds.
|
|
57
|
+
def to_utc(wall, timezone)
|
|
58
|
+
target = civil_seconds(wall)
|
|
59
|
+
guess = target + (target - civil_seconds(wall(target, timezone)))
|
|
60
|
+
seen = wall(guess, timezone)
|
|
61
|
+
if seen == wall
|
|
62
|
+
earlier = guess - 3600
|
|
63
|
+
return wall(earlier, timezone) == wall ? earlier : guess
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
shifted = guess + target - civil_seconds(seen)
|
|
67
|
+
return shifted if wall(shifted, timezone) == wall
|
|
68
|
+
|
|
69
|
+
[guess, shifted].max
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/cronwatch.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "cronwatch/version"
|
|
6
|
+
require_relative "cronwatch/js"
|
|
7
|
+
require_relative "cronwatch/types"
|
|
8
|
+
require_relative "cronwatch/duration"
|
|
9
|
+
require_relative "cronwatch/stats"
|
|
10
|
+
require_relative "cronwatch/output"
|
|
11
|
+
require_relative "cronwatch/schedule"
|
|
12
|
+
require_relative "cronwatch/evaluate"
|
|
13
|
+
require_relative "cronwatch/format"
|
|
14
|
+
require_relative "cronwatch/serialize"
|
|
15
|
+
require_relative "cronwatch/job"
|
|
16
|
+
require_relative "cronwatch/http"
|
|
17
|
+
require_relative "cronwatch/stores/memory"
|
|
18
|
+
require_relative "cronwatch/alerts/console"
|
|
19
|
+
require_relative "cronwatch/alerts/custom"
|
|
20
|
+
require_relative "cronwatch/alerts/slack"
|
|
21
|
+
require_relative "cronwatch/alerts/discord"
|
|
22
|
+
require_relative "cronwatch/alerts/webhook"
|
|
23
|
+
require_relative "cronwatch/client"
|
|
24
|
+
|
|
25
|
+
# Cron and scheduled-job monitoring that lives inside your app.
|
|
26
|
+
#
|
|
27
|
+
# CW = Cronwatch.new(alerts: [Cronwatch::Alerts::Slack.new(webhook_url: ENV.fetch("SLACK_WEBHOOK_URL"))])
|
|
28
|
+
# NIGHTLY = CW.job("nightly-report", schedule: "0 2 * * *", grace: "15m")
|
|
29
|
+
# NIGHTLY.run { |job| job.log("Report written") }
|
|
30
|
+
#
|
|
31
|
+
# Or configure one client for the whole app and reach it as Cronwatch.client.
|
|
32
|
+
module Cronwatch
|
|
33
|
+
# The options Cronwatch.configure sets. Anything left unset takes the client's default.
|
|
34
|
+
class Configuration
|
|
35
|
+
OPTIONS = %i[store alerts triage cron_secret retention defaults redact deliver now on_error].freeze
|
|
36
|
+
|
|
37
|
+
attr_accessor(*OPTIONS)
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@cron_secret = Client::UNSET
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_options
|
|
44
|
+
OPTIONS.each_with_object({}) do |option, out|
|
|
45
|
+
value = public_send(option)
|
|
46
|
+
next if value.nil? && option != :cron_secret
|
|
47
|
+
|
|
48
|
+
out[option] = value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@lock = Mutex.new
|
|
54
|
+
@client = nil
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
# A new client. See Client#initialize for the options.
|
|
58
|
+
def new(**options)
|
|
59
|
+
Client.new(**options)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Builds the app's client:
|
|
63
|
+
#
|
|
64
|
+
# Cronwatch.configure do |c|
|
|
65
|
+
# c.store = Cronwatch::Stores::ActiveRecord.new
|
|
66
|
+
# c.alerts = [Cronwatch::Alerts::Slack.new(webhook_url: ENV["SLACK_WEBHOOK_URL"])]
|
|
67
|
+
# end
|
|
68
|
+
#
|
|
69
|
+
# Configuring again replaces the client and stops the old one's interval.
|
|
70
|
+
def configure
|
|
71
|
+
config = Configuration.new
|
|
72
|
+
yield config if block_given?
|
|
73
|
+
client = Client.new(**config.to_options)
|
|
74
|
+
previous = @lock.synchronize do
|
|
75
|
+
old = @client
|
|
76
|
+
@client = client
|
|
77
|
+
old
|
|
78
|
+
end
|
|
79
|
+
previous&.stop
|
|
80
|
+
client
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The configured client, or one with the defaults if configure was never called.
|
|
84
|
+
def client
|
|
85
|
+
@lock.synchronize { @client ||= Client.new }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def client=(client)
|
|
89
|
+
@lock.synchronize { @client = client }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# In a Rails app Bundler requires gems after Rails itself, so `gem "cronwatch"`
|
|
95
|
+
# alone brings in the Railtie, the ActiveJob concern and the generator.
|
|
96
|
+
require_relative "cronwatch/rails" if defined?(::Rails::Railtie)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require "cronwatch/active_record"
|
|
6
|
+
|
|
7
|
+
module Cronwatch
|
|
8
|
+
module Generators
|
|
9
|
+
# bin/rails generate cronwatch:install
|
|
10
|
+
#
|
|
11
|
+
# Writes a migration for the three tables the ActiveRecord store uses
|
|
12
|
+
# (created with the SDK's own DDL, so a Node process can share them) and
|
|
13
|
+
# config/initializers/cronwatch.rb, then prints how to schedule
|
|
14
|
+
# Cronwatch::CheckJob and mount the dashboard. The templates live in this
|
|
15
|
+
# file so the gem ships nothing but Ruby.
|
|
16
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
17
|
+
include ::ActiveRecord::Generators::Migration
|
|
18
|
+
|
|
19
|
+
desc "Creates the CronWatch migration and initializer."
|
|
20
|
+
|
|
21
|
+
class_option :prefix, type: :string, default: Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX,
|
|
22
|
+
desc: "Table name prefix: lowercase letters, digits and underscores"
|
|
23
|
+
class_option :database, type: :string, aliases: %i[--db],
|
|
24
|
+
desc: "The database for the migration, in an app with several"
|
|
25
|
+
|
|
26
|
+
def check_prefix
|
|
27
|
+
Cronwatch::Stores::ActiveRecord.table_prefix(options[:prefix])
|
|
28
|
+
rescue ArgumentError => e
|
|
29
|
+
raise ::Thor::Error, e.message
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_migration_file
|
|
33
|
+
dir = db_migrate_path
|
|
34
|
+
absolute = File.join(destination_root, dir)
|
|
35
|
+
if (existing = self.class.migration_exists?(absolute, migration_name))
|
|
36
|
+
say_status :exist, existing.delete_prefix("#{destination_root}/"), :blue
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
number = self.class.next_migration_number(absolute)
|
|
41
|
+
create_file File.join(dir, "#{number}_#{migration_name}.rb"), migration
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create_initializer
|
|
45
|
+
create_file "config/initializers/cronwatch.rb", initializer
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def show_next_steps
|
|
49
|
+
say next_steps
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# create_cronwatch_tables, or with a prefix create_cronwatch_ops_tables,
|
|
55
|
+
# so each prefix gets a migration of its own.
|
|
56
|
+
def migration_name
|
|
57
|
+
prefix = options[:prefix]
|
|
58
|
+
return "create_cronwatch_tables" if prefix == Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX
|
|
59
|
+
|
|
60
|
+
"create_cronwatch_#{prefix.squeeze("_").delete_prefix("_").delete_suffix("_")}_tables"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def store_args
|
|
64
|
+
prefix = options[:prefix]
|
|
65
|
+
prefix == Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX ? "" : "(prefix: #{prefix.inspect})"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def migration
|
|
69
|
+
<<~RUBY
|
|
70
|
+
# frozen_string_literal: true
|
|
71
|
+
|
|
72
|
+
require "cronwatch/active_record"
|
|
73
|
+
|
|
74
|
+
# The tables CronWatch keeps jobs, runs and alert state in. They are
|
|
75
|
+
# created with the SDK's own statements, so a Node process using
|
|
76
|
+
# @cronwatch/sdk can share them.
|
|
77
|
+
class #{migration_name.camelize} < ActiveRecord::Migration[#{::ActiveRecord::Migration.current_version}]
|
|
78
|
+
def up
|
|
79
|
+
Cronwatch::Stores::ActiveRecord.create_tables!(connection, prefix: #{options[:prefix].inspect})
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def down
|
|
83
|
+
Cronwatch::Stores::ActiveRecord.drop_tables!(connection, prefix: #{options[:prefix].inspect})
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
RUBY
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initializer
|
|
90
|
+
<<~RUBY
|
|
91
|
+
# frozen_string_literal: true
|
|
92
|
+
|
|
93
|
+
# CronWatch: told when a scheduled job is missed, failed, stuck, slow or
|
|
94
|
+
# over budget. https://cronwatch.dev/docs/
|
|
95
|
+
#
|
|
96
|
+
# Monitor a job by including Cronwatch::ActiveJob and declaring its schedule:
|
|
97
|
+
#
|
|
98
|
+
# class NightlyReportJob < ApplicationJob
|
|
99
|
+
# include Cronwatch::ActiveJob
|
|
100
|
+
# cronwatch schedule: "0 2 * * *", grace: "15m"
|
|
101
|
+
# end
|
|
102
|
+
#
|
|
103
|
+
# and run Cronwatch::CheckJob every few minutes to catch the runs that never happen.
|
|
104
|
+
Cronwatch.configure do |c|
|
|
105
|
+
# Jobs, runs and alert state, in this app's database.
|
|
106
|
+
c.store = Cronwatch::Stores::ActiveRecord.new#{store_args}
|
|
107
|
+
|
|
108
|
+
# Where alerts go. With none set, they are written to standard error.
|
|
109
|
+
c.alerts = [
|
|
110
|
+
(Cronwatch::Alerts::Slack.new(webhook_url: ENV["SLACK_WEBHOOK_URL"]) if ENV["SLACK_WEBHOOK_URL"].present?),
|
|
111
|
+
# Cronwatch::Alerts::Discord.new(webhook_url: ENV["DISCORD_WEBHOOK_URL"]),
|
|
112
|
+
# Cronwatch::Alerts::Webhook.new(url: ENV["CRONWATCH_WEBHOOK_URL"], secret: ENV["CRONWATCH_WEBHOOK_SECRET"]),
|
|
113
|
+
].compact.presence
|
|
114
|
+
|
|
115
|
+
# How long finished runs are kept. Each job's newest run is always kept.
|
|
116
|
+
# c.retention = "30d"
|
|
117
|
+
|
|
118
|
+
# Applied to every job that does not set its own.
|
|
119
|
+
# c.defaults = { grace: "10m", timezone: "Europe/London", failures_before_alert: 1 }
|
|
120
|
+
|
|
121
|
+
# Called with (error, where) when the store, a channel or triage fails. Default: Rails.logger.
|
|
122
|
+
# c.on_error = ->(error, where) { Rails.error.report(error, handled: true, context: { cronwatch: where }) }
|
|
123
|
+
|
|
124
|
+
# The bearer secret the dashboard's check endpoint takes. Default: ENV["CRON_SECRET"].
|
|
125
|
+
# c.cron_secret = Rails.application.credentials.cron_secret
|
|
126
|
+
end
|
|
127
|
+
RUBY
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def next_steps
|
|
131
|
+
<<~TEXT
|
|
132
|
+
|
|
133
|
+
CronWatch is installed. Next:
|
|
134
|
+
|
|
135
|
+
1. Create the tables:
|
|
136
|
+
|
|
137
|
+
bin/rails db:migrate
|
|
138
|
+
|
|
139
|
+
2. Monitor a job:
|
|
140
|
+
|
|
141
|
+
class NightlyReportJob < ApplicationJob
|
|
142
|
+
include Cronwatch::ActiveJob
|
|
143
|
+
cronwatch schedule: "0 2 * * *", grace: "15m" # name: "nightly-report"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
3. Run Cronwatch::CheckJob every 5 minutes, from one scheduler only. It notices
|
|
147
|
+
the runs that never happen; two checkers would send each alert twice.
|
|
148
|
+
|
|
149
|
+
Solid Queue, in config/recurring.yml:
|
|
150
|
+
|
|
151
|
+
production:
|
|
152
|
+
cronwatch_check:
|
|
153
|
+
class: Cronwatch::CheckJob
|
|
154
|
+
schedule: every 5 minutes
|
|
155
|
+
|
|
156
|
+
sidekiq-cron, in config/schedule.yml:
|
|
157
|
+
|
|
158
|
+
cronwatch_check:
|
|
159
|
+
cron: "*/5 * * * *"
|
|
160
|
+
class: "Cronwatch::CheckJob"
|
|
161
|
+
|
|
162
|
+
Or from a crontab: bin/rails cronwatch:check
|
|
163
|
+
|
|
164
|
+
4. Mount the dashboard in config/routes.rb:
|
|
165
|
+
|
|
166
|
+
mount Cronwatch::Web.new(Cronwatch.client) => "/cronwatch"
|
|
167
|
+
|
|
168
|
+
Outside development it needs CRONWATCH_TOKEN set to sign in. In
|
|
169
|
+
development, without one, the server prints a sign-in link on
|
|
170
|
+
the dashboard's first request.
|
|
171
|
+
|
|
172
|
+
TEXT
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|