cronwatch 0.3.1 → 0.5.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -3
  3. data/lib/cronwatch/alerts/bugsnag.rb +69 -0
  4. data/lib/cronwatch/alerts/custom.rb +9 -2
  5. data/lib/cronwatch/alerts/datadog.rb +54 -0
  6. data/lib/cronwatch/alerts/email.rb +78 -0
  7. data/lib/cronwatch/alerts/honeybadger.rb +59 -0
  8. data/lib/cronwatch/alerts/mailgun.rb +36 -0
  9. data/lib/cronwatch/alerts/newrelic.rb +52 -0
  10. data/lib/cronwatch/alerts/postmark.rb +41 -0
  11. data/lib/cronwatch/alerts/provider.rb +159 -0
  12. data/lib/cronwatch/alerts/resend.rb +39 -0
  13. data/lib/cronwatch/alerts/rollbar.rb +53 -0
  14. data/lib/cronwatch/alerts/sendgrid.rb +41 -0
  15. data/lib/cronwatch/alerts/sentry.rb +94 -0
  16. data/lib/cronwatch/alerts/ses.rb +72 -0
  17. data/lib/cronwatch/alerts/sigv4.rb +84 -0
  18. data/lib/cronwatch/alerts/twilio.rb +206 -0
  19. data/lib/cronwatch/alerts/webhook.rb +7 -2
  20. data/lib/cronwatch/client.rb +473 -52
  21. data/lib/cronwatch/evaluate.rb +14 -1
  22. data/lib/cronwatch/format.rb +10 -4
  23. data/lib/cronwatch/http.rb +67 -6
  24. data/lib/cronwatch/job.rb +17 -0
  25. data/lib/cronwatch/pg_cron.rb +524 -0
  26. data/lib/cronwatch/run_handle.rb +203 -0
  27. data/lib/cronwatch/schedule.rb +28 -0
  28. data/lib/cronwatch/stores/active_record.rb +23 -0
  29. data/lib/cronwatch/stores/memory.rb +33 -10
  30. data/lib/cronwatch/types.rb +1 -0
  31. data/lib/cronwatch/version.rb +1 -1
  32. data/lib/cronwatch/web/app.rb +97 -20
  33. data/lib/cronwatch/web/html.rb +395 -140
  34. data/lib/cronwatch/web/icons.rb +19 -0
  35. data/lib/cronwatch/web/origin.rb +133 -0
  36. data/lib/cronwatch/web/pwa.rb +137 -0
  37. data/lib/cronwatch/web/timeline.rb +476 -0
  38. data/lib/cronwatch/web.rb +4 -0
  39. data/lib/cronwatch.rb +17 -1
  40. metadata +28 -3
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ # A run recorded by JobHandle#start or found by JobHandle#resume, to finish
5
+ # later, perhaps in another process. Lines and metrics wait in the handle
6
+ # until #flush or #finish. Store failures go to on_error; none of these
7
+ # methods raises for them.
8
+ #
9
+ # run = SYNC.start(id: event_id)
10
+ # # later, perhaps elsewhere
11
+ # run = SYNC.resume(event_id)
12
+ # run.log("sent 40 emails")
13
+ # run.finish # or run.fail(error)
14
+ class RunHandle
15
+ # Raised by the client's side of finish when the store failed part way
16
+ # and nothing was recorded (already reported): the handle stays active.
17
+ #
18
+ # @api private
19
+ class Retry < StandardError; end
20
+
21
+ # `started_at` is nil when a resumed run could not be read.
22
+ attr_reader :id, :job, :started_at
23
+
24
+ # Made by the client (Client#run_handle), not by apps. `finish` and
25
+ # `flush` are the client's side of each; `inactive` says why finish has
26
+ # nothing to do, or is nil.
27
+ #
28
+ # @api private
29
+ def initialize(id:, job:, started_at:, inactive:, finish:, flush:, ignored:)
30
+ @id = id
31
+ @job = job
32
+ @started_at = started_at
33
+ @inactive = inactive
34
+ @finish = finish
35
+ @flush = flush
36
+ @ignored = ignored
37
+ # @state guards the flags and which recorder is current; @turn keeps
38
+ # flush and finish in order, one at a time.
39
+ @state = Mutex.new
40
+ @turn = Mutex.new
41
+ @recorder = fresh_recorder
42
+ @finished = !inactive.nil?
43
+ @finish_called = false
44
+ # The first Output::CAP characters of every line flushed from this
45
+ # handle, unredacted, or nil before the first flush. The stored output
46
+ # keeps only the tail, so without it an expect rule at finish would
47
+ # miss a line logged early, which run would have seen.
48
+ @head = nil
49
+ end
50
+
51
+ # False once finished, and from the start for a resumed run that already
52
+ # finished or does not exist.
53
+ def active?
54
+ @state.synchronize { !@finished }
55
+ end
56
+
57
+ # Add a line of output, as JobContext#log does. Kept in the handle until flush or finish.
58
+ def log(*parts)
59
+ @state.synchronize { @recorder.context.log(*parts) }
60
+ end
61
+
62
+ # Report a number for this run. A later value for the same name replaces an earlier one.
63
+ def metric(name, value)
64
+ @state.synchronize { @recorder.context.metric(name, value) }
65
+ end
66
+
67
+ def metrics(values = nil, **more)
68
+ @state.synchronize { @recorder.context.metrics(values, **more) }
69
+ end
70
+
71
+ # Append the lines and metrics added so far to the stored run, which must
72
+ # still be running and belong to this job. Output is redacted as it is
73
+ # written. A read, change and write of the run's row, written only while
74
+ # it is still running: two processes appending to one run at the same
75
+ # moment can lose one's lines, but a flush never undoes a finish. When
76
+ # the write fails the lines stay here for finish. The first 16 KB of
77
+ # everything flushed stay in the handle, so an expect rule at finish
78
+ # sees an early line as run would.
79
+ def flush
80
+ @turn.synchronize do
81
+ next if @flush.nil? || @state.synchronize { @finished }
82
+
83
+ # Lines logged while this waits on the store go to a new recorder.
84
+ taken = @state.synchronize do
85
+ current = @recorder
86
+ @recorder = fresh_recorder
87
+ current
88
+ end
89
+ lines = taken.output
90
+ values = taken.metrics
91
+ next if lines.nil? && values.empty?
92
+
93
+ if @flush.call(lines, values)
94
+ keep_head(taken.expect_text)
95
+ else
96
+ put_back(taken)
97
+ end
98
+ end
99
+ nil
100
+ end
101
+
102
+ # Finish the run, judge it like any other and send what that produces.
103
+ #
104
+ # finish # ok
105
+ # finish(status: "ok") # ok
106
+ # finish(error: e) # failed, recorded like an error run caught
107
+ # finish("text") # like run's return value: the output when
108
+ # finish(result: "text") # nothing was logged, checked by expect
109
+ #
110
+ # Returns the run as recorded, or nil when nothing was: the run was
111
+ # already finished (here or elsewhere), was not found, or belongs to
112
+ # another job, which is reported to on_error. When several processes
113
+ # finish one run, only the one whose write lands judges it. A store that
114
+ # fails is reported, nothing is recorded, and the handle stays active so
115
+ # finish can be called again.
116
+ def finish(outcome = nil)
117
+ was_inactive = nil
118
+ again = @state.synchronize do
119
+ next true if @finish_called
120
+
121
+ @finish_called = true
122
+ was_inactive = @finished
123
+ @finished = true
124
+ false
125
+ end
126
+ if again
127
+ @ignored.call("was already finished by this handle")
128
+ return nil
129
+ end
130
+
131
+ @turn.synchronize do
132
+ if was_inactive
133
+ @ignored.call(@inactive)
134
+ next nil
135
+ end
136
+
137
+ begin
138
+ @finish.call(@state.synchronize { @recorder }, outcome, @head)
139
+ rescue Retry
140
+ reopen
141
+ nil
142
+ rescue Exception # rubocop:disable Lint/RescueException
143
+ # An Interrupt or Timeout mid-finish: the run may still be running,
144
+ # so the handle stays open (lines kept) for finish to be called again.
145
+ reopen
146
+ raise
147
+ end
148
+ end
149
+ end
150
+
151
+ # finish(error: error).
152
+ def fail(error)
153
+ finish({ error: error })
154
+ end
155
+
156
+ # [failed, result, error] from what finish was given. A Hash with an
157
+ # :error key is a failure; a String, or a Hash's :result, is the result.
158
+ #
159
+ # @api private
160
+ def self.read_outcome(outcome)
161
+ case outcome
162
+ when String then [false, outcome, nil]
163
+ when Hash
164
+ key = [:error, "error"].find { |k| outcome.key?(k) }
165
+ return [true, nil, outcome[key]] if key
166
+
167
+ [false, outcome.key?(:result) ? outcome[:result] : outcome["result"], nil]
168
+ else [false, nil, nil]
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ def fresh_recorder
175
+ RunRecorder.new(Run.new(id: @id, job: @job, started_at: @started_at || 0), nil)
176
+ end
177
+
178
+ # A flush that could not write: its lines go back ahead of any logged since.
179
+ def put_back(taken)
180
+ @state.synchronize do
181
+ later = @recorder
182
+ @recorder = fresh_recorder
183
+ [taken.expect_text, later.expect_text].each { |text| @recorder.log(text) unless text.nil? }
184
+ taken.metrics.merge(later.metrics).each { |name, value| @recorder.metric(name, value) }
185
+ end
186
+ end
187
+
188
+ # A finish that recorded nothing leaves the handle active, to be finished again.
189
+ def reopen
190
+ @state.synchronize do
191
+ @finish_called = false
192
+ @finished = false
193
+ end
194
+ end
195
+
196
+ # Keeps the start of what a flush wrote, up to the cap, for expect at finish.
197
+ def keep_head(text)
198
+ return if text.nil? || (!@head.nil? && JS.length16(@head) >= Output::CAP)
199
+
200
+ @head = JS.head16(@head.nil? || @head.empty? ? text : "#{@head}\n#{text}", Output::CAP)
201
+ end
202
+ end
203
+ end
@@ -93,6 +93,34 @@ module Cronwatch
93
93
  nil
