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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +18 -9
  3. data/lib/cronwatch/alerts/datadog.rb +1 -0
  4. data/lib/cronwatch/alerts/discord.rb +22 -3
  5. data/lib/cronwatch/alerts/email.rb +2 -0
  6. data/lib/cronwatch/alerts/honeybadger.rb +1 -0
  7. data/lib/cronwatch/alerts/postmark.rb +1 -0
  8. data/lib/cronwatch/alerts/provider.rb +2 -0
  9. data/lib/cronwatch/alerts/resend.rb +1 -0
  10. data/lib/cronwatch/alerts/rollbar.rb +1 -0
  11. data/lib/cronwatch/alerts/sentry.rb +1 -0
  12. data/lib/cronwatch/alerts/sigv4.rb +2 -0
  13. data/lib/cronwatch/alerts/slack.rb +1 -0
  14. data/lib/cronwatch/alerts/twilio.rb +1 -0
  15. data/lib/cronwatch/alerts/webhook.rb +18 -5
  16. data/lib/cronwatch/client.rb +66 -17
  17. data/lib/cronwatch/cron_pattern.rb +17 -8
  18. data/lib/cronwatch/deprecation.rb +18 -0
  19. data/lib/cronwatch/duration.rb +2 -0
  20. data/lib/cronwatch/environment.rb +93 -17
  21. data/lib/cronwatch/evaluate.rb +41 -10
  22. data/lib/cronwatch/flight.rb +2 -0
  23. data/lib/cronwatch/format.rb +1 -0
  24. data/lib/cronwatch/http.rb +21 -5
  25. data/lib/cronwatch/job.rb +2 -0
  26. data/lib/cronwatch/js.rb +74 -1
  27. data/lib/cronwatch/monitored.rb +6 -1
  28. data/lib/cronwatch/output.rb +3 -0
  29. data/lib/cronwatch/pg_cron.rb +22 -3
  30. data/lib/cronwatch/rails/active_job.rb +2 -2
  31. data/lib/cronwatch/schedule.rb +25 -1
  32. data/lib/cronwatch/scheduler.rb +6 -1
  33. data/lib/cronwatch/serialize.rb +1 -0
  34. data/lib/cronwatch/stats.rb +1 -0
  35. data/lib/cronwatch/stores/active_record.rb +36 -18
  36. data/lib/cronwatch/ticker.rb +2 -0
  37. data/lib/cronwatch/triage/anthropic.rb +2 -0
  38. data/lib/cronwatch/types.rb +148 -31
  39. data/lib/cronwatch/version.rb +1 -1
  40. data/lib/cronwatch/walker.rb +2 -0
  41. data/lib/cronwatch/web/app.rb +108 -33
  42. data/lib/cronwatch/web/html.rb +10 -6
  43. data/lib/cronwatch/web/origin.rb +2 -0
  44. data/lib/cronwatch/web/pwa.rb +20 -2
  45. data/lib/cronwatch/web/timeline.rb +2 -0
  46. data/lib/cronwatch/web.rb +1 -2
  47. data/lib/cronwatch/zone.rb +2 -0
  48. data/lib/cronwatch.rb +2 -0
  49. data/lib/generators/cronwatch/install/install_generator.rb +1 -1
  50. metadata +2 -1
@@ -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::Web.new => "/cronwatch"
16
+ # mount Cronwatch.client.routes => "/cronwatch"
15
17
  #
16
18
  # # config.ru, standalone
17
- # run Cronwatch::Web.new(CW)
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>` and a cookie is set.
21
- # Defaults to ENV["CRONWATCH_TOKEN"]; an empty string counts as unset.
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
- BEARER = Regexp.new("\\ABearer[#{JS::WHITESPACE}]+", Regexp::IGNORECASE)
70
- DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
71
- RADIX = { "x" => 16, "o" => 8, "b" => 2 }.freeze
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
- given = token.equal?(UNSET) ? nil : token
78
- @token = @opted_out ? nil : [given, ENV.fetch("CRONWATCH_TOKEN", nil)].map(&:to_s).find { |t| !t.empty? }
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 Cronwatch::Web.new), or pass token: nil to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503)
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&.sub(BEARER, "")
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 wants_html ? html(HTML.message_page("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base, sign_in: true), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401)
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
- return wants_html ? html(HTML.message_page("Sign in", "Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.", base, sign_in: true), 401) : api({ ok: false, error: "Unauthorized" }, 401)
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 history or logs.
214
- secure = public_origin(request).start_with?("https:") ? "; Secure" : ""
215
- return redirect(request.pathname + request.search_without("token"),
216
- "set-cookie" => "#{COOKIE}=#{cookie_value(@token)}; Path=#{base.empty? ? "/" : base}; HttpOnly; SameSite=Lax; Max-Age=#{COOKIE_MAX_AGE}#{secure}")
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
- return api({ ok: true, state: cw.silence(name, duration) })
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
- text = JS.trim(value)
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
 
@@ -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, only registers the service worker: the pages refresh
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 only registers the service worker (Web::PWA). Everything works
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 takes the
568
- # token and sends it as ?token=, which the app moves into the cookie:
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="get" action="#{h(base)}/"><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>) : ""
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
@@ -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]/
@@ -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 does nothing else. The page works
18
- # the same without it. Its own URL gives the base, so it is the same
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;
@@ -15,6 +15,8 @@ module Cronwatch
15
15
  # open, and a visually hidden list says the same things in words.
16
16
  #
17
17
  # Every time is UTC: without script the page cannot know the viewer's zone.
18
+ #
19
+ # @api private
18
20
  module Timeline
19
21
  HOUR = 3_600_000
20
22
  DAY = 24 * HOUR
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
- # require "cronwatch/web"
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
@@ -5,6 +5,8 @@ require "fugit"
5
5
  module Cronwatch
6
6
  # IANA zones through TZInfo (which Fugit brings, by way of et-orbi), or the
7
7
  # process's own zone when none is named, as JavaScript's Date does.
8
+ #
9
+ # @api private
8
10
  module Zone
9
11
  @zones = {}
10
12
  @names = nil
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::Web.new(Cronwatch.client) => "/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.10.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