cronwatch 0.12.4 → 0.13.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 +18 -61
- data/lib/cronwatch/alerts/datadog.rb +1 -1
- data/lib/cronwatch/alerts/discord.rb +1 -1
- data/lib/cronwatch/alerts/email.rb +2 -2
- data/lib/cronwatch/alerts/provider.rb +21 -3
- data/lib/cronwatch/alerts/slack.rb +1 -1
- data/lib/cronwatch/alerts/webhook.rb +8 -1
- data/lib/cronwatch/client.rb +153 -46
- data/lib/cronwatch/duration.rb +2 -2
- data/lib/cronwatch/environment.rb +34 -23
- data/lib/cronwatch/evaluate.rb +72 -11
- data/lib/cronwatch/format.rb +9 -1
- data/lib/cronwatch/http.rb +18 -7
- data/lib/cronwatch/js.rb +3 -3
- data/lib/cronwatch/monitored.rb +3 -1
- data/lib/cronwatch/output.rb +6 -6
- data/lib/cronwatch/pg_cron.rb +7 -7
- data/lib/cronwatch/rails/active_job.rb +1 -1
- data/lib/cronwatch/rails/check_job.rb +1 -1
- data/lib/cronwatch/rails.rb +1 -1
- data/lib/cronwatch/run_handle.rb +2 -2
- data/lib/cronwatch/scheduler.rb +6 -6
- data/lib/cronwatch/sidekiq.rb +5 -3
- data/lib/cronwatch/stores/active_record.rb +5 -5
- data/lib/cronwatch/stores/memory.rb +1 -1
- data/lib/cronwatch/triage/anthropic.rb +3 -3
- data/lib/cronwatch/types.rb +11 -7
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +149 -34
- data/lib/cronwatch/web/html.rb +2 -2
- data/lib/cronwatch/web/origin.rb +2 -2
- data/lib/cronwatch/web/pwa.rb +4 -4
- data/lib/cronwatch/web/timeline.rb +3 -3
- data/lib/cronwatch.rb +1 -1
- data/lib/generators/cronwatch/install/install_generator.rb +5 -5
- metadata +3 -3
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
require "base64"
|
|
4
4
|
require "digest"
|
|
5
5
|
require "securerandom"
|
|
6
|
+
require "stringio"
|
|
6
7
|
require "uri"
|
|
7
8
|
|
|
8
9
|
module Cronwatch
|
|
9
10
|
# The dashboard and the small JSON API, as a Rack app: the SDK's routes
|
|
10
|
-
# (routes/index.ts) with the same URLs, JSON, auth, CSRF rules and headers,
|
|
11
|
+
# (routes/index.ts) with the same URLs, JSON, auth, CSRF rules, and headers,
|
|
11
12
|
# so @cronwatch/mcp works against a Ruby app as it does against a Node one.
|
|
12
13
|
#
|
|
13
14
|
# Made by Client#routes, the one way to mount it:
|
|
@@ -24,12 +25,13 @@ module Cronwatch
|
|
|
24
25
|
# token: required to reach anything. Send it as `Authorization: Bearer <token>`,
|
|
25
26
|
# or open the dashboard once with `?token=<token>` (or sign in with the
|
|
26
27
|
# form, which posts it to <base>/signin) and a cookie is set.
|
|
27
|
-
# Defaults to ENV["CRONWATCH_TOKEN"]
|
|
28
|
+
# Defaults to ENV["CRONWATCH_TOKEN"]. It is trimmed of the whitespace
|
|
29
|
+
# around it; an empty string or one of only
|
|
28
30
|
# whitespace, given here or in the variable, counts as unset, and
|
|
29
31
|
# anything but a String or nil (false, a number, a Symbol) raises TypeError.
|
|
30
|
-
# With no token while Cronwatch::Environment is development
|
|
31
|
-
#
|
|
32
|
-
# Thin or rackup, which set RACK_ENV=development by default,
|
|
32
|
+
# With no token while Cronwatch::Environment is development
|
|
33
|
+
# (Environment.stated_development?: under Puma, Unicorn,
|
|
34
|
+
# Thin, or rackup, which set RACK_ENV=development by default,
|
|
33
35
|
# RACK_ENV alone does not count), the app makes a random one and
|
|
34
36
|
# prints a sign-in link to stdout on its first request; with no
|
|
35
37
|
# token otherwise it answers 503. Pass `token: nil` to opt out and serve it open everywhere,
|
|
@@ -39,18 +41,24 @@ module Cronwatch
|
|
|
39
41
|
# mount point (SCRIPT_NAME), which is right under Rails' `mount`.
|
|
40
42
|
# origin: the public origin the dashboard is served from, such as
|
|
41
43
|
# "https://app.example.com". By default the request's own origin
|
|
42
|
-
# is used
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
# sign-in cookie's Secure flag, the Referer the redirect back after
|
|
48
|
-
# a form follows, and the development sign-in line. Read as
|
|
44
|
+
# is used: rack.url_scheme and the Host header, or the forwarded
|
|
45
|
+
# ones under trust_proxy. Set this to pin it. Used in place of the
|
|
46
|
+
# request's origin for the cross-site check on writes, the sign-in
|
|
47
|
+
# cookie's Secure flag, the Referer the redirect back after a form
|
|
48
|
+
# follows, and the development sign-in line. Read as
|
|
49
49
|
# `new URL(value).origin` reads it (Web::Origin) and normalised to
|
|
50
50
|
# scheme://host[:port]; anything that is not an absolute http or
|
|
51
51
|
# https URL, or has a port outside 1 to 65535, raises
|
|
52
|
-
# ArgumentError here. "" counts as unset.
|
|
53
|
-
#
|
|
52
|
+
# ArgumentError here. "" counts as unset. Takes precedence over
|
|
53
|
+
# trust_proxy.
|
|
54
|
+
# trust_proxy: take the public origin from X-Forwarded-Proto and
|
|
55
|
+
# X-Forwarded-Host (the first value of each, falling back to the
|
|
56
|
+
# request's own scheme or host for whichever is missing) when a
|
|
57
|
+
# request carries either, as the SDK's trustProxy does. Only for
|
|
58
|
+
# an app whose proxy sets or overwrites both headers: a client can
|
|
59
|
+
# send them too. Defaults to true when Rails is loaded, since Rails
|
|
60
|
+
# itself trusts them (ActionDispatch::Request#base_url), and to
|
|
61
|
+
# false otherwise, as in the SDK.
|
|
54
62
|
class Web
|
|
55
63
|
# Tells "token not given" (read CRONWATCH_TOKEN) from "token: nil" (open on purpose).
|
|
56
64
|
UNSET = Object.new.freeze
|
|
@@ -62,7 +70,7 @@ module Cronwatch
|
|
|
62
70
|
COOKIE_MAX_AGE = 60 * 60 * 24 * 30
|
|
63
71
|
|
|
64
72
|
# 'self' only for what the app shell needs: app.js (which registers the
|
|
65
|
-
# service worker and nothing else), the manifest, the worker and the icons.
|
|
73
|
+
# service worker and nothing else), the manifest, the worker, and the icons.
|
|
66
74
|
# No inline script, and the pages work without any.
|
|
67
75
|
CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; " \
|
|
68
76
|
"manifest-src 'self'; worker-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
|
|
@@ -103,24 +111,41 @@ module Cronwatch
|
|
|
103
111
|
app
|
|
104
112
|
end
|
|
105
113
|
|
|
106
|
-
def initialize(client = nil, token: UNSET, base_path: nil, origin: nil)
|
|
114
|
+
def initialize(client = nil, token: UNSET, base_path: nil, origin: nil, trust_proxy: UNSET)
|
|
107
115
|
@client = client
|
|
108
116
|
@origin = Web.configured_origin(origin)
|
|
117
|
+
@trust_proxy = trust_proxy.equal?(UNSET) ? Web.rails? : trust_proxy == true
|
|
109
118
|
@opted_out = token.nil?
|
|
110
119
|
# A blank token, given or read, counts as unset; one given that is not a String raises.
|
|
111
120
|
given = token.equal?(UNSET) ? nil : Environment.secret_option(token, "routes: token")
|
|
112
121
|
@token = @opted_out ? nil : given || Environment.secret("CRONWATCH_TOKEN")
|
|
113
122
|
@base_path = base_path&.to_s&.sub(%r{/+\z}, "")
|
|
114
123
|
# A Rack app cannot reliably tell a local caller from a remote one
|
|
115
|
-
# (proxies, tunnels and a server bound to every interface all look
|
|
124
|
+
# (proxies, tunnels, and a server bound to every interface all look
|
|
116
125
|
# alike), so development gets a token too: made here, and shown only in
|
|
117
126
|
# the server log.
|
|
118
127
|
@generated = @token.nil? && !@opted_out && Environment.stated_development?
|
|
119
128
|
@token = Web.development_token if @generated
|
|
129
|
+
Web.warn_short_token(@token) unless @generated
|
|
120
130
|
@announced = false
|
|
121
131
|
@announce_lock = Mutex.new
|
|
122
132
|
end
|
|
123
133
|
|
|
134
|
+
# Under this many characters a token could be guessed one request at a
|
|
135
|
+
# time, since nothing slows down wrong guesses.
|
|
136
|
+
SHORT_TOKEN = 32
|
|
137
|
+
private_constant :SHORT_TOKEN
|
|
138
|
+
|
|
139
|
+
# Warns, once per app, when the dashboard's token is short enough to guess.
|
|
140
|
+
#
|
|
141
|
+
# @api private
|
|
142
|
+
def self.warn_short_token(token)
|
|
143
|
+
return if token.nil? || token.length >= SHORT_TOKEN
|
|
144
|
+
|
|
145
|
+
warn "[cronwatch] the dashboard token is #{token.length} characters; use #{SHORT_TOKEN} or more random characters, " \
|
|
146
|
+
"such as the output of `ruby -rsecurerandom -e 'puts SecureRandom.urlsafe_base64(32)'`"
|
|
147
|
+
end
|
|
148
|
+
|
|
124
149
|
# A token for one app in development, when none is configured: 32 random
|
|
125
150
|
# bytes, base64url (43 characters).
|
|
126
151
|
def self.development_token
|
|
@@ -129,7 +154,7 @@ module Cronwatch
|
|
|
129
154
|
|
|
130
155
|
# The line a development token is announced with, printed once to stdout
|
|
131
156
|
# on the app's first request. `origin` is the `origin:` option when set,
|
|
132
|
-
# otherwise that request's origin (scheme, host and any port) when its
|
|
157
|
+
# otherwise that request's origin (scheme, host, and any port) when its
|
|
133
158
|
# host is loopback, and nil for any other host: the request's host is
|
|
134
159
|
# the client's to choose, so the line then leaves it out rather than
|
|
135
160
|
# point the link, token and all, somewhere else. `base` is the base path
|
|
@@ -168,6 +193,11 @@ module Cronwatch
|
|
|
168
193
|
Origin.parse(value)
|
|
169
194
|
end
|
|
170
195
|
|
|
196
|
+
# Whether the app runs under Rails, which trusts forwarded headers by default.
|
|
197
|
+
def self.rails?
|
|
198
|
+
defined?(::Rails::Railtie) ? true : false
|
|
199
|
+
end
|
|
200
|
+
|
|
171
201
|
# The client given, or Cronwatch.client when none was.
|
|
172
202
|
def client
|
|
173
203
|
@client || Cronwatch.client
|
|
@@ -203,7 +233,7 @@ module Cronwatch
|
|
|
203
233
|
|
|
204
234
|
announce(request, base) if @generated && !@announced
|
|
205
235
|
|
|
206
|
-
# The app shell: the manifest, icons, service worker, app.js and the
|
|
236
|
+
# The app shell: the manifest, icons, service worker, app.js, and the
|
|
207
237
|
# offline page. Served to anyone, since a browser fetches some of it
|
|
208
238
|
# without cookies and none of it says anything about the jobs.
|
|
209
239
|
if %w[GET HEAD].include?(method)
|
|
@@ -230,7 +260,9 @@ module Cronwatch
|
|
|
230
260
|
# The sign-in form posts the token here, in the body, so it stays out of
|
|
231
261
|
# the URL and every access log. Cross-site posts were refused above.
|
|
232
262
|
if @token && method == "POST" && path == "/signin"
|
|
233
|
-
|
|
263
|
+
body = read_body(request)
|
|
264
|
+
# A body too large is no token at all.
|
|
265
|
+
sent = body.equal?(TOO_LARGE) ? nil : body["token"]
|
|
234
266
|
return sign_in_page(base) if sent.nil? || !HTTP.constant_time_equal?(sent, @token)
|
|
235
267
|
|
|
236
268
|
return redirect(sign_in_return(request.header("referer"), public_origin(request), base),
|
|
@@ -260,7 +292,10 @@ module Cronwatch
|
|
|
260
292
|
# Move the token from the URL into a cookie, so it is not left in the
|
|
261
293
|
# browser's history. The request line that carried it may still be in
|
|
262
294
|
# an access log, which is why the sign-in form posts instead.
|
|
263
|
-
|
|
295
|
+
# One leading slash: "//host/..." would be a protocol-relative Location,
|
|
296
|
+
# off this site, and a browser reads a backslash there as a slash.
|
|
297
|
+
return redirect(request.pathname.sub(%r{\A[/\\]+}, "/") + request.search_without("token"),
|
|
298
|
+
"set-cookie" => sign_in_cookie(request, base))
|
|
264
299
|
end
|
|
265
300
|
end
|
|
266
301
|
|
|
@@ -301,8 +336,11 @@ module Cronwatch
|
|
|
301
336
|
return html(HTML.message_page("No such job", "#{name} is not in the store.", base), 404) unless cw.job_summary(name)
|
|
302
337
|
|
|
303
338
|
if action == "silence"
|
|
339
|
+
body = read_body(request)
|
|
340
|
+
return html(HTML.message_page("Not silenced", "The request was too large.", base), 413) if body.equal?(TOO_LARGE)
|
|
341
|
+
|
|
304
342
|
begin
|
|
305
|
-
duration = silence_duration(
|
|
343
|
+
duration = silence_duration(body["for"])
|
|
306
344
|
rescue ArgumentError => e
|
|
307
345
|
return html(HTML.message_page("Not silenced", e.message, base), 400)
|
|
308
346
|
end
|
|
@@ -346,6 +384,8 @@ module Cronwatch
|
|
|
346
384
|
|
|
347
385
|
if rest[2] == "silence"
|
|
348
386
|
body = read_body(request)
|
|
387
|
+
return api({ ok: false, error: "Request body too large" }, 413) if body.equal?(TOO_LARGE)
|
|
388
|
+
|
|
349
389
|
begin
|
|
350
390
|
duration = silence_duration(body.key?("for") ? body["for"] : request.query("for"))
|
|
351
391
|
rescue ArgumentError => e
|
|
@@ -399,7 +439,8 @@ module Cronwatch
|
|
|
399
439
|
end
|
|
400
440
|
return unless first
|
|
401
441
|
|
|
402
|
-
|
|
442
|
+
own = request_origin(request)
|
|
443
|
+
shown = @origin || (Web.loopback_origin?(own) ? own : nil)
|
|
403
444
|
$stdout.puts(Web.development_sign_in_line(shown, base, @token))
|
|
404
445
|
$stdout.flush
|
|
405
446
|
end
|
|
@@ -477,17 +518,26 @@ module Cronwatch
|
|
|
477
518
|
!site.nil? && site != "same-origin" && site != "none"
|
|
478
519
|
end
|
|
479
520
|
|
|
521
|
+
# A request body past Request::MAX_BODY, which read_body answers with.
|
|
522
|
+
TOO_LARGE = Object.new.freeze
|
|
523
|
+
private_constant :TOO_LARGE
|
|
524
|
+
|
|
480
525
|
# The form fields or JSON object of a request, each value as String(value)
|
|
481
|
-
# gives it in JavaScript
|
|
482
|
-
#
|
|
483
|
-
# order mark is dropped.
|
|
526
|
+
# gives it in JavaScript, or TOO_LARGE for a body past Request::MAX_BODY.
|
|
527
|
+
# The body is read as the SDK's request.json() reads it: bytes that are
|
|
528
|
+
# not UTF-8 become U+FFFD, and a leading byte order mark is dropped.
|
|
484
529
|
def read_body(request)
|
|
485
530
|
type = request.header("content-type") || ""
|
|
486
|
-
|
|
531
|
+
json = type.include?("application/json")
|
|
532
|
+
form = type.include?("application/x-www-form-urlencoded") || type.include?("multipart/form-data")
|
|
533
|
+
return {} unless json || form
|
|
534
|
+
return TOO_LARGE if request.too_large?
|
|
535
|
+
|
|
536
|
+
if json
|
|
487
537
|
data = JSON.parse(Output.utf8(request.body).delete_prefix(""))
|
|
488
538
|
return data.to_h { |k, v| [k.to_s, js_string(v)] } if data.is_a?(Hash)
|
|
489
539
|
return data.each_with_index.to_h { |v, i| [i.to_s, js_string(v)] } if data.is_a?(Array)
|
|
490
|
-
|
|
540
|
+
else
|
|
491
541
|
return request.form.transform_values { |v| form_value(v) }
|
|
492
542
|
end
|
|
493
543
|
{}
|
|
@@ -547,7 +597,12 @@ module Cronwatch
|
|
|
547
597
|
|
|
548
598
|
# The origin a browser sees: the `origin` option when set, otherwise the request's own.
|
|
549
599
|
def public_origin(request)
|
|
550
|
-
@origin || request
|
|
600
|
+
@origin || request_origin(request)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# The request's origin: the forwarded one under trust_proxy, else its own.
|
|
604
|
+
def request_origin(request)
|
|
605
|
+
@trust_proxy ? request.forwarded_origin : request.origin
|
|
551
606
|
end
|
|
552
607
|
|
|
553
608
|
def api(body, status = 200, headers = {})
|
|
@@ -607,11 +662,35 @@ module Cronwatch
|
|
|
607
662
|
@env[key]
|
|
608
663
|
end
|
|
609
664
|
|
|
610
|
-
# scheme://host[:port], the page's origin
|
|
611
|
-
#
|
|
612
|
-
#
|
|
665
|
+
# scheme://host[:port], the page's origin as the request URL gives it
|
|
666
|
+
# (rack.url_scheme and the Host header, never a forwarded header),
|
|
667
|
+
# normalised as a browser writes it in Origin and Referer.
|
|
613
668
|
def origin
|
|
614
|
-
@rack.
|
|
669
|
+
scheme = @env["rack.url_scheme"].to_s.downcase
|
|
670
|
+
scheme = "http" unless %w[http https].include?(scheme)
|
|
671
|
+
host = @env["HTTP_HOST"].to_s
|
|
672
|
+
host = [@env["SERVER_NAME"], @env["SERVER_PORT"]].compact.join(":") if host.empty?
|
|
673
|
+
Origin.bare("#{scheme}://#{host}") || "#{scheme}://#{host.downcase}"
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# With trust_proxy: the first X-Forwarded-Proto and X-Forwarded-Host,
|
|
677
|
+
# each falling back to the request's own, when either is present and
|
|
678
|
+
# well formed; otherwise the request's own origin. The SDK's forwardedOrigin.
|
|
679
|
+
def forwarded_origin
|
|
680
|
+
own = origin
|
|
681
|
+
proto = Request.first_value(header("x-forwarded-proto"))&.downcase
|
|
682
|
+
host = Request.first_value(header("x-forwarded-host"))
|
|
683
|
+
return own if proto.nil? && host.nil?
|
|
684
|
+
return own unless proto.nil? || %w[http https].include?(proto)
|
|
685
|
+
|
|
686
|
+
scheme, authority = own.split("://", 2)
|
|
687
|
+
Origin.bare("#{proto || scheme}://#{host || authority}") || own
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# The first of a header's comma-separated values, trimmed, or nil.
|
|
691
|
+
def self.first_value(value)
|
|
692
|
+
first = value&.split(",", 2)&.first&.strip
|
|
693
|
+
first.nil? || first.empty? ? nil : first
|
|
615
694
|
end
|
|
616
695
|
|
|
617
696
|
# URLSearchParams#get: the first value, or nil.
|
|
@@ -627,12 +706,24 @@ module Cronwatch
|
|
|
627
706
|
pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
|
|
628
707
|
end
|
|
629
708
|
|
|
709
|
+
# The most of a request body the routes read (1 MiB), as every port's.
|
|
710
|
+
MAX_BODY = 1 << 20
|
|
711
|
+
|
|
712
|
+
# Whether the body is past MAX_BODY: by its Content-Length when that
|
|
713
|
+
# says more, else by reading one byte past the cap at most, so a client
|
|
714
|
+
# cannot make the server hold a body of any size. The forms and JSON the
|
|
715
|
+
# routes take are a few bytes. What was read is left to be read again.
|
|
716
|
+
def too_large?
|
|
717
|
+
@too_large = measure_too_large if @too_large.nil?
|
|
718
|
+
@too_large
|
|
719
|
+
end
|
|
720
|
+
|
|
630
721
|
def body
|
|
631
722
|
input = @env["rack.input"]
|
|
632
723
|
return "" unless input
|
|
633
724
|
|
|
634
725
|
input.rewind if input.respond_to?(:rewind)
|
|
635
|
-
text = input.read.to_s
|
|
726
|
+
text = input.read(MAX_BODY + 1).to_s
|
|
636
727
|
input.rewind if input.respond_to?(:rewind)
|
|
637
728
|
text.force_encoding(Encoding::UTF_8)
|
|
638
729
|
end
|
|
@@ -645,6 +736,30 @@ module Cronwatch
|
|
|
645
736
|
fields.each_with_object({}) { |(k, v), out| out[Output.utf8(k)] = v }
|
|
646
737
|
end
|
|
647
738
|
|
|
739
|
+
private
|
|
740
|
+
|
|
741
|
+
def measure_too_large
|
|
742
|
+
length = @env["CONTENT_LENGTH"].to_s
|
|
743
|
+
return true if length.match?(/\A\d+\z/) && length.to_i > MAX_BODY
|
|
744
|
+
|
|
745
|
+
input = @env["rack.input"]
|
|
746
|
+
return false unless input
|
|
747
|
+
|
|
748
|
+
input.rewind if input.respond_to?(:rewind)
|
|
749
|
+
read = input.read(MAX_BODY + 1).to_s
|
|
750
|
+
return true if read.bytesize > MAX_BODY
|
|
751
|
+
|
|
752
|
+
# An input that cannot be rewound is handed on as what was read.
|
|
753
|
+
if input.respond_to?(:rewind)
|
|
754
|
+
input.rewind
|
|
755
|
+
else
|
|
756
|
+
@env["rack.input"] = StringIO.new(read)
|
|
757
|
+
end
|
|
758
|
+
false
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
public
|
|
762
|
+
|
|
648
763
|
# application/x-www-form-urlencoded parsing as URLSearchParams does it:
|
|
649
764
|
# "+" is a space, and a bad escape is kept as written rather than refused.
|
|
650
765
|
def self.parse_query(text)
|
data/lib/cronwatch/web/html.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Cronwatch
|
|
|
10
10
|
# person reads, a mono for what a machine printed, neutral greys, and
|
|
11
11
|
# colour only for the states CronWatch reports. The page loads nothing but
|
|
12
12
|
# its own app shell (its CSP is default-src 'none' plus 'self' for the
|
|
13
|
-
# script, the manifest, the worker and images), so the fonts are system
|
|
13
|
+
# script, the manifest, the worker, and images), so the fonts are system
|
|
14
14
|
# stacks that echo the site's Newsreader and IBM Plex Mono, and use them
|
|
15
15
|
# when they are installed.
|
|
16
16
|
#
|
|
@@ -251,7 +251,7 @@ module Cronwatch
|
|
|
251
251
|
end
|
|
252
252
|
|
|
253
253
|
# A page. `base` is where the dashboard is mounted ("" at the root): the
|
|
254
|
-
# head links the web app manifest, the icons and app.js, the one script,
|
|
254
|
+
# head links the web app manifest, the icons, and app.js, the one script,
|
|
255
255
|
# which registers the service worker and toggles the theme (Web::PWA). Everything works
|
|
256
256
|
# without it.
|
|
257
257
|
def layout(title, body, base, refresh: nil)
|
data/lib/cronwatch/web/origin.rb
CHANGED
|
@@ -42,7 +42,7 @@ module Cronwatch
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# scheme://host[:port] for text that is a scheme and a bare host, or nil
|
|
45
|
-
# when it carries credentials, a path, a query or a fragment, or is not
|
|
45
|
+
# when it carries credentials, a path, a query, or a fragment, or is not
|
|
46
46
|
# an http or https URL.
|
|
47
47
|
def bare(value)
|
|
48
48
|
return nil unless value.is_a?(String)
|
|
@@ -61,7 +61,7 @@ module Cronwatch
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Whether what follows the host would show in the URL: a path other
|
|
64
|
-
# than "/", a query or a fragment.
|
|
64
|
+
# than "/", a query, or a fragment.
|
|
65
65
|
def past_host?(after)
|
|
66
66
|
path, hash, fragment = after.partition("#")
|
|
67
67
|
path, _, query = path.partition("?")
|
data/lib/cronwatch/web/pwa.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Cronwatch
|
|
|
4
4
|
class Web
|
|
5
5
|
# What makes the dashboard an installable web app, as the SDK's
|
|
6
6
|
# routes/pwa.ts has it: a manifest, icons, a service worker, the script
|
|
7
|
-
# that registers it and a page to show offline. None of it says anything
|
|
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
10
|
#
|
|
@@ -46,7 +46,7 @@ module Cronwatch
|
|
|
46
46
|
JS
|
|
47
47
|
|
|
48
48
|
# The service worker. It caches the app shell (the offline page, the
|
|
49
|
-
# manifest, the icons and app.js) and nothing else: every other request
|
|
49
|
+
# manifest, the icons, and app.js) and nothing else: every other request
|
|
50
50
|
# goes to the network as the page made it, and its answer is never
|
|
51
51
|
# stored, since the pages and the JSON carry job data. When a page
|
|
52
52
|
# cannot be reached it shows the offline page. Its scope gives the base,
|
|
@@ -95,7 +95,7 @@ module Cronwatch
|
|
|
95
95
|
});
|
|
96
96
|
JS
|
|
97
97
|
|
|
98
|
-
# One app shell file: its type, body and Cache-Control, and whether it
|
|
98
|
+
# One app shell file: its type, body, and Cache-Control, and whether it
|
|
99
99
|
# is the service worker (which may control everything under the base).
|
|
100
100
|
Asset = Struct.new(:type, :body, :cache, :worker, keyword_init: true)
|
|
101
101
|
|
|
@@ -120,7 +120,7 @@ module Cronwatch
|
|
|
120
120
|
id: "#{base}/",
|
|
121
121
|
name: "CronWatch",
|
|
122
122
|
short_name: "CronWatch",
|
|
123
|
-
description: "The scheduled jobs of this app: their health, their last day and their runs.",
|
|
123
|
+
description: "The scheduled jobs of this app: their health, their last day, and their runs.",
|
|
124
124
|
start_url: "#{base}/",
|
|
125
125
|
scope: "#{base}/",
|
|
126
126
|
display: "standalone",
|
|
@@ -239,11 +239,11 @@ module Cronwatch
|
|
|
239
239
|
|
|
240
240
|
if open?(job, :over_budget) && last
|
|
241
241
|
over = over_ceilings(job)
|
|
242
|
-
return "went over budget#{over.empty? ? "" : " on #{
|
|
242
|
+
return "went over budget#{over.empty? ? "" : " on #{Format.and_list(over)}"} at #{when_at(last.started_at, now)}"
|
|
243
243
|
end
|
|
244
244
|
if open?(job, :under_floor) && last
|
|
245
245
|
under = under_floors(job)
|
|
246
|
-
return "fell short#{under.empty? ? "" : " on #{
|
|
246
|
+
return "fell short#{under.empty? ? "" : " on #{Format.and_list(under)}"} at #{when_at(last.started_at, now)}"
|
|
247
247
|
end
|
|
248
248
|
return "slow: took #{Duration.format(last.duration_ms)}" if open?(job, :slow) && !last&.duration_ms.nil?
|
|
249
249
|
return "failing" if open?(job, :failed)
|
|
@@ -405,7 +405,7 @@ module Cronwatch
|
|
|
405
405
|
[box.call("run ok"), "ran"],
|
|
406
406
|
[box.call("run bad"), "failed"],
|
|
407
407
|
[box.call("run timeout"), "timed out"],
|
|
408
|
-
[box.call("run warn"), "over budget, under floor or slow"],
|
|
408
|
+
[box.call("run warn"), "over budget, under floor, or slow"],
|
|
409
409
|
[box.call("run running"), "running"],
|
|
410
410
|
[box.call("missed"), "missed"],
|
|
411
411
|
]
|
data/lib/cronwatch.rb
CHANGED
|
@@ -110,5 +110,5 @@ module Cronwatch
|
|
|
110
110
|
end
|
|
111
111
|
|
|
112
112
|
# In a Rails app Bundler requires gems after Rails itself, so `gem "cronwatch"`
|
|
113
|
-
# alone brings in the Railtie, the ActiveJob concern and the generator.
|
|
113
|
+
# alone brings in the Railtie, the ActiveJob concern, and the generator.
|
|
114
114
|
require_relative "cronwatch/rails" if defined?(::Rails::Railtie)
|
|
@@ -22,7 +22,7 @@ module Cronwatch
|
|
|
22
22
|
desc "Creates the CronWatch migration and initializer."
|
|
23
23
|
|
|
24
24
|
class_option :prefix, type: :string, default: Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX,
|
|
25
|
-
desc: "Table name prefix: lowercase letters, digits and underscores"
|
|
25
|
+
desc: "Table name prefix: lowercase letters, digits, and underscores"
|
|
26
26
|
class_option :database, type: :string, aliases: %i[--db],
|
|
27
27
|
desc: "The database for the tables, in an app with several (from config/database.yml)"
|
|
28
28
|
|
|
@@ -107,7 +107,7 @@ module Cronwatch
|
|
|
107
107
|
|
|
108
108
|
require "cronwatch/active_record"
|
|
109
109
|
|
|
110
|
-
# The tables CronWatch keeps jobs, runs and alert state in. They are
|
|
110
|
+
# The tables CronWatch keeps jobs, runs, and alert state in. They are
|
|
111
111
|
# created with the SDK's own statements, so a Node process using
|
|
112
112
|
# @cronwatch/sdk can share them.
|
|
113
113
|
class #{migration_name.camelize} < ActiveRecord::Migration[#{::ActiveRecord::Migration.current_version}]
|
|
@@ -127,7 +127,7 @@ module Cronwatch
|
|
|
127
127
|
# frozen_string_literal: true
|
|
128
128
|
|
|
129
129
|
# CronWatch: told when a scheduled job is missed, failed, stuck, slow,
|
|
130
|
-
# over budget or under its floor. https://cronwatch.dev/docs/
|
|
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
|
#
|
|
@@ -138,7 +138,7 @@ module Cronwatch
|
|
|
138
138
|
#
|
|
139
139
|
# and run Cronwatch::CheckJob every few minutes to catch the runs that never happen.
|
|
140
140
|
Cronwatch.configure do |c|
|
|
141
|
-
# Jobs, runs and alert state, in #{database_name ? "the #{database_name} database (see app/models/cronwatch_record.rb)" : "this app's database"}.
|
|
141
|
+
# Jobs, runs, and alert state, in #{database_name ? "the #{database_name} database (see app/models/cronwatch_record.rb)" : "this app's database"}.
|
|
142
142
|
c.store = Cronwatch::Stores::ActiveRecord.new#{store_args}
|
|
143
143
|
|
|
144
144
|
# Where alerts go. With none set, they are written to standard error.
|
|
@@ -154,7 +154,7 @@ module Cronwatch
|
|
|
154
154
|
# Applied to every job that does not set its own.
|
|
155
155
|
# c.defaults = { grace: "10m", timezone: "Europe/London", failures_before_alert: 1 }
|
|
156
156
|
|
|
157
|
-
# Called with (error, where) when the store, a channel or triage fails. Default: Rails.logger.
|
|
157
|
+
# Called with (error, where) when the store, a channel, or triage fails. Default: Rails.logger.
|
|
158
158
|
# c.on_error = ->(error, where) { Rails.error.report(error, handled: true, context: { cronwatch: where }) }
|
|
159
159
|
|
|
160
160
|
# The bearer secret the dashboard's check endpoint takes. Default: ENV["CRON_SECRET"].
|
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.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon C. Phillips
|
|
@@ -24,8 +24,8 @@ dependencies:
|
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '1.11'
|
|
26
26
|
description: 'Wrap a job, run a check, get told when it is missed, failed, stuck,
|
|
27
|
-
slow or over budget. The Ruby port of @cronwatch/sdk: same rules, same alerts,
|
|
28
|
-
stored rows.'
|
|
27
|
+
slow, or over budget. The Ruby port of @cronwatch/sdk: same rules, same alerts,
|
|
28
|
+
same stored rows.'
|
|
29
29
|
executables: []
|
|
30
30
|
extensions: []
|
|
31
31
|
extra_rdoc_files: []
|