94
94
  end
95
95
 
96
+ # Every fire of a cron strictly after `from` and at or before `to`,
97
+ # ascending, or nil when there are more than `limit`. Walks the fires in
98
+ # batches, as firesBetween in schedule.ts asks croner for them, and drops
99
+ # any that do not move forward (see fire_after).
100
+ def fires_between(parsed, from, to, limit)
101
+ raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
102
+
103
+ out = []
104
+ probe = from
105
+ last = from
106
+ 1000.times do
107
+ batch = next_runs(parsed, [limit + 1 - out.length, 24].min, probe)
108
+ return out if batch.empty?
109
+
110
+ batch.each do |t|
111
+ next if t <= last
112
+ return out if t > to
113
+
114
+ out << t
115
+ last = t
116
+ return nil if out.length > limit
117
+ end
118
+ finish = batch.last
119
+ probe = finish > probe ? finish : probe + 3_600_000
120
+ end
121
+ out
122
+ end
123
+
96
124
  # Up to `count` fires, each found from the one before, as croner's nextRuns.
97
125
  def next_runs(parsed, count, from)
98
126
  runs = []
@@ -216,6 +216,18 @@ module Cronwatch
216
216
  nil
217
217
  end
218
218
 
219
+ # update_run, only while the stored status is one of `from_statuses`,
220
+ # in one statement (sql.ts updateRunIfSql). Returns whether it wrote.
221
+ def update_run_if(run, from_statuses)
222
+ statuses = Array(from_statuses).map(&:to_s)
223
+ return false if statuses.empty?
224
+
225
+ binds = [run.status.to_s, run.finished_at, run.duration_ms, run.error, run.output,
226
+ JS.json(run.metrics || {}), run.id, *statuses]
227
+ changed = with_connection { |conn| conn.exec_update(update_run_if_sql(conn, statuses.length), NAME, binds) }
228
+ changed.to_i.positive?
229
+ end
230
+
219
231
  def get_run(id)
