cronwatch 0.9.0 → 0.11.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -11
  3. data/lib/cronwatch/alerts/datadog.rb +1 -0
  4. data/lib/cronwatch/alerts/discord.rb +34 -2
  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 +275 -110
  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 +109 -7
  21. data/lib/cronwatch/evaluate.rb +145 -10
  22. data/lib/cronwatch/flight.rb +6 -0
  23. data/lib/cronwatch/format.rb +1 -0
  24. data/lib/cronwatch/http.rb +21 -5
  25. data/lib/cronwatch/job.rb +7 -2
  26. data/lib/cronwatch/js.rb +74 -1
  27. data/lib/cronwatch/monitored.rb +6 -1
  28. data/lib/cronwatch/output.rb +28 -1
  29. data/lib/cronwatch/pg_cron.rb +29 -6
  30. data/lib/cronwatch/rails/active_job.rb +2 -2
  31. data/lib/cronwatch/schedule.rb +25 -1
  32. data/lib/cronwatch/scheduler.rb +79 -11
  33. data/lib/cronwatch/serialize.rb +1 -0
  34. data/lib/cronwatch/stats.rb +1 -0
  35. data/lib/cronwatch/stores/active_record.rb +37 -11
  36. data/lib/cronwatch/ticker.rb +14 -0
  37. data/lib/cronwatch/triage/anthropic.rb +2 -0
  38. data/lib/cronwatch/types.rb +199 -27
  39. data/lib/cronwatch/version.rb +1 -1
  40. data/lib/cronwatch/walker.rb +2 -0
  41. data/lib/cronwatch/web/app.rb +114 -37
  42. data/lib/cronwatch/web/html.rb +9 -5
  43. data/lib/cronwatch/web/origin.rb +2 -0
  44. data/lib/cronwatch/web/pwa.rb +2 -0
  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 +52 -8
  50. metadata +2 -1
@@ -21,6 +21,8 @@ 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}
@@ -242,7 +244,7 @@ module Cronwatch
242
244
 
243
245
  # Object.entries: integer-like keys first, ascending, then the rest in insertion order.
244
246
  def entries(hash)
245
- return [] if hash.nil?
247
+ return [] unless hash.is_a?(Hash)
246
248
 
247
249
  JS.object_keys(hash).map { |k| [k.to_s, hash[k]] }
248
250
  end
