cronwatch 0.10.0 → 0.11.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/README.md +18 -9
- data/lib/cronwatch/alerts/datadog.rb +1 -0
- data/lib/cronwatch/alerts/discord.rb +22 -3
- data/lib/cronwatch/alerts/email.rb +2 -0
- data/lib/cronwatch/alerts/honeybadger.rb +1 -0
- data/lib/cronwatch/alerts/postmark.rb +1 -0
- data/lib/cronwatch/alerts/provider.rb +2 -0
- data/lib/cronwatch/alerts/resend.rb +1 -0
- data/lib/cronwatch/alerts/rollbar.rb +1 -0
- data/lib/cronwatch/alerts/sentry.rb +1 -0
- data/lib/cronwatch/alerts/sigv4.rb +2 -0
- data/lib/cronwatch/alerts/slack.rb +1 -0
- data/lib/cronwatch/alerts/twilio.rb +1 -0
- data/lib/cronwatch/alerts/webhook.rb +18 -5
- data/lib/cronwatch/client.rb +66 -17
- data/lib/cronwatch/cron_pattern.rb +17 -8
- data/lib/cronwatch/deprecation.rb +18 -0
- data/lib/cronwatch/duration.rb +2 -0
- data/lib/cronwatch/environment.rb +93 -17
- data/lib/cronwatch/evaluate.rb +41 -10
- data/lib/cronwatch/flight.rb +2 -0
- data/lib/cronwatch/format.rb +1 -0
- data/lib/cronwatch/http.rb +21 -5
- data/lib/cronwatch/job.rb +2 -0
- data/lib/cronwatch/js.rb +74 -1
- data/lib/cronwatch/monitored.rb +6 -1
- data/lib/cronwatch/output.rb +3 -0
- data/lib/cronwatch/pg_cron.rb +22 -3
- data/lib/cronwatch/rails/active_job.rb +2 -2
- data/lib/cronwatch/schedule.rb +25 -1
- data/lib/cronwatch/scheduler.rb +6 -1
- data/lib/cronwatch/serialize.rb +1 -0
- data/lib/cronwatch/stats.rb +1 -0
- data/lib/cronwatch/stores/active_record.rb +36 -18
- data/lib/cronwatch/ticker.rb +2 -0
- data/lib/cronwatch/triage/anthropic.rb +2 -0
- data/lib/cronwatch/types.rb +148 -31
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/walker.rb +2 -0
- data/lib/cronwatch/web/app.rb +108 -33
- data/lib/cronwatch/web/html.rb +10 -6
- data/lib/cronwatch/web/origin.rb +2 -0
- data/lib/cronwatch/web/pwa.rb +20 -2
- data/lib/cronwatch/web/timeline.rb +2 -0
- data/lib/cronwatch/web.rb +1 -2
- data/lib/cronwatch/zone.rb +2 -0
- data/lib/cronwatch.rb +2 -0
- data/lib/generators/cronwatch/install/install_generator.rb +1 -1
- metadata +2 -1
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -10,15 +10,23 @@ module Cronwatch
|
|
|
10
10
|
# (routes/index.ts) with the same URLs, JSON, auth, CSRF rules and headers,
|
|
11
11
|
# so @cronwatch/mcp works against a Ruby app as it does against a Node one.
|
|
12
12
|
#
|
|
13
|
+
# Made by Client#routes, the one way to mount it:
|
|
14
|
+
#
|
|
13
15
|
# # config/routes.rb
|
|
14
|
-
# mount Cronwatch
|
|
16
|
+
# mount Cronwatch.client.routes => "/cronwatch"
|
|
15
17
|
#
|
|
16
18
|
# # config.ru, standalone
|
|
17
|
-
# run
|
|
19
|
+
# run CW.routes
|
|
20
|
+
#
|
|
21
|
+
# Cronwatch::Web.new(client, **options) makes the same app and is
|
|
22
|
+
# deprecated: it still works through 1.x and goes in 2.0.
|
|
18
23
|
#
|
|
19
24
|
# token: required to reach anything. Send it as `Authorization: Bearer <token>`,
|
|
20
|
-
# or open the dashboard once with `?token=<token>`
|
|
21
|
-
#
|
|
25
|
+
# or open the dashboard once with `?token=<token>` (or sign in with the
|
|
26
|
+
# form, which posts it to <base>/signin) and a cookie is set.
|
|
27
|
+
# Defaults to ENV["CRONWATCH_TOKEN"]; an empty string or one of only
|
|
28
|
+
# whitespace, given here or in the variable, counts as unset, and
|
|
29
|
+
# anything but a String or nil (false, a number, a Symbol) raises TypeError.
|
|
22
30
|
# With no token while Cronwatch::Environment is development or
|
|
23
31
|
# test (Environment.stated_development?: under Puma, Unicorn,
|
|
24
32
|
# Thin or rackup, which set RACK_ENV=development by default,
|
|
@@ -66,16 +74,42 @@ module Cronwatch
|
|
|
66
74
|
SECURITY_HEADERS = {
|
|
67
75
|
"x-content-type-options" => "nosniff", "referrer-policy" => "same-origin", "x-robots-tag" => "noindex",
|
|
68
76
|
}.freeze
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
# A bearer is what follows the scheme when the scheme is Bearer (any case)
|
|
78
|
+
# and whitespace follows it. Any other Authorization header (a proxy's
|
|
79
|
+
# Basic auth) is no bearer at all.
|
|
80
|
+
BEARER = Regexp.new("\\ABearer[#{JS::WHITESPACE}]+(.*)\\z", Regexp::IGNORECASE | Regexp::MULTILINE)
|
|
81
|
+
# What GET <base>/api names, so a client such as @cronwatch/mcp can tell
|
|
82
|
+
# what it is talking to. API_VERSION goes up only with a change that is
|
|
83
|
+
# not additive, in a major release.
|
|
84
|
+
LIBRARY = "cronwatch"
|
|
85
|
+
LANGUAGE = "ruby"
|
|
86
|
+
API_VERSION = 1
|
|
87
|
+
private_constant :API_VERSION, :ASSET_CSP, :BEARER, :BOARD_PAGE_RUNS, :COOKIE, :COOKIE_MAX_AGE, :CSP,
|
|
88
|
+
:DEFAULT_RUNS, :LANGUAGE, :LIBRARY, :MAX_RUNS, :SECURITY_HEADERS
|
|
89
|
+
|
|
90
|
+
# Deprecated: use client.routes(**options). Still works through 1.x; a
|
|
91
|
+
# Web made without a client serves Cronwatch.client, read on each request.
|
|
92
|
+
def self.new(client = nil, **options)
|
|
93
|
+
Deprecation.warn("Cronwatch::Web.new(client, ...)", "client.routes(...)")
|
|
94
|
+
build(client, **options)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The app Client#routes returns, without the warning.
|
|
98
|
+
#
|
|
99
|
+
# @api private
|
|
100
|
+
def self.build(client = nil, **options)
|
|
101
|
+
app = allocate
|
|
102
|
+
app.send(:initialize, client, **options)
|
|
103
|
+
app
|
|
104
|
+
end
|
|
72
105
|
|
|
73
106
|
def initialize(client = nil, token: UNSET, base_path: nil, origin: nil)
|
|
74
107
|
@client = client
|
|
75
108
|
@origin = Web.configured_origin(origin)
|
|
76
109
|
@opted_out = token.nil?
|
|
77
|
-
|
|
78
|
-
|
|
110
|
+
# A blank token, given or read, counts as unset; one given that is not a String raises.
|
|
111
|
+
given = token.equal?(UNSET) ? nil : Environment.secret_option(token, "routes: token")
|
|
112
|
+
@token = @opted_out ? nil : given || Environment.secret("CRONWATCH_TOKEN")
|
|
79
113
|
@base_path = base_path&.to_s&.sub(%r{/+\z}, "")
|
|
80
114
|
# A Rack app cannot reliably tell a local caller from a remote one
|
|
81
115
|
# (proxies, tunnels and a server bound to every interface all look
|
|
@@ -183,7 +217,7 @@ module Cronwatch
|
|
|
183
217
|
|
|
184
218
|
# No token outside development: fail closed.
|
|
185
219
|
if !@token && !@opted_out
|
|
186
|
-
return wants_html ? html(HTML.message_page("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token: to
|
|
220
|
+
return wants_html ? html(HTML.message_page("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token: to routes), or pass token: nil to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503)
|
|
187
221
|
end
|
|
188
222
|
|
|
189
223
|
if method != "GET" && method != "HEAD" && cross_site?(request)
|
|
@@ -191,7 +225,18 @@ module Cronwatch
|
|
|
191
225
|
end
|
|
192
226
|
|
|
193
227
|
authorization = request.header("authorization")
|
|
194
|
-
bearer = authorization&.
|
|
228
|
+
bearer = authorization && BEARER.match(authorization)&.[](1)
|
|
229
|
+
|
|
230
|
+
# The sign-in form posts the token here, in the body, so it stays out of
|
|
231
|
+
# the URL and every access log. Cross-site posts were refused above.
|
|
232
|
+
if @token && method == "POST" && path == "/signin"
|
|
233
|
+
sent = read_body(request)["token"]
|
|
234
|
+
return sign_in_page(base) if sent.nil? || !HTTP.constant_time_equal?(sent, @token)
|
|
235
|
+
|
|
236
|
+
return redirect(sign_in_return(request.header("referer"), public_origin(request), base),
|
|
237
|
+
"set-cookie" => sign_in_cookie(request, base))
|
|
238
|
+
end
|
|
239
|
+
|
|
195
240
|
if @token
|
|
196
241
|
# ?token= is only the sign-in that moves the token into a cookie.
|
|
197
242
|
query = wants_html && method == "GET" ? request.query("token") : nil
|
|
@@ -204,16 +249,18 @@ module Cronwatch
|
|
|
204
249
|
else !cookie.nil? && HTTP.constant_time_equal?(cookie, cookie_value(@token))
|
|
205
250
|
end
|
|
206
251
|
unless cron_secret_ok || token_ok
|
|
252
|
+
return sign_in_page(base) if wants_html
|
|
207
253
|
if @generated
|
|
208
|
-
return
|
|
254
|
+
return api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401)
|
|
209
255
|
end
|
|
210
|
-
|
|
256
|
+
|
|
257
|
+
return api({ ok: false, error: "Unauthorized" }, 401)
|
|
211
258
|
end
|
|
212
259
|
unless query.nil?
|
|
213
|
-
# Move the token from the URL into a cookie so it is not in
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
260
|
+
# Move the token from the URL into a cookie, so it is not left in the
|
|
261
|
+
# browser's history. The request line that carried it may still be in
|
|
262
|
+
# an access log, which is why the sign-in form posts instead.
|
|
263
|
+
return redirect(request.pathname + request.search_without("token"), "set-cookie" => sign_in_cookie(request, base))
|
|
217
264
|
end
|
|
218
265
|
end
|
|
219
266
|
|
|
@@ -259,7 +306,7 @@ module Cronwatch
|
|
|
259
306
|
rescue ArgumentError => e
|
|
260
307
|
return html(HTML.message_page("Not silenced", e.message, base), 400)
|
|
261
308
|
end
|
|
262
|
-
cw.silence(name, duration)
|
|
309
|
+
cw.silence(name, for: duration)
|
|
263
310
|
else
|
|
264
311
|
cw.unsilence(name)
|
|
265
312
|
end
|
|
@@ -273,6 +320,9 @@ module Cronwatch
|
|
|
273
320
|
|
|
274
321
|
def serve_api(request, method, rest, bearer)
|
|
275
322
|
cw = client
|
|
323
|
+
if method == "GET" && rest.empty?
|
|
324
|
+
return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION })
|
|
325
|
+
end
|
|
276
326
|
return api({ ok: true, jobs: cw.jobs }) if method == "GET" && rest[0] == "jobs" && rest.length == 1
|
|
277
327
|
|
|
278
328
|
if rest[0] == "jobs" && rest.length == 2
|
|
@@ -301,9 +351,13 @@ module Cronwatch
|
|
|
301
351
|
rescue ArgumentError => e
|
|
302
352
|
return api({ ok: false, error: e.message }, 400)
|
|
303
353
|
end
|
|
304
|
-
|
|
354
|
+
cw.silence(name, for: duration)
|
|
355
|
+
return api({ ok: true, job: cw.job_summary(name) })
|
|
356
|
+
end
|
|
357
|
+
if rest[2] == "unsilence"
|
|
358
|
+
cw.unsilence(name)
|
|
359
|
+
return api({ ok: true, job: cw.job_summary(name) })
|
|
305
360
|
end
|
|
306
|
-
return api({ ok: true, state: cw.unsilence(name) }) if rest[2] == "unsilence"
|
|
307
361
|
end
|
|
308
362
|
if rest[0] == "check" && rest.length == 1
|
|
309
363
|
# A page cannot send an Authorization header cross-site, so a GET
|
|
@@ -350,6 +404,37 @@ module Cronwatch
|
|
|
350
404
|
$stdout.flush
|
|
351
405
|
end
|
|
352
406
|
|
|
407
|
+
# The 401 sign-in page, with the form that posts the token to <base>/signin.
|
|
408
|
+
def sign_in_page(base)
|
|
409
|
+
message =
|
|
410
|
+
if @generated
|
|
411
|
+
"CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server " \
|
|
412
|
+
"log: open it once, or enter the token from it below, and this browser stays signed in."
|
|
413
|
+
else
|
|
414
|
+
"Enter your CRONWATCH_TOKEN and this browser stays signed in."
|
|
415
|
+
end
|
|
416
|
+
html(HTML.message_page("Sign in", message, base, sign_in: true), 401)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The cookie a sign-in sets, by ?token= or by the form.
|
|
420
|
+
def sign_in_cookie(request, base)
|
|
421
|
+
secure = public_origin(request).start_with?("https:") ? "; Secure" : ""
|
|
422
|
+
"#{COOKIE}=#{cookie_value(@token)}; Path=#{base.empty? ? "/" : base}; HttpOnly; SameSite=Lax; Max-Age=#{COOKIE_MAX_AGE}#{secure}"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Where a sign-in through the form goes next: the page it was posted from
|
|
426
|
+
# (the Referer) when that is on the public origin and its query has no
|
|
427
|
+
# `token` parameter, else the dashboard. A Referer that starts with the
|
|
428
|
+
# origin and "/" always parses as a URL; its query is read as
|
|
429
|
+
# URLSearchParams reads it, after the tabs and newlines a URL parser drops.
|
|
430
|
+
def sign_in_return(referer, origin, base)
|
|
431
|
+
return "#{base}/" if referer.nil? || !referer.start_with?("#{origin}/")
|
|
432
|
+
|
|
433
|
+
url = referer.delete("\t\n\r").split("#", 2).first.to_s
|
|
434
|
+
query = url.include?("?") ? url.split("?", 2).last : ""
|
|
435
|
+
Request.parse_query(query).any? { |key, _| key == "token" } ? "#{base}/" : referer
|
|
436
|
+
end
|
|
437
|
+
|
|
353
438
|
# The cookie holds a digest of the token, so a leaked cookie does not reveal the bearer token itself.
|
|
354
439
|
def cookie_value(token)
|
|
355
440
|
Digest::SHA256.hexdigest("cronwatch-cookie:#{token}")
|
|
@@ -448,19 +533,7 @@ module Cronwatch
|
|
|
448
533
|
|
|
449
534
|
# Number(string): decimal, 0x/0o/0b, Infinity, or NaN.
|
|
450
535
|
def js_number(value)
|
|
451
|
-
|
|
452
|
-
return 0 if text.empty?
|
|
453
|
-
# Ruby's Float() wants a digit on both sides of the point; JavaScript does not.
|
|
454
|
-
return Float(text.sub(/\A([+-]?)\./, "\\10.").sub(/\.(?=[eE]|\z)/, ".0")) if DECIMAL.match?(text)
|
|
455
|
-
return text.start_with?("-") ? -Float::INFINITY : Float::INFINITY if /\A[+-]?Infinity\z/.match?(text)
|
|
456
|
-
|
|
457
|
-
if (m = /\A0([xXoObB])([0-9a-fA-F]+)\z/.match(text))
|
|
458
|
-
return Integer(m[2], RADIX.fetch(m[1].downcase))
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
Float::NAN
|
|
462
|
-
rescue ArgumentError
|
|
463
|
-
Float::NAN
|
|
536
|
+
JS.to_number(value)
|
|
464
537
|
end
|
|
465
538
|
|
|
466
539
|
def whole(number)
|
|
@@ -508,6 +581,8 @@ module Cronwatch
|
|
|
508
581
|
end
|
|
509
582
|
|
|
510
583
|
# What the routes need from a Rack env, read the way the SDK reads a fetch Request.
|
|
584
|
+
#
|
|
585
|
+
# @api private
|
|
511
586
|
class Request
|
|
512
587
|
attr_reader :env
|
|
513
588
|
|
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
|
|
@@ -21,10 +21,13 @@ module Cronwatch
|
|
|
21
21
|
# Motion is CSS only and says something: marks arrive in time order, the
|
|
22
22
|
# now line drops in last, and open problems (a missed slot, a running bar)
|
|
23
23
|
# breathe slowly. prefers-reduced-motion turns all of it off.
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
24
26
|
module HTML
|
|
25
27
|
CSS = "\n" + <<~'CSS'
|
|
26
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}
|
|
27
|
-
@media(prefers-color-scheme:dark){:root{--paper:#09090b;--sheet:#111113;--sunk:#18181b;--rule:#27272a;--rule-2:#3f3f46;--tick:#66666f;--ink:#fff;--body:#e4e4e7;--muted:#a1a1aa;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171}}
|
|
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}
|
|
28
31
|
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
|
29
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}
|
|
30
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}
|
|
@@ -249,7 +252,7 @@ module Cronwatch
|
|
|
249
252
|
|
|
250
253
|
# A page. `base` is where the dashboard is mounted ("" at the root): the
|
|
251
254
|
# head links the web app manifest, the icons and app.js, the one script,
|
|
252
|
-
# which
|
|
255
|
+
# which registers the service worker and toggles the theme (Web::PWA). Everything works
|
|
253
256
|
# without it.
|
|
254
257
|
def layout(title, body, base, refresh: nil)
|
|
255
258
|
b = h(base)
|
|
@@ -564,13 +567,14 @@ module Cronwatch
|
|
|
564
567
|
layout("#{job.name}: CronWatch", body, base, refresh: 60)
|
|
565
568
|
end
|
|
566
569
|
|
|
567
|
-
# A page with one message. With `sign_in`, a form under it
|
|
568
|
-
# token
|
|
570
|
+
# A page with one message. With `sign_in`, a form under it posts the
|
|
571
|
+
# token to <base>/signin in the body, keeping it out of the URL and
|
|
572
|
+
# access logs, and the app sets the cookie:
|
|
569
573
|
# the way in where there is no address bar to open a link with, such as
|
|
570
574
|
# an app on an iPhone's home screen, which keeps its cookies apart from
|
|
571
575
|
# Safari's.
|
|
572
576
|
def message_page(title, message, base, sign_in: false)
|
|
573
|
-
form = sign_in ? %(<form class="signin" method="
|
|
577
|
+
form = sign_in ? %(<form class="signin" method="post" action="#{h(base)}/signin"><label for="token">Token</label><input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required><button class="primary" type="submit">Sign in</button></form>) : ""
|
|
574
578
|
layout(title, %(<header class="top">#{brand(base)}</header><main class="message"><h1>#{h(title)}</h1><p>#{h(message)}</p>#{form}</main>), base)
|
|
575
579
|
end
|
|
576
580
|
end
|
data/lib/cronwatch/web/origin.rb
CHANGED
|
@@ -10,6 +10,8 @@ module Cronwatch
|
|
|
10
10
|
# ignored, the host is lowercased (percent escapes decoded, IPv4 numbers
|
|
11
11
|
# written out, IPv6 compressed, a non-ASCII host converted to punycode)
|
|
12
12
|
# and a default port is left out. A port outside 1 to 65535 is refused.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
13
15
|
module Origin
|
|
14
16
|
ABSOLUTE = "routes: origin must be an absolute URL such as \"https://app.example.com\", got %s"
|
|
15
17
|
FORBIDDEN_HOST = /[\x00-\x20#%\/:<>?@\[\\\]^|\x7F]/
|
data/lib/cronwatch/web/pwa.rb
CHANGED
|
@@ -7,6 +7,8 @@ module Cronwatch
|
|
|
7
7
|
# that registers it and a page to show offline. None of it says anything
|
|
8
8
|
# about the jobs, so it is served without the token (a browser fetches the
|
|
9
9
|
# manifest and icons without cookies in some flows).
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
10
12
|
module PWA
|
|
11
13
|
# The page colours the app's window takes: the paper behind the sheet,
|
|
12
14
|
# and the sheet the header sits on.
|
|
@@ -14,11 +16,27 @@ module Cronwatch
|
|
|
14
16
|
THEME_COLOR = "#ffffff"
|
|
15
17
|
THEME_COLOR_DARK = "#111113"
|
|
16
18
|
|
|
17
|
-
# Registers the service worker, and
|
|
18
|
-
#
|
|
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
|
|
19
22
|
# text wherever the dashboard is mounted.
|
|
20
23
|
APP_JS = <<~'JS'
|
|
21
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
|
+
})();
|
|
22
40
|
(function () {
|
|
23
41
|
var script = document.currentScript;
|
|
24
42
|
if (!script || !("serviceWorker" in navigator)) return;
|
data/lib/cronwatch/web.rb
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
# The dashboard and JSON API, as a Rack app. Needs the rack gem; the Rails
|
|
4
4
|
# integration loads this file itself.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
# run Cronwatch::Web.new(CW)
|
|
6
|
+
# run CW.routes # Client#routes requires this file itself
|
|
8
7
|
begin
|
|
9
8
|
require "rack"
|
|
10
9
|
rescue LoadError => e
|
data/lib/cronwatch/zone.rb
CHANGED
data/lib/cronwatch.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
require_relative "cronwatch/version"
|
|
6
6
|
require_relative "cronwatch/js"
|
|
7
|
+
require_relative "cronwatch/deprecation"
|
|
7
8
|
require_relative "cronwatch/types"
|
|
8
9
|
require_relative "cronwatch/duration"
|
|
9
10
|
require_relative "cronwatch/stats"
|
|
@@ -49,6 +50,7 @@ module Cronwatch
|
|
|
49
50
|
# The options Cronwatch.configure sets. Anything left unset takes the client's default.
|
|
50
51
|
class Configuration
|
|
51
52
|
OPTIONS = %i[store alerts triage cron_secret retention defaults redact deliver now on_error sources].freeze
|
|
53
|
+
private_constant :OPTIONS
|
|
52
54
|
|
|
53
55
|
attr_accessor(*OPTIONS)
|
|
54
56
|
|
|
@@ -207,7 +207,7 @@ module Cronwatch
|
|
|
207
207
|
|
|
208
208
|
4. Mount the dashboard in config/routes.rb:
|
|
209
209
|
|
|
210
|
-
mount Cronwatch
|
|
210
|
+
mount Cronwatch.client.routes => "/cronwatch"
|
|
211
211
|
|
|
212
212
|
Outside development it needs CRONWATCH_TOKEN set to sign in. In
|
|
213
213
|
development, without one, the server prints a sign-in link on
|
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.11.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon C. Phillips
|
|
@@ -57,6 +57,7 @@ files:
|
|
|
57
57
|
- lib/cronwatch/alerts/webhook.rb
|
|
58
58
|
- lib/cronwatch/client.rb
|
|
59
59
|
- lib/cronwatch/cron_pattern.rb
|
|
60
|
+
- lib/cronwatch/deprecation.rb
|
|
60
61
|
- lib/cronwatch/duration.rb
|
|
61
62
|
- lib/cronwatch/environment.rb
|
|
62
63
|
- lib/cronwatch/evaluate.rb
|