220
232
  row = read(:get_run, [id]).first
221
233
  row && row_to_run(row)
@@ -313,6 +325,17 @@ module Cronwatch
313
325
  (@statements[dialect] ||= statements(dialect)).fetch(key)
314
326
  end
315
327
 
328
+ # sql.ts `updateRunIfSql`: update_run's statement, only while the
329
+ # stored status is one of `count` statuses, bound value by value.
330
+ def update_run_if_sql(conn, count)
331
+ text = "UPDATE #{@prefix}runs SET status = ?, finished_at = ?, duration_ms = ?, error = ?, output = ?, metrics = ? " \
332
+ "WHERE id = ? AND status IN (#{Array.new(count, "?").join(", ")})"
333
+ return text unless self.class.dialect(conn) == :postgres
334
+
335
+ n = 0
336
+ text.gsub("?") { "$#{n += 1}" }
337
+ end
338
+
316
339
  # sql.ts `statements`: the same text, with `?` numbered for Postgres.
317
340
  def statements(dialect)
318
341
  p = @prefix
@@ -7,7 +7,8 @@ module Cronwatch
7
7
  # so a missed run cannot be noticed across one.
8
8
  #
9
9
  # Every store answers the same methods: init (optional), upsert_job,
10
- # get_job, list_jobs, delete_job, insert_run, update_run, get_run,
10
+ # get_job, list_jobs, delete_job, insert_run, update_run, update_run_if
11
+ # (optional: without it the client reads the run, then writes it), get_run,
11
12
  # list_runs, last_run, running_runs, get_state, set_state, compare_and_set_state