@@ -456,7 +458,8 @@ module Cronwatch
456
458
  truthy?(run.error) ? %(<details class="out error" open><summary>error</summary><pre>#{h(run.error)}</pre></details>) : "",
457
459
  truthy?(run.output) ? %(<details class="out"#{run.status.to_s == "ok" ? "" : " open"}><summary>output</summary><pre>#{h(run.output)}</pre></details>) : "",
458
460
  ].join
459
- metrics = entries(run.metrics).map do |k, v|
461
+ # A foreign row may hold a metric that is no finite number (null, text); it is left out.
462
+ metrics = entries(run.metrics).select { |_, v| v.is_a?(Numeric) && v.real? && JS.finite?(v) }.map do |k, v|
460
463
  %(<span><span class="k">#{h(k)}</span> #{h(JS.integer?(v) ? v : to_fixed(v, 4))}</span>)
461
464
  end.join
462
465
  took = run.duration_ms.nil? ? %(<span class="muted">running</span>) : h(Duration.format(run.duration_ms))
@@ -563,13 +566,14 @@ module Cronwatch
563
566
  layout("#{job.name}: CronWatch", body, base, refresh: 60)
564
567
  end
565
568
 
566
- # A page with one message. With `sign_in`, a form under it takes the
567
- # token and sends it as ?token=, which the app moves into the cookie:
569
+ # A page with one message. With `sign_in`, a form under it posts the
570
+ # token to <base>/signin in the body, keeping it out of the URL and
571
+ # access logs, and the app sets the cookie:
568
572
  # the way in where there is no address bar to open a link with, such as
569
573
  # an app on an iPhone's home screen, which keeps its cookies apart from
570
574
  # Safari's.
571
575
  def message_page(title, message, base, sign_in: false)
572
- 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>) : ""
576
+ 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>) : ""
573
577
  layout(title, %(<header class="top">#{brand(base)}</header><main class="message"><h1>#{h(title)}</h1><p>#{h(message)}</p>#{form}</main>), base)
574
578
  end
575
579
  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.
@@ -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
 
@@ -11,8 +11,11 @@ module Cronwatch
11
11
  # Writes a migration for the three tables the ActiveRecord store uses
12
12
  # (created with the SDK's own DDL, so a Node process can share them) and
13
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.
14
+ # Cronwatch::CheckJob and mount the dashboard. With --database, the
15
+ # migration goes to that database and app/models/cronwatch_record.rb, an
16
+ # abstract class connected to it, is the store's connection_class, so the
17
+ # store reads the tables where the migration made them. The templates
18
+ # live in this file so the gem ships nothing but Ruby.
16
19
  class InstallGenerator < ::Rails::Generators::Base
17
20
  include ::ActiveRecord::Generators::Migration
18
21
 
@@ -21,7 +24,10 @@ module Cronwatch
21
24
  class_option :prefix, type: :string, default: Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX,
22
25
  desc: "Table name prefix: lowercase letters, digits and underscores"
23
26
  class_option :database, type: :string, aliases: %i[--db],
24
- desc: "The database for the migration, in an app with several"
27
+ desc: "The database for the tables, in an app with several (from config/database.yml)"
28
+
29
+ # The abstract class --database writes, which the store connects through.
30
+ RECORD_CLASS = "CronwatchRecord"
25
31
 
26
32
  def check_prefix
27
33
  Cronwatch::Stores::ActiveRecord.table_prefix(options[:prefix])
@@ -41,6 +47,12 @@ module Cronwatch
41
47
  create_file File.join(dir, "#{number}_#{migration_name}.rb"), migration
42
48
  end
43
49
 
50
+ def create_connection_class
51
+ return unless database_name
52
+
53
+ create_file "app/models/cronwatch_record.rb", connection_class
54
+ end
55
+
44
56
  def create_initializer
45
57
  create_file "config/initializers/cronwatch.rb", initializer
46
58
  end
@@ -60,9 +72,33 @@ module Cronwatch
60
72
  "create_cronwatch_#{prefix.squeeze("_").delete_prefix("_").delete_suffix("_")}_tables"
61
73
  end
62
74
 
75
+ # The --database name, or nil.
76
+ def database_name
77
+ name = options[:database].to_s
78
+ name.empty? ? nil : name
79
+ end
80
+
63
81
  def store_args
64
82
  prefix = options[:prefix]
65
- prefix == Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX ? "" : "(prefix: #{prefix.inspect})"
83
+ args = []
84
+ args << "prefix: #{prefix.inspect}" unless prefix == Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX
85
+ # Named, not referenced, so the initializer does not load an app class at boot.
86
+ args << "connection_class: #{RECORD_CLASS.inspect}" if database_name
87
+ args.empty? ? "" : "(#{args.join(", ")})"
88
+ end
89
+
90
+ def connection_class
91
+ <<~RUBY
92
+ # frozen_string_literal: true
93
+
94
+ # The #{database_name} database, where CronWatch keeps its tables. The store in
95
+ # config/initializers/cronwatch.rb connects through this class.
96
+ class #{RECORD_CLASS} < ActiveRecord::Base
97
+ self.abstract_class = true
98
+
99
+ connects_to database: { writing: :#{database_name} }
100
+ end
101
+ RUBY
66
102
  end
67
103
 
68
104
  def migration
@@ -102,7 +138,7 @@ module Cronwatch
102
138
  #
103
139
  # and run Cronwatch::CheckJob every few minutes to catch the runs that never happen.
104
140
  Cronwatch.configure do |c|
105
- # Jobs, runs and alert state, in 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"}.
106
142
  c.store = Cronwatch::Stores::ActiveRecord.new#{store_args}
107
143
 
108
144
  # Where alerts go. With none set, they are written to standard error.
@@ -127,14 +163,22 @@ module Cronwatch
127
163
  RUBY
128
164
  end
129
165
 
166
+ # What the next steps say about --database after the migrate line, or "".
167
+ def database_step
168
+ return "" unless database_name
169
+
170
+ "\n\n The store connects through #{RECORD_CLASS} (app/models/cronwatch_record.rb),\n " \
171
+ "which connects_to the #{database_name} database named in config/database.yml."
172
+ end
173
+
130
174
  def next_steps
131
175
  <<~TEXT
132
176
 
133
177
  CronWatch is installed. Next:
134
178
 
135
- 1. Create the tables:
179
+ 1. Create the tables#{database_name ? " in the #{database_name} database" : ""}:
136
180
 
137
- bin/rails db:migrate
181
+ bin/rails db:migrate#{database_step}
138
182
 
139
183
  2. Monitor a job:
140
184
 
@@ -163,7 +207,7 @@ module Cronwatch
163
207
 
164
208
  4. Mount the dashboard in config/routes.rb:
165
209
 
166
- mount Cronwatch::Web.new(Cronwatch.client) => "/cronwatch"
210
+ mount Cronwatch.client.routes => "/cronwatch"
167
211
 
168
212
  Outside development it needs CRONWATCH_TOKEN set to sign in. In
169
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.9.0
4
+ version: 0.11.0
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