cronwatch 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/cronwatch/alerts/webhook.rb +1 -1
- data/lib/cronwatch/http.rb +25 -1
- data/lib/cronwatch/schedule.rb +28 -0
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +59 -9
- data/lib/cronwatch/web/html.rb +395 -140
- data/lib/cronwatch/web/icons.rb +19 -0
- data/lib/cronwatch/web/pwa.rb +137 -0
- data/lib/cronwatch/web/timeline.rb +476 -0
- data/lib/cronwatch/web.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce9304826bfc7f8e79615453ee7b5c25503d3dbd7d8b803c229152b3e3c93844
|
|
4
|
+
data.tar.gz: ef0e084ae833b0e57f2dd997fa500ca43c6dfcddd65730265e166d7c227f00d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 23c832ba7e7ff1e34ca6930f3b9eb7785f6175db5bfec6ef731495f695a16454ea63e0fb474dd305771f33eed145d3ab41fa762ea40baafb8187254c8b1099d5
|
|
7
|
+
data.tar.gz: bdaac367aac5c069fd6c3c3c38117e40c1afa7f073d5cb95c2c56c0a97efa58a0fabe86b1640905d2d01ec44cef44ae78aaff45d03cc4fe9dd9e381295e44d57
|
data/lib/cronwatch/http.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Cronwatch
|
|
|
34
34
|
# past it while the body is still arriving, returns the answer with an
|
|
35
35
|
# empty body, as the SDK's channels treat a body they could not read.
|
|
36
36
|
def post(url, body, headers)
|
|
37
|
-
uri =
|
|
37
|
+
uri = HTTP.postable(url)
|
|
38
38
|
deadline = HTTP.monotonic + @timeout
|
|
39
39
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
40
40
|
headers.each { |k, v| request[k] = HTTP.trim_header(v) }
|
|
@@ -96,6 +96,30 @@ module Cronwatch
|
|
|
96
96
|
[deadline - monotonic, 0.001].max
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# A URL as fetch reads it: the spaces and control characters around it,
|
|
100
|
+
# and any tab or line break inside it, dropped (a pasted webhook URL often
|
|
101
|
+
# ends in a newline).
|
|
102
|
+
def clean_url(url)
|
|
103
|
+
url.to_s.gsub(/\A[\u0000-\u0020]+|[\u0000-\u0020]+\z/, "").delete("\t\n\r")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The URL parsed, once it is one a channel can post to. Refused without
|
|
107
|
+
# quoting it: URI's own errors quote the whole URL, and a webhook URL's
|
|
108
|
+
# path is its credential.
|
|
109
|
+
def postable(url)
|
|
110
|
+
uri = begin
|
|
111
|
+
URI(clean_url(url))
|
|
112
|
+
rescue URI::Error, ArgumentError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
scheme = uri&.scheme&.downcase
|
|
116
|
+
unless %w[http https].include?(scheme) && uri.host && !uri.host.empty?
|
|
117
|
+
shown = uri&.scheme && uri.host ? "#{scheme}:" : "this URL"
|
|
118
|
+
raise ArgumentError, "only http and https URLs can be posted to, not #{shown}"
|
|
119
|
+
end
|
|
120
|
+
uri
|
|
121
|
+
end
|
|
122
|
+
|
|
99
123
|
# A header value without the spaces, tabs and line breaks around it, as fetch sends it.
|
|
100
124
|
def trim_header(value)
|
|
101
125
|
value.to_s.gsub(/\A[ \t\r\n]+|[ \t\r\n]+\z/, "")
|
data/lib/cronwatch/schedule.rb
CHANGED
|
@@ -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 = []
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -47,10 +47,17 @@ module Cronwatch
|
|
|
47
47
|
COOKIE = "cronwatch_token"
|
|
48
48
|
DEFAULT_RUNS = 20
|
|
49
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
|
|
50
52
|
COOKIE_MAX_AGE = 60 * 60 * 24 * 30
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
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'"
|
|
54
61
|
# same-origin rather than no-referrer: under no-referrer browsers send
|
|
55
62
|
# `Origin: null` on form posts, which the CSRF check would refuse, and the
|
|
56
63
|
# forms redirect back to the page named by the same-origin Referer.
|
|
@@ -136,6 +143,18 @@ module Cronwatch
|
|
|
136
143
|
|
|
137
144
|
announce(request, base) if @generated && !@announced
|
|
138
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
|
+
|
|
139
158
|
# No token outside development: fail closed.
|
|
140
159
|
if !@token && !@opted_out
|
|
141
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)
|
|
@@ -160,9 +179,9 @@ module Cronwatch
|
|
|
160
179
|
end
|
|
161
180
|
unless cron_secret_ok || token_ok
|
|
162
181
|
if @generated
|
|
163
|
-
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)
|
|
164
183
|
end
|
|
165
|
-
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)
|
|
166
185
|
end
|
|
167
186
|
unless query.nil?
|
|
168
187
|
# Move the token from the URL into a cookie so it is not in history or logs.
|
|
@@ -179,15 +198,20 @@ module Cronwatch
|
|
|
179
198
|
|
|
180
199
|
# HTML
|
|
181
200
|
if method == "GET" && path == "/"
|
|
182
|
-
entries = cw.jobs_with_runs(
|
|
201
|
+
entries = cw.jobs_with_runs(BOARD_PAGE_RUNS)
|
|
202
|
+
now = cw.now
|
|
183
203
|
runs_by_job = entries.to_h { |entry| [entry.job.name, entry.runs] }
|
|
184
|
-
return html(HTML.dashboard_page(entries.map(&:job), runs_by_job,
|
|
204
|
+
return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, now, base, nil, board_lanes(cw, entries, now)))
|
|
185
205
|
end
|
|
186
206
|
if method == "GET" && parts[0] == "jobs" && parts.length == 2
|
|
187
207
|
job = cw.job_summary(parts[1])
|
|
188
208
|
return html(HTML.message_page("No such job", "#{parts[1]} is not in the store.", base), 404) unless job
|
|
189
209
|
|
|
190
|
-
|
|
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))
|
|
191
215
|
end
|
|
192
216
|
if method == "POST" && path == "/check"
|
|
193
217
|
cw.check
|
|
@@ -270,6 +294,22 @@ module Cronwatch
|
|
|
270
294
|
api({ ok: false, error: "Not found" }, 404)
|
|
271
295
|
end
|
|
272
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
|
+
|
|
273
313
|
# Prints the development sign-in link, once per app.
|
|
274
314
|
def announce(request, base)
|
|
275
315
|
first = @announce_lock.synchronize do
|
|
@@ -422,9 +462,19 @@ module Cronwatch
|
|
|
422
462
|
[303, { "location" => location, "cache-control" => "no-store", **SECURITY_HEADERS, **headers }, []]
|
|
423
463
|
end
|
|
424
464
|
|
|
425
|
-
|
|
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")
|
|
426
476
|
[status, {
|
|
427
|
-
"content-type" => "text/html; charset=utf-8", "cache-control" =>
|
|
477
|
+
"content-type" => "text/html; charset=utf-8", "cache-control" => cache,
|
|
428
478
|
"content-security-policy" => CSP, "x-frame-options" => "DENY", "content-length" => body.bytesize.to_s,
|
|
429
479
|
**SECURITY_HEADERS,
|
|
430
480
|
}, [body]]
|