12
13
  # (optional: without it the client falls back to set_state), prune and close
13
14
  # (optional). They take and return the types in types.rb.
@@ -56,8 +57,11 @@ module Cronwatch
56
57
  nil
57
58
  end
58
59
 
60
+ # Like SQL's primary key: an id already recorded is refused, never overwritten.
59
61
  def insert_run(run)
60
62
  sync do
63
+ raise "run #{run.id} already exists" if @runs.key?(run.id)
64
+
61
65
  @runs[run.id] = clone(run, Run)
62
66
  @order[run.id] = (@seq += 1)
63
67
  end
@@ -70,19 +74,25 @@ module Cronwatch
70
74
  existing = @runs[run.id]
71
75
  next unless existing
72
76
 
73
- copy = clone(run, Run)
74
- @runs[run.id] = existing.dup.tap do |r|
75
- r.status = copy.status
76
- r.finished_at = copy.finished_at
77
- r.duration_ms = copy.duration_ms
78
- r.error = copy.error
79
- r.output = copy.output
80
- r.metrics = copy.metrics
81
- end
77
+ @runs[run.id] = finished_fields(existing, run)
82
78
  end
83
79
  nil
84
80
  end
85
81
 
82
+ # update_run, only while the stored run's status is one of
83
+ # `from_statuses`, in one step. Returns whether it wrote. What lets
84
+ # exactly one of several processes finishing a run evaluate it.
85
+ def update_run_if(run, from_statuses)
86
+ statuses = Array(from_statuses).map(&:to_sym)
87
+ sync do
88
+ existing = @runs[run.id]
89
+ next false unless existing && statuses.include?(existing.status)
90
+
91
+ @runs[run.id] = finished_fields(existing, run)
92
+ true
93
+ end
94
+ end
95
+
86
96
  def get_run(id)
87
97
  sync { (run = @runs[id]) && clone(run, Run) }
88
98
  end
@@ -149,6 +159,19 @@ module Cronwatch
149
159
 
150
160
  private
151
161
 
162
+ # `existing` with the fields an update writes taken from `run`.
163
+ def finished_fields(existing, run)
164
+ copy = clone(run, Run)
165
+ existing.dup.tap do |r|
166
+ r.status = copy.status
167
+ r.finished_at = copy.finished_at
168
+ r.duration_ms = copy.duration_ms
169
+ r.error = copy.error
170
+ r.output = copy.output
171
+ r.metrics = copy.metrics
172
+ end
173
+ end
174
+
152
175
  def sync(&block)
153
176
  @lock.synchronize(&block)
154
177
  end
@@ -226,6 +226,7 @@ module Cronwatch
226
226
  run = Naming.fetch(hash, "run")
227
227
  details = Naming.from_json_value(Naming.fetch(hash, "details") || {})
228
228
  details[:after] = details[:after].map(&:to_sym) if details[:after].is_a?(Array)
229
+ details[:reason] = details[:reason].to_sym if details[:reason].is_a?(String)
229
230
  alert = new(
230
231
  type: Naming.fetch(hash, "type")&.to_sym,
231
232
  run: run && Run.from_h(run),
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.3.1"
5
+ VERSION = "0.5.0"
6
6
  end
@@ -3,6 +3,7 @@
3
3
  require "base64"
4
4
  require "digest"
5
5
  require "securerandom"
6
+ require "uri"
6
7
 
7
8
  module Cronwatch
8
9
  # The dashboard and the small JSON API, as a Rack app: the SDK's routes
@@ -26,16 +27,37 @@ module Cronwatch
26
27
  # client's cron_secret as a bearer, for a platform cron.
27
28
  # base_path: where the app is mounted, so links resolve. Defaults to the
28
29
  # mount point (SCRIPT_NAME), which is right under Rails' `mount`.
30
+ # origin: the public origin the dashboard is served from, such as
31
+ # "https://app.example.com". By default the request's own origin
32
+ # is used, as Rack reads it (Rack::Request#base_url, which follows
33
+ # X-Forwarded-Proto and X-Forwarded-Host the way Rails does). Set
34
+ # this to pin it, for an app behind a proxy that does not set
35
+ # those headers or one that should not trust them. Used in place
36
+ # of the request's origin for the cross-site check on writes, the
37
+ # sign-in cookie's Secure flag, the Referer the redirect back after
38
+ # a form follows, and the development sign-in line. Read as
39
+ # `new URL(value).origin` reads it (Web::Origin) and normalised to
40
+ # scheme://host[:port]; anything that is not an absolute http or
41
+ # https URL, or has a port outside 1 to 65535, raises
42
+ # ArgumentError here. "" counts as unset. Behind more than one
43
+ # proxy set it: Rack takes the last of several forwarded values.
29
44
  class Web
30
45
  # Tells "token not given" (read CRONWATCH_TOKEN) from "token: nil" (open on purpose).
31
46
  UNSET = Object.new.freeze
32
47
  COOKIE = "cronwatch_token"
33
48
  DEFAULT_RUNS = 20
34
49
  MAX_RUNS = 500
50
+ # Runs per job the board reads in one go: the table's sparkline, and most jobs' lanes.
51
+ BOARD_PAGE_RUNS = 20
35
52
  COOKIE_MAX_AGE = 60 * 60 * 24 * 30
36
53
 
37
- CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; " \
38
- "frame-ancestors 'none'; base-uri 'none'"
54
+ # 'self' only for what the app shell needs: app.js (which registers the
55
+ # service worker and nothing else), the manifest, the worker and the icons.
56
+ # No inline script, and the pages work without any.
57
+ CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; " \
58
+ "manifest-src 'self'; worker-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
59
+ # For the SVG icons, should one be opened on its own.
60
+ ASSET_CSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
39
61
  # same-origin rather than no-referrer: under no-referrer browsers send
40
62
  # `Origin: null` on form posts, which the CSRF check would refuse, and the
41
63
  # forms redirect back to the page named by the same-origin Referer.
@@ -46,8 +68,9 @@ module Cronwatch
46
68
  DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
47
69
  RADIX = { "x" => 16, "o" => 8, "b" => 2 }.freeze
48
70
 
49
- def initialize(client = nil, token: UNSET, base_path: nil)
71
+ def initialize(client = nil, token: UNSET, base_path: nil, origin: nil)
50
72
  @client = client
73
+ @origin = Web.configured_origin(origin)
51
74
  @opted_out = token.nil?
52
75
  given = token.equal?(UNSET) ? nil : token
53
76
  @token = @opted_out ? nil : [given, ENV.fetch("CRONWATCH_TOKEN", nil)].map(&:to_s).find { |t| !t.empty? }
@@ -77,6 +100,14 @@ module Cronwatch
77
100
  "Sign in: #{origin}#{base}/?token=#{token}"
78
101
  end
79
102
 
103
+ # The `origin` option, normalised to scheme://host[:port], or nil when
104
+ # unset. Raises on a value that is not an http or https origin, so a typo
105
+ # fails at startup.
106
+ # Read as the SDK's `new URL(value).origin` reads it (Web::Origin).
107
+ def self.configured_origin(value)
108
+ Origin.parse(value)
109
+ end
110
+
80
111
  # The client given, or Cronwatch.client when none was.
81
112
  def client
82
113
  @client || Cronwatch.client
@@ -112,6 +143,18 @@ module Cronwatch
112
143
 
113
144
  announce(request, base) if @generated && !@announced
114
145
 
146
+ # The app shell: the manifest, icons, service worker, app.js and the
147
+ # offline page. Served to anyone, since a browser fetches some of it
148
+ # without cookies and none of it says anything about the jobs.
149
+ if %w[GET HEAD].include?(method)
150
+ if path == "/offline"
151
+ return html(HTML.message_page("You are offline", "CronWatch shows live data from your app, so it needs a connection.", base), 200, "no-cache")
152
+ end
153
+
154
+ asset = PWA.asset(path, base)
155
+ return shell(asset, base) if asset
156
+ end
157
+
115
158
  # No token outside development: fail closed.
116
159
  if !@token && !@opted_out
117
160
  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)
@@ -136,13 +179,13 @@ module Cronwatch
136
179
  end
137
180
  unless cron_secret_ok || token_ok
138
181
  if @generated
139
- 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), 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)
182
+ 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)
140
183
  end
141
- 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), 401) : api({ ok: false, error: "Unauthorized" }, 401)
184
+ 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)
142
185
  end
143
186
  unless query.nil?
144
187
  # Move the token from the URL into a cookie so it is not in history or logs.
145
- secure = request.https? ? "; Secure" : ""
188
+ secure = public_origin(request).start_with?("https:") ? "; Secure" : ""
146
189
  return redirect(request.pathname + request.search_without("token"),
147
190
  "set-cookie" => "#{COOKIE}=#{cookie_value(@token)}; Path=#{base.empty? ? "/" : base}; HttpOnly; SameSite=Lax; Max-Age=#{COOKIE_MAX_AGE}#{secure}")
148
191
  end
@@ -155,15 +198,20 @@ module Cronwatch
155
198
 
156
199
  # HTML
157
200
  if method == "GET" && path == "/"
158
- entries = cw.jobs_with_runs(20)
201
+ entries = cw.jobs_with_runs(BOARD_PAGE_RUNS)
202
+ now = cw.now
159
203
  runs_by_job = entries.to_h { |entry| [entry.job.name, entry.runs] }
160
- return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, cw.now, base, nil))
204
+ return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, now, base, nil, board_lanes(cw, entries, now)))
161
205
  end
162
206
  if method == "GET" && parts[0] == "jobs" && parts.length == 2
163
207
  job = cw.job_summary(parts[1])
164
208
  return html(HTML.message_page("No such job", "#{parts[1]} is not in the store.", base), 404) unless job
165
209
 
166
- return html(HTML.job_page(job, cw.runs(job.name, 50), cw.now, base))
210
+ now = cw.now
211
+ # Enough runs to draw the job's week; the page lists the newest fifty.
212
+ limit = Timeline.week_runs_limit(job, now)
213
+ runs = cw.runs(job.name, limit)
214
+ return html(HTML.job_page(job, runs, now, base, runs.length < limit))
167
215
  end
168
216
  if method == "POST" && path == "/check"
169
217
  cw.check
@@ -246,6 +294,22 @@ module Cronwatch
246
294
  api({ ok: false, error: "Not found" }, 404)
247
295
  end
248
296
 
297
+ # The board's timeline lanes, the first Timeline::BOARD_LANES jobs. The
298
+ # runs already read for the table usually cover the last day; only a job
299
+ # whose twenty newest runs all fall inside it (one that runs more often
300
+ # than every hour or so) is read again, deeper.
301
+ def board_lanes(cw, entries, now)
302
+ from = now - Timeline::BOARD_BEHIND_MS
303
+ entries.first(Timeline::BOARD_LANES).map do |entry|
304
+ runs = entry.runs
305
+ short = runs.length >= BOARD_PAGE_RUNS && runs.last.started_at > from
306
+ next Timeline::LaneInput.new(job: entry.job, runs: runs, complete: true) unless short
307
+
308
+ deeper = cw.runs(entry.job.name, Timeline::BOARD_RUNS)
309
+ Timeline::LaneInput.new(job: entry.job, runs: deeper, complete: deeper.length < Timeline::BOARD_RUNS)
310
+ end
311
+ end
312
+
249
313
  # Prints the development sign-in link, once per app.
250
314
  def announce(request, base)
251
315
  first = @announce_lock.synchronize do
@@ -255,7 +319,7 @@ module Cronwatch
255
319
  end
256
320
  return unless first
257
321
 
258
- $stdout.puts(Web.development_sign_in_line(request.origin, base, @token))
322
+ $stdout.puts(Web.development_sign_in_line(public_origin(request), base, @token))
259
323
  $stdout.flush
260
324
  end
261
325
 
@@ -295,7 +359,7 @@ module Cronwatch
295
359
  # a page cannot forge either. Non-browser clients send neither.
296
360
  def cross_site?(request)
297
361
  origin = request.header("origin")
298
- return true if !origin.nil? && origin != request.origin
362
+ return true if !origin.nil? && origin != public_origin(request)
299
363
 
300
364
  site = request.header("sec-fetch-site")
301
365
  !site.nil? && site != "same-origin" && site != "none"
@@ -378,7 +442,12 @@ module Cronwatch
378
442
 
379
443
  def redirect_back(request, base)
380
444
  referer = request.header("referer") || ""
381
- redirect(referer.start_with?("#{request.origin}/") ? referer : "#{base}/")
445
+ redirect(referer.start_with?("#{public_origin(request)}/") ? referer : "#{base}/")
446
+ end
447
+
448
+ # The origin a browser sees: the `origin` option when set, otherwise the request's own.
449
+ def public_origin(request)
450
+ @origin || request.origin
382
451
  end
383
452
 
384
453
  def api(body, status = 200, headers = {})
@@ -393,9 +462,19 @@ module Cronwatch
393
462
  [303, { "location" => location, "cache-control" => "no-store", **SECURITY_HEADERS, **headers }, []]
394
463
  end
395
464
 
396
- def html(body, status = 200)
465
+ # An app shell file. The worker may be scoped to the base (it is served
466
+ # from there anyway); the SVGs get a CSP of their own.
467
+ def shell(asset, base)
468
+ headers = { "content-type" => asset.type, "cache-control" => asset.cache, **SECURITY_HEADERS }
469
+ headers["content-security-policy"] = ASSET_CSP if asset.type == "image/svg+xml"
470
+ headers["service-worker-allowed"] = "#{base}/" if asset.worker
471
+ headers["content-length"] = asset.body.bytesize.to_s
472
+ [200, headers, [asset.body]]
473
+ end
474
+
475
+ def html(body, status = 200, cache = "no-store")
397
476
  [status, {
398
- "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
477
+ "content-type" => "text/html; charset=utf-8", "cache-control" => cache,
399
478
  "content-security-policy" => CSP, "x-frame-options" => "DENY", "content-length" => body.bytesize.to_s,
400
479
  **SECURITY_HEADERS,
401
480
  }, [body]]
@@ -426,13 +505,11 @@ module Cronwatch
426
505
  @env[key]
427
506
  end
428
507
 
429
- # scheme://host[:port], the page's origin.
508
+ # scheme://host[:port], the page's origin, lowercased as a browser
509
+ # writes it in Origin and Referer (Rack's base_url keeps the case of
510
+ # the Host or X-Forwarded-Host it read).
430
511
  def origin
431
- @rack.base_url
432
- end
433
-
434
- def https?
435
- @rack.scheme == "https"
512
+ @rack.base_url.downcase
436
513
  end
437
514
 
438
515
  # URLSearchParams#get: the first value, or nil.