runsheets 0.0.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 +7 -0
- data/CHANGELOG.md +98 -0
- data/LICENSE.txt +21 -0
- data/README.md +277 -0
- data/bin/runsheets +12 -0
- data/examples/about-the-examples.md +40 -0
- data/examples/directory_based_runbooks/hello/rollback.md +11 -0
- data/examples/directory_based_runbooks/hello/runbook.md +40 -0
- data/examples/directory_based_runbooks/hello/steps/010-say-hello.md +33 -0
- data/examples/directory_based_runbooks/hello/steps/020-inspect-ruby.md +28 -0
- data/examples/directory_based_runbooks/hello/steps/030-take-a-breath.md +11 -0
- data/examples/directory_based_runbooks/hello/steps/035-keep-a-clock-running.md +21 -0
- data/examples/directory_based_runbooks/hello/steps/040-exercise-failure.md +26 -0
- data/examples/directory_based_runbooks/hello/steps/045-check-the-greeting.md +18 -0
- data/examples/directory_based_runbooks/hello/verify.md +18 -0
- data/examples/directory_based_runbooks/staging-teardown/rollback.md +28 -0
- data/examples/directory_based_runbooks/staging-teardown/runbook.md +57 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/010-confirm-nothing-to-preserve.md +18 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/020-snapshot-the-database.md +47 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/030-stop-the-pipeline.md +32 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/040-drain-the-ecs-services.md +35 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/050-record-the-final-row-counts.md +41 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/060-delete-the-stack.md +56 -0
- data/examples/directory_based_runbooks/staging-teardown/verify.md +40 -0
- data/examples/single_md_file_runbooks/db-maintenance.md +163 -0
- data/examples/single_md_file_runbooks/disk-space-triage.md +90 -0
- data/examples/single_md_file_runbooks/disk-usage-glossary.md +48 -0
- data/examples/single_md_file_runbooks/policies/cleanup-policy.md +33 -0
- data/examples/single_md_file_runbooks/policies/images/triage-flow.svg +44 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/.loki +17 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/asgard-tasks.md +135 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/checks.loki +27 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/greet.loki +6 -0
- data/lib/runsheets/assets.rb +780 -0
- data/lib/runsheets/block.rb +118 -0
- data/lib/runsheets/cli.rb +424 -0
- data/lib/runsheets/config/defaults.yml +44 -0
- data/lib/runsheets/config.rb +139 -0
- data/lib/runsheets/diff.rb +67 -0
- data/lib/runsheets/execution.rb +144 -0
- data/lib/runsheets/executor.rb +157 -0
- data/lib/runsheets/expectation.rb +101 -0
- data/lib/runsheets/fences.rb +172 -0
- data/lib/runsheets/front_matter.rb +29 -0
- data/lib/runsheets/library.rb +268 -0
- data/lib/runsheets/pages/chooser.rb +408 -0
- data/lib/runsheets/pages/search.rb +119 -0
- data/lib/runsheets/pages/tree.rb +88 -0
- data/lib/runsheets/pages.rb +947 -0
- data/lib/runsheets/redactor.rb +88 -0
- data/lib/runsheets/renderer.rb +229 -0
- data/lib/runsheets/run.rb +330 -0
- data/lib/runsheets/run_record.rb +371 -0
- data/lib/runsheets/runbook.rb +301 -0
- data/lib/runsheets/search.rb +129 -0
- data/lib/runsheets/session.rb +307 -0
- data/lib/runsheets/session_log.rb +114 -0
- data/lib/runsheets/single_file.rb +93 -0
- data/lib/runsheets/step.rb +91 -0
- data/lib/runsheets/version.rb +5 -0
- data/lib/runsheets/web.rb +537 -0
- data/lib/runsheets.rb +90 -0
- data/sig/runsheets.rbs +4 -0
- metadata +226 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/utils"
|
|
4
|
+
|
|
5
|
+
module Runsheets
|
|
6
|
+
# Pure functions that build the HTML pages from a Session. No HTTP here, so
|
|
7
|
+
# every page can be rendered and inspected in a test.
|
|
8
|
+
#
|
|
9
|
+
# Every page function takes +nonce:+, the Content-Security-Policy nonce the
|
|
10
|
+
# web layer put in the response header; the page's one inline script and
|
|
11
|
+
# stylesheet carry it, and nothing else on the page can run script.
|
|
12
|
+
module Pages
|
|
13
|
+
APP_NAME = "runsheets"
|
|
14
|
+
APP_FULL = "executable runbooks"
|
|
15
|
+
|
|
16
|
+
ICONS = {
|
|
17
|
+
home: '<svg class="icon" viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5"/><path d="M5.5 10v9.5h4.5V14h4v5.5h4.5V10"/></svg>',
|
|
18
|
+
prev: '<svg class="icon" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="m11 18-6-6 6-6"/></svg>',
|
|
19
|
+
next: '<svg class="icon" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>',
|
|
20
|
+
menu: '<svg class="icon" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg>',
|
|
21
|
+
book: '<svg class="icon" viewBox="0 0 24 24"><path d="M4 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4z"/><path d="M20 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>',
|
|
22
|
+
alert: '<svg class="icon" viewBox="0 0 24 24"><path d="M12 3 2.5 20h19z"/><path d="M12 10v5"/><path d="M12 18h.01"/></svg>',
|
|
23
|
+
log: '<svg class="icon" viewBox="0 0 24 24"><path d="M5 4h14v16H5z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg>',
|
|
24
|
+
check: '<svg class="icon" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9"/><path d="m8.5 12 2.5 2.5L21 4.5"/></svg>',
|
|
25
|
+
folder: '<svg class="icon" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
|
26
|
+
file: '<svg class="icon" viewBox="0 0 24 24"><path d="M6 3h8l5 5v13H6z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/></svg>',
|
|
27
|
+
search: '<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/></svg>',
|
|
28
|
+
chevron: '<svg class="icon" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>'
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# An inline icon, so no page asks for /favicon.ico.
|
|
32
|
+
FAVICON = %(<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%235ab0ff'/%3E%3Cpath d='M4 4h8M4 8h8M4 12h5' stroke='%230e1117' stroke-width='2'/%3E%3C/svg%3E">)
|
|
33
|
+
|
|
34
|
+
STATUS_MARKS = { "done" => "✓", "skipped" => "↷", "failed" => "✗", "ran" => "•", "pending" => "·" }.freeze
|
|
35
|
+
|
|
36
|
+
def self.h(value) = Renderer.h(value)
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Layout
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def self.layout(session, title:, body:, kind:, step: nil, nonce: nil, query: "")
|
|
43
|
+
runbook = session.runbook
|
|
44
|
+
nonce_attr = nonce ? %( nonce="#{h nonce}") : ""
|
|
45
|
+
<<~HTML
|
|
46
|
+
<!DOCTYPE html>
|
|
47
|
+
<html lang="en">
|
|
48
|
+
<head>
|
|
49
|
+
<meta charset="utf-8">
|
|
50
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
51
|
+
<meta name="rs-token" content="#{h session.token}">
|
|
52
|
+
<meta name="rs-mark" content="#{Renderer::MARK}">
|
|
53
|
+
#{FAVICON}
|
|
54
|
+
<meta name="rs-runbook" content="#{h runbook.slug}">
|
|
55
|
+
<title>#{h title} · #{h runbook.title}</title>
|
|
56
|
+
<style#{nonce_attr}>#{Assets.stylesheet}</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body class="kind-#{kind}">
|
|
59
|
+
#{header(session, step:, kind:, query:)}
|
|
60
|
+
<div class="rs-shell">
|
|
61
|
+
#{sidebar(session, step:, kind:)}
|
|
62
|
+
<main class="rs-main">
|
|
63
|
+
#{body}
|
|
64
|
+
</main>
|
|
65
|
+
</div>
|
|
66
|
+
<footer class="rs-footer">
|
|
67
|
+
<span>#{h APP_NAME} · #{h APP_FULL} · #{h runbook.dir}</span>
|
|
68
|
+
<span class="keys"><kbd>f</kbd> search <kbd>n</kbd> note <kbd>h</kbd> home <kbd>←</kbd><kbd>→</kbd> prev/next step <kbd>s</kbd> sidebar</span>
|
|
69
|
+
</footer>
|
|
70
|
+
<script#{nonce_attr}>#{Assets.javascript}</script>
|
|
71
|
+
</body>
|
|
72
|
+
</html>
|
|
73
|
+
HTML
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The search box in every header: a plain GET form, so it works
|
|
77
|
+
# without script. [f] focuses it.
|
|
78
|
+
def self.search_box(query = "")
|
|
79
|
+
"<form class=\"rs-search\" action=\"/search\" method=\"get\" role=\"search\">#{ICONS[:search]}" \
|
|
80
|
+
"<input type=\"search\" name=\"q\" id=\"rs-search\" value=\"#{h query}\" placeholder=\"Search runbooks\" " \
|
|
81
|
+
"aria-label=\"Search runbooks\" autocomplete=\"off\" spellcheck=\"false\"><kbd>f</kbd></form>"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.nav_button(label, href, icon, key: nil, title: nil)
|
|
85
|
+
tip = h([title || label, key && "[#{key}]"].compact.join(" "))
|
|
86
|
+
inner = "#{ICONS[icon]}<span>#{h label}</span>"
|
|
87
|
+
if href
|
|
88
|
+
"<a class=\"nav-btn\" href=\"#{h href}\" title=\"#{tip}\" data-key=\"#{h key}\">#{inner}</a>"
|
|
89
|
+
else
|
|
90
|
+
"<span class=\"nav-btn disabled\" title=\"#{tip}\">#{inner}</span>"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.step_href(step) = "/steps/#{Rack::Utils.escape_path(step.slug)}"
|
|
95
|
+
|
|
96
|
+
def self.header(session, step:, kind:, query: "")
|
|
97
|
+
runbook = session.runbook
|
|
98
|
+
prev, nxt = step ? runbook.neighbors(step) : [nil, nil]
|
|
99
|
+
crumbs = library_crumbs(session)
|
|
100
|
+
crumbs << (kind == :landing ? "<span class=\"current\">#{h runbook.title}</span>" : "<a href=\"/\">#{h runbook.title}</a>")
|
|
101
|
+
crumbs << "<span class=\"current\">#{h step.title}</span>" if step
|
|
102
|
+
crumbs << '<span class="current">Runsheet</span>' if kind == :run
|
|
103
|
+
crumbs << '<span class="current">Checks</span>' if kind == :verify
|
|
104
|
+
crumbs << '<span class="current">Session</span>' if kind == :session
|
|
105
|
+
|
|
106
|
+
<<~HTML
|
|
107
|
+
<header class="rs-header">
|
|
108
|
+
<div class="brand">
|
|
109
|
+
<button class="icon-btn sidebar-toggle" type="button" title="Toggle sidebar [s]" aria-label="Toggle sidebar">#{ICONS[:menu]}</button>
|
|
110
|
+
<a class="home" href="/" title="Runbook home [h]" data-key="h">
|
|
111
|
+
#{ICONS[:home]}
|
|
112
|
+
<span class="brand-name">#{h APP_NAME}</span>
|
|
113
|
+
<span class="brand-tag">#{h runbook.slug}</span>
|
|
114
|
+
</a>
|
|
115
|
+
</div>
|
|
116
|
+
<nav class="crumbs" aria-label="Breadcrumb">#{crumbs.join('<span class="sep">/</span>')}</nav>
|
|
117
|
+
<nav class="actions" aria-label="Step navigation">
|
|
118
|
+
#{search_box(query)}
|
|
119
|
+
#{nav_button('Runbooks', '/library', :book, key: 'r', title: 'Choose another runbook') if session.library}
|
|
120
|
+
#{nav_button('Prev', prev && step_href(prev), :prev, key: '←', title: prev&.title)}
|
|
121
|
+
#{nav_button('Next', nxt && step_href(nxt), :next, key: '→', title: nxt&.title)}
|
|
122
|
+
#{session_pill(session)}
|
|
123
|
+
</nav>
|
|
124
|
+
</header>
|
|
125
|
+
HTML
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The session's elapsed time; the page script keeps it current.
|
|
129
|
+
def self.elapsed_span(session)
|
|
130
|
+
since = session.ended? ? "" : %( data-since="#{h session.started_at.iso8601}")
|
|
131
|
+
"<span class=\"elapsed\"#{since}>#{h duration_text(session.elapsed)}</span>"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The session in the header: who, for how long, how many runbooks,
|
|
135
|
+
# linking to the session page.
|
|
136
|
+
def self.session_pill(session)
|
|
137
|
+
runs = session.runs.size
|
|
138
|
+
title = "Session #{session.id}: #{session.why}"
|
|
139
|
+
"<a class=\"run-pill active\" href=\"/session\" title=\"#{h title}\"><span class=\"dot\"></span>" \
|
|
140
|
+
"#{h session.engineer} · #{elapsed_span(session)}#{" · #{runs} runbook#{'s' unless runs == 1}" if runs.positive?}</a>"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# When the runbook was opened from a library, the crumbs start with the
|
|
144
|
+
# library and the folders above the runbook, each a link into the tree.
|
|
145
|
+
def self.library_crumbs(session)
|
|
146
|
+
library = session.library
|
|
147
|
+
return [] unless library
|
|
148
|
+
|
|
149
|
+
['<a href="/library" title="Runbooks [r]">Runbooks</a>',
|
|
150
|
+
*library.ancestors(session.runbook.slug).map { "<a href=\"#{h Chooser.href(it.slug)}\">#{h it.name}</a>" }]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Sidebar: the step list with run status marks, extra documents, the
|
|
154
|
+
# rollback panel, and an outline for the current page.
|
|
155
|
+
def self.sidebar(session, step:, kind:)
|
|
156
|
+
extras = also_items(session, step, kind)
|
|
157
|
+
<<~HTML
|
|
158
|
+
<aside class="rs-sidebar">
|
|
159
|
+
#{running_panel(session)}
|
|
160
|
+
<section>
|
|
161
|
+
<h2>Steps</h2>
|
|
162
|
+
<ol class="steps">#{step_items(session, step).join}</ol>
|
|
163
|
+
</section>
|
|
164
|
+
#{"<section><h2>Also</h2><ul>#{extras.join}</ul></section>" unless extras.empty?}
|
|
165
|
+
#{rollback_panel(session, step, kind)}
|
|
166
|
+
<section class="outline"><h2>On this page</h2><ol id="outline"></ol></section>
|
|
167
|
+
</aside>
|
|
168
|
+
HTML
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# One sidebar entry per numbered step, with its status mark.
|
|
172
|
+
def self.step_items(session, current)
|
|
173
|
+
session.runbook.steps.map do |s|
|
|
174
|
+
status = step_mark(session, s)
|
|
175
|
+
<<~LI
|
|
176
|
+
<li#{' class="active"' if s == current}><a href="#{h step_href(s)}" title="#{h s.title}">
|
|
177
|
+
<span class="num">#{h(s.number || s.position)}</span>
|
|
178
|
+
<span class="name">#{h s.title}</span>
|
|
179
|
+
<span class="mark #{status}" title="#{status}">#{STATUS_MARKS[status]}</span>
|
|
180
|
+
</a></li>
|
|
181
|
+
LI
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The "Also" entries: checks, extra documents, the active runsheet.
|
|
186
|
+
# Rollback is left out where the sidebar already shows its panel.
|
|
187
|
+
def self.also_items(session, current, kind)
|
|
188
|
+
runbook = session.runbook
|
|
189
|
+
extras = runbook.extras.values
|
|
190
|
+
extras -= [runbook.rollback] if rollback_panel?(session, current, kind)
|
|
191
|
+
items = extras.map { also_item(step_href(it), ICONS[:book], it.title, active: it == current) }
|
|
192
|
+
items.unshift(also_item("/verify", ICONS[:check], "Checks", active: kind == :verify)) if runbook.verify_documents.any?
|
|
193
|
+
items << also_item("/run", ICONS[:log], "Runsheet") if session.active?
|
|
194
|
+
items << also_item("/session", ICONS[:file], "Session & notes", active: kind == :session)
|
|
195
|
+
items
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def self.also_item(href, icon, name, active: false)
|
|
199
|
+
"<li#{' class="active"' if active}><a href=\"#{h href}\"><span class=\"num\">#{icon}</span><span class=\"name\">#{h name}</span><span></span></a></li>"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The rollback document, collapsed in the sidebar of every step page
|
|
203
|
+
# but its own.
|
|
204
|
+
def self.rollback_panel?(session, current, kind)
|
|
205
|
+
rollback = session.runbook.rollback
|
|
206
|
+
!rollback.nil? && kind == :step && current != rollback
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.rollback_panel(session, current, kind)
|
|
210
|
+
return "" unless rollback_panel?(session, current, kind)
|
|
211
|
+
|
|
212
|
+
rollback = session.runbook.rollback
|
|
213
|
+
|
|
214
|
+
<<~HTML
|
|
215
|
+
<section>
|
|
216
|
+
<details>
|
|
217
|
+
<summary>Rollback</summary>
|
|
218
|
+
<div class="rollback-body markdown-body">#{document_html(session, rollback)}</div>
|
|
219
|
+
</details>
|
|
220
|
+
</section>
|
|
221
|
+
HTML
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Processes still running anywhere in the session, each with a Stop
|
|
225
|
+
# button. The page's JavaScript keeps the entries current.
|
|
226
|
+
def self.running_panel(session)
|
|
227
|
+
running = session.running
|
|
228
|
+
return "" if running.empty?
|
|
229
|
+
|
|
230
|
+
items = running.map do |run, ex|
|
|
231
|
+
here = run == session.current
|
|
232
|
+
step = here && run.runbook.step(ex.step_slug)
|
|
233
|
+
href = step ? "#{step_href(step)}#block-#{Rack::Utils.escape_path(ex.block_id)}" : Chooser.href(run.slug)
|
|
234
|
+
<<~LI
|
|
235
|
+
<li data-execution="#{h ex.id}"#{Renderer.mark_attr}>
|
|
236
|
+
<a href="#{h href}" title="#{h run.slug} · #{h ex.step_slug}"><code>#{h ex.block_id}</code>#{" <small>#{h run.slug}</small>" unless here}</a>
|
|
237
|
+
<span class="meta" data-role="running-status">#{ex.background? ? 'background' : 'running'} · pid #{ex.pid}</span>
|
|
238
|
+
<button type="button" class="rs-btn rs-stop" data-action="stop" data-execution="#{h ex.id}">Stop</button>
|
|
239
|
+
</li>
|
|
240
|
+
LI
|
|
241
|
+
end
|
|
242
|
+
<<~HTML
|
|
243
|
+
<section class="running" id="rs-running">
|
|
244
|
+
<h2>Running</h2>
|
|
245
|
+
<ul>#{items.join}</ul>
|
|
246
|
+
</section>
|
|
247
|
+
HTML
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# done | skipped | failed | ran | pending for the sidebar and step list.
|
|
251
|
+
def self.step_mark(session, step)
|
|
252
|
+
marked = session.step_status(step.slug)
|
|
253
|
+
return marked if marked
|
|
254
|
+
|
|
255
|
+
run = session.run
|
|
256
|
+
return "pending" unless run
|
|
257
|
+
|
|
258
|
+
executions = run.refresh!.executions.select { it[:step] == step.slug }
|
|
259
|
+
acked = run.acks.values.any? { it[:step] == step.slug }
|
|
260
|
+
return "pending" if executions.empty? && !acked
|
|
261
|
+
|
|
262
|
+
executions.any? { RunRecord.failure?(it) } ? "failed" : "ran"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
# Landing page
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def self.landing(session, nonce: nil)
|
|
270
|
+
runbook = session.runbook
|
|
271
|
+
body = [page_head(runbook), warnings_banner(runbook.warnings), meta_table(runbook),
|
|
272
|
+
run_panel(session), steps_list(session), history_panel(session),
|
|
273
|
+
"<article class=\"markdown-body\">#{runbook.preamble_html}</article>"].join("\n")
|
|
274
|
+
layout(session, title: "Home", body:, kind: :landing, nonce:)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def self.page_head(runbook)
|
|
278
|
+
badges = runbook.tags.map { "<span class=\"badge tag\">#{h it}</span>" }
|
|
279
|
+
badges.unshift('<span class="badge destructive">destructive</span>') if runbook.destructive?
|
|
280
|
+
<<~HTML
|
|
281
|
+
<div class="page-head">
|
|
282
|
+
<h1>#{ICONS[:book]} #{h runbook.title}</h1>
|
|
283
|
+
<p class="sub">#{runbook.steps.size} step#{'s' unless runbook.steps.size == 1}</p>
|
|
284
|
+
#{"<div class=\"badges\">#{badges.join}</div>" unless badges.empty?}
|
|
285
|
+
</div>
|
|
286
|
+
HTML
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def self.warnings_banner(warnings)
|
|
290
|
+
return "" if warnings.empty?
|
|
291
|
+
|
|
292
|
+
<<~HTML
|
|
293
|
+
<div class="banner warn">#{ICONS[:alert]}<div><strong>Authoring warnings</strong><ul>#{warnings.map { "<li>#{h it}</li>" }.join}</ul></div></div>
|
|
294
|
+
HTML
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# The front-matter facts as a table. The library page shows when_to_use
|
|
298
|
+
# on its own and passes the rows without it.
|
|
299
|
+
def self.meta_table(runbook, rows = meta_rows(runbook))
|
|
300
|
+
return "" if rows.empty?
|
|
301
|
+
|
|
302
|
+
"<table class=\"meta-table\">#{rows.map { |k, v| "<tr><th>#{k}</th><td>#{v}</td></tr>" }.join}</table>"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# label => html for each front-matter fact the runbook states.
|
|
306
|
+
def self.meta_rows(runbook)
|
|
307
|
+
rows = {}
|
|
308
|
+
rows["When to use"] = h(runbook.when_to_use) if runbook.when_to_use
|
|
309
|
+
rows["Prerequisites"] = "<ul>#{runbook.prerequisites.map { "<li>#{h it}</li>" }.join}</ul>" if runbook.prerequisites.any?
|
|
310
|
+
rows["Blast radius"] = "<span class=\"badge destructive\">destructive</span> #{h runbook.blast_radius}" if runbook.blast_radius
|
|
311
|
+
rows["Escalation"] = h(runbook.escalation) if runbook.escalation
|
|
312
|
+
rows
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Start-run form, or the active run's controls.
|
|
316
|
+
def self.run_panel(session)
|
|
317
|
+
session.active? ? active_run_panel(session) : start_panel(session)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def self.active_run_panel(session)
|
|
321
|
+
record = session.run
|
|
322
|
+
runbook = session.runbook
|
|
323
|
+
<<~HTML
|
|
324
|
+
<section class="panel">
|
|
325
|
+
<h2>Run open</h2>
|
|
326
|
+
<p><strong>#{h record.id}</strong> opened #{h record.started_at.strftime('%Y-%m-%d %H:%M:%S')} · #{run_progress(record, runbook)} · <a href="/run">view runsheet</a></p>
|
|
327
|
+
#{inputs_line(record.inputs, session.secret_inputs_set)}
|
|
328
|
+
<p class="meta">Work through the steps in order, or open any step and run only that. The run stays open until the session ends.</p>
|
|
329
|
+
<div class="btn-row">#{go_buttons(runbook)}</div>
|
|
330
|
+
#{change_inputs_form(session) if runbook.inputs.any?}
|
|
331
|
+
</section>
|
|
332
|
+
HTML
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def self.run_progress(record, runbook)
|
|
336
|
+
"#{record.executions.size} execution#{'s' unless record.executions.size == 1} · #{record.steps_done} of #{runbook.steps.size} steps done"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Work through the steps from the first, or go to the checks.
|
|
340
|
+
def self.go_buttons(runbook)
|
|
341
|
+
first = runbook.steps.first
|
|
342
|
+
checks = runbook.verify_blocks.size
|
|
343
|
+
buttons = []
|
|
344
|
+
buttons << "<a class=\"btn primary\" href=\"#{h step_href(first)}\">#{ICONS[:next]} Work through the steps</a>" if first
|
|
345
|
+
buttons << "<a class=\"btn\" href=\"/verify\">#{ICONS[:check]} Go to checks (#{checks})</a>" if checks.positive?
|
|
346
|
+
buttons.join
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# The start form's fields, prefilled with +values+. A secret is never
|
|
350
|
+
# put in the page: its field starts empty, and the server fills a blank
|
|
351
|
+
# one from the environment or the default when the run starts.
|
|
352
|
+
def self.input_fields(runbook, values)
|
|
353
|
+
runbook.inputs.map { it.secret? ? input_field(it, "", secret_hint(it, values[it.name])) : input_field(it, values[it.name]) }.join
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# What an empty secret field will become.
|
|
357
|
+
def self.secret_hint(input, value)
|
|
358
|
+
return "" if value.to_s.empty?
|
|
359
|
+
|
|
360
|
+
ENV[input.name].to_s.empty? ? "leave blank to use the default" : "leave blank to use $#{input.name} from the environment"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# The change form's fields: secrets are left empty, and an empty one
|
|
364
|
+
# keeps the value the run already has, so a secret never comes back to
|
|
365
|
+
# the page.
|
|
366
|
+
def self.change_input_fields(runbook, values)
|
|
367
|
+
runbook.inputs.map { it.secret? ? input_field(it, "", "leave blank to keep the current value") : input_field(it, values[it.name]) }.join
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# One input as a labelled field; a secret is a password field.
|
|
371
|
+
def self.input_field(input, value, placeholder = "")
|
|
372
|
+
name = h input.name
|
|
373
|
+
secret = input.secret?
|
|
374
|
+
<<~FIELD
|
|
375
|
+
<div class="field">
|
|
376
|
+
<label for="input-#{name}">#{h input.prompt}<code>$#{name}#{' · secret, not recorded' if secret}</code></label>
|
|
377
|
+
<input type="#{secret ? 'password' : 'text'}" id="input-#{name}" name="inputs[#{name}]" value="#{h value}" placeholder="#{h placeholder}" autocomplete="off">
|
|
378
|
+
</div>
|
|
379
|
+
FIELD
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def self.change_inputs_form(session)
|
|
383
|
+
<<~HTML
|
|
384
|
+
<details class="change-inputs">
|
|
385
|
+
<summary>Change inputs</summary>
|
|
386
|
+
<form method="post" action="/run/inputs">
|
|
387
|
+
<input type="hidden" name="_token" value="#{h session.token}">
|
|
388
|
+
<input type="hidden" name="runbook" value="#{h session.runbook.slug}">
|
|
389
|
+
#{change_input_fields(session.runbook, session.current.inputs)}
|
|
390
|
+
<div class="btn-row"><button class="btn" type="submit">Change inputs</button><span class="meta">Blocks run from now on see the new values; the change is recorded.</span></div>
|
|
391
|
+
</form>
|
|
392
|
+
</details>
|
|
393
|
+
HTML
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def self.start_panel(session)
|
|
397
|
+
runbook = session.runbook
|
|
398
|
+
<<~HTML
|
|
399
|
+
<section class="panel">
|
|
400
|
+
<h2>Start a run</h2>
|
|
401
|
+
<form method="post" action="/runs">
|
|
402
|
+
<input type="hidden" name="_token" value="#{h session.token}">
|
|
403
|
+
<input type="hidden" name="slug" value="#{h runbook.slug}">
|
|
404
|
+
#{input_fields(runbook, session.resolve_inputs)}
|
|
405
|
+
<div class="btn-row"><button class="btn primary" type="submit">Start run</button><span class="meta">The run stays open until the session ends. Its runsheet goes under #{h session.runs_root}</span></div>
|
|
406
|
+
</form>
|
|
407
|
+
</section>
|
|
408
|
+
HTML
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# The active run's inputs: values for plain inputs, "set" for secrets.
|
|
412
|
+
def self.inputs_line(inputs, secrets_set)
|
|
413
|
+
parts = inputs.map { |k, v| "<code>#{h k}=#{h v}</code>" }
|
|
414
|
+
parts += secrets_set.map { "<code class=\"secret\" title=\"secret, not recorded\">#{h it}=•••</code>" }
|
|
415
|
+
return "" if parts.empty?
|
|
416
|
+
|
|
417
|
+
"<p class=\"meta\">Inputs: #{parts.join(' ')}</p>"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def self.steps_list(session)
|
|
421
|
+
runbook = session.runbook
|
|
422
|
+
return '<p class="empty">This runbook has no steps yet. Add markdown files under steps/.</p>' if runbook.steps.empty?
|
|
423
|
+
|
|
424
|
+
rows = runbook.steps.map do |step|
|
|
425
|
+
status = step_mark(session, step)
|
|
426
|
+
badges = ["<span class=\"badge #{h step.kind}\">#{h step.kind}</span>"]
|
|
427
|
+
badges << '<span class="badge destructive">destructive</span>' if step.destructive?
|
|
428
|
+
<<~LI
|
|
429
|
+
<li>
|
|
430
|
+
<span class="num">#{h(step.number || step.position)}</span>
|
|
431
|
+
<a class="title" href="#{h step_href(step)}">#{h step.title}<small>#{step.executable_blocks.size} executable block#{'s' unless step.executable_blocks.size == 1}</small></a>
|
|
432
|
+
<span class="badges">#{badges.join}</span>
|
|
433
|
+
<span class="mark #{status}" title="#{status}">#{STATUS_MARKS[status]}</span>
|
|
434
|
+
</li>
|
|
435
|
+
LI
|
|
436
|
+
end
|
|
437
|
+
"<ol class=\"steps-list\">#{rows.join}</ol>"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Seconds as "0.4s", "12s", "3m 05s", "1h 02m".
|
|
441
|
+
def self.duration_text(seconds)
|
|
442
|
+
s = seconds.to_f
|
|
443
|
+
return format("%.1fs", s) if s < 10
|
|
444
|
+
return "#{s.round}s" if s < 60
|
|
445
|
+
return format("%<m>dm %<s>02ds", m: s / 60, s: s % 60) if s < 3600
|
|
446
|
+
|
|
447
|
+
format("%<h>dh %<m>02dm", h: s / 3600, m: (s % 3600) / 60)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def self.history_panel(session)
|
|
451
|
+
runs = session.history.reject { session.active? && it.dir == session.run.dir } # the open run is not history yet
|
|
452
|
+
return "" if runs.empty?
|
|
453
|
+
|
|
454
|
+
total = session.runbook.steps.size
|
|
455
|
+
rows = runs.first(20).map { history_row(it, total) }
|
|
456
|
+
"<section class=\"panel\"><h2>Previous runs</h2><ul class=\"history\">#{rows.join}</ul></section>"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def self.history_row(run, total)
|
|
460
|
+
s = run.summary
|
|
461
|
+
verdict = s[:status]
|
|
462
|
+
<<~LI
|
|
463
|
+
<li class="#{h verdict}">
|
|
464
|
+
<a href="/runs/#{h s[:id]}">#{h s[:id]}</a>
|
|
465
|
+
#{run.verify? ? '<span class="badge verify">verify</span>' : '<span></span>'}
|
|
466
|
+
<span class="meta verdict">#{h verdict}</span>
|
|
467
|
+
<span class="meta">#{h run.started_at.strftime('%Y-%m-%d %H:%M')} · #{h duration_text(s[:duration])}</span>
|
|
468
|
+
<span class="meta">#{s[:executions]} exec · #{s[:failures]} failed</span>
|
|
469
|
+
<span class="meta">#{history_progress(run, s, total)}</span>
|
|
470
|
+
</li>
|
|
471
|
+
LI
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def self.history_progress(run, summary, total)
|
|
475
|
+
checks = summary[:executions]
|
|
476
|
+
return "#{checks} check#{'s' unless checks == 1}, #{summary[:unresolved]} failing" if run.verify?
|
|
477
|
+
|
|
478
|
+
done = summary[:steps_done]
|
|
479
|
+
stopped = summary[:last_step] && done < total ? ", stopped at #{h summary[:last_step]}" : ""
|
|
480
|
+
"#{done} of #{total} steps done#{stopped}"
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# ------------------------------------------------------------------
|
|
484
|
+
# Step page
|
|
485
|
+
# ------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
def self.step(session, step, nonce: nil)
|
|
488
|
+
body = <<~HTML
|
|
489
|
+
<div class="page-head">
|
|
490
|
+
<h1>#{"<span class=\"num\">#{h(step.number || step.position)}</span>" if step.position} #{h step.title}</h1>
|
|
491
|
+
<div class="badges">#{step_badges(step).join}</div>
|
|
492
|
+
</div>
|
|
493
|
+
#{warnings_banner(step.warnings)}
|
|
494
|
+
#{step_banners(session, step)}
|
|
495
|
+
<article class="markdown-body">#{document_html(session, step)}</article>
|
|
496
|
+
#{mark_panel(session, step)}
|
|
497
|
+
#{step_nav(session.runbook, step)}
|
|
498
|
+
<script type="application/json" id="rs-prior"#{Renderer.mark_attr}>#{prior_executions_json(session, step)}</script>
|
|
499
|
+
HTML
|
|
500
|
+
layout(session, title: step.title, body:, kind: :step, step:, nonce:)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def self.step_badges(step)
|
|
504
|
+
badges = ["<span class=\"badge #{h step.kind}\">#{h step.kind}</span>"]
|
|
505
|
+
badges << '<span class="badge destructive">destructive</span>' if step.destructive?
|
|
506
|
+
badges << "<span class=\"badge\">timeout #{step.timeout}s</span>" if step.executable_blocks.any?
|
|
507
|
+
badges
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# The blast-radius banner for a destructive step, then any banner about
|
|
511
|
+
# why its blocks cannot run right now.
|
|
512
|
+
def self.step_banners(session, step)
|
|
513
|
+
banners = [destructive_banner(session.runbook, step)]
|
|
514
|
+
if step.executable_blocks.any? && !session.active?
|
|
515
|
+
banners << info_banner('No run for this runbook yet. Blocks can be read and copied but not executed. <a href="/">Start the run</a> first.')
|
|
516
|
+
end
|
|
517
|
+
banners.join
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def self.destructive_banner(runbook, step)
|
|
521
|
+
return "" unless step.destructive?
|
|
522
|
+
|
|
523
|
+
confirm = "Running a destructive block asks you to type a confirmation code first."
|
|
524
|
+
body = if runbook.blast_radius
|
|
525
|
+
escalation = runbook.escalation ? "<br><strong>Escalation</strong><br>#{h runbook.escalation}" : ""
|
|
526
|
+
"<strong>Blast radius</strong><br>#{h runbook.blast_radius}#{escalation}<br>#{confirm}"
|
|
527
|
+
else
|
|
528
|
+
"<strong>This step is destructive.</strong> Read it fully before running anything. #{confirm}"
|
|
529
|
+
end
|
|
530
|
+
"<div class=\"banner danger\">#{ICONS[:alert]}<div>#{body}</div></div>"
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# A document's rendered markdown with every button that cannot work right
|
|
534
|
+
# now rendered disabled, its title saying why.
|
|
535
|
+
def self.document_html(session, doc)
|
|
536
|
+
locks = { "execute" => execute_lock(session, doc), "acknowledge" => acknowledge_lock(session) }
|
|
537
|
+
locks.compact.reduce(doc.html) { |html, (action, reason)| lock_buttons(html, action, reason) }
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Why blocks cannot execute right now, or nil if they can.
|
|
541
|
+
def self.execute_lock(session, _doc) = session.active? ? nil : "Start a run to execute blocks"
|
|
542
|
+
|
|
543
|
+
# Why terminal blocks cannot be confirmed right now, or nil if they can.
|
|
544
|
+
def self.acknowledge_lock(session) = session.active? ? nil : "Start a run to confirm terminal blocks"
|
|
545
|
+
|
|
546
|
+
# Every button in +html+ with this data-action, disabled, marked
|
|
547
|
+
# data-locked, and titled with +reason+.
|
|
548
|
+
def self.lock_buttons(html, action, reason)
|
|
549
|
+
html.gsub(%(data-action="#{action}"), %(data-action="#{action}" data-locked disabled title="#{h reason}"))
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def self.info_banner(html) = "<div class=\"banner info\">#{ICONS[:alert]}<div>#{html}</div></div>"
|
|
553
|
+
|
|
554
|
+
def self.mark_panel(session, step)
|
|
555
|
+
return "" unless session.active? && step.position
|
|
556
|
+
|
|
557
|
+
status = session.step_status(step.slug)
|
|
558
|
+
heading = step.manual? ? "Acknowledge this step" : "Step status"
|
|
559
|
+
done = step.manual? ? "I have done this, continue" : "Mark done and continue"
|
|
560
|
+
hint = step.manual? ? "A manual step is complete when you say so. The acknowledgement and your note go into the runsheet." : ""
|
|
561
|
+
<<~HTML
|
|
562
|
+
<section class="panel">
|
|
563
|
+
<h2>#{heading}#{": #{h status}" if status}</h2>
|
|
564
|
+
#{"<p class=\"meta\">#{hint}</p>" unless hint.empty?}
|
|
565
|
+
<form method="post" action="#{h step_href(step)}/mark">
|
|
566
|
+
<input type="hidden" name="_token" value="#{h session.token}">
|
|
567
|
+
<input type="hidden" name="runbook" value="#{h session.runbook.slug}">
|
|
568
|
+
<div class="field"><label for="note">Note (optional)</label><input type="text" id="note" name="note" placeholder="What you checked, what you saw"></div>
|
|
569
|
+
<div class="btn-row">
|
|
570
|
+
<button class="btn ok" type="submit" name="status" value="done">#{done}</button>
|
|
571
|
+
<button class="btn" type="submit" name="status" value="skipped">Skip</button>
|
|
572
|
+
</div>
|
|
573
|
+
</form>
|
|
574
|
+
</section>
|
|
575
|
+
HTML
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def self.step_nav(runbook, step)
|
|
579
|
+
prev, nxt = runbook.neighbors(step)
|
|
580
|
+
left = prev ? "<a class=\"btn\" href=\"#{h step_href(prev)}\">#{ICONS[:prev]} #{h prev.title}</a>" : "<a class=\"btn\" href=\"/\">#{ICONS[:home]} Home</a>"
|
|
581
|
+
right = nxt ? "<a class=\"btn primary\" href=\"#{h step_href(nxt)}\">#{h nxt.title} #{ICONS[:next]}</a>" : (step.position ? "<a class=\"btn primary\" href=\"/\">Finish on the home page #{ICONS[:next]}</a>" : "")
|
|
582
|
+
"<nav class=\"step-nav\">#{left}#{right}</nav>"
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# The last execution of each block on these documents, and the terminal
|
|
586
|
+
# blocks the operator has confirmed, for the page to restore.
|
|
587
|
+
def self.prior_executions_json(session, *steps)
|
|
588
|
+
run = session.run
|
|
589
|
+
return "{}" unless run
|
|
590
|
+
|
|
591
|
+
mine = ->(block_id) { steps.any? { it.block(block_id) } }
|
|
592
|
+
latest = {}
|
|
593
|
+
run.executions.each { latest[it[:block_id]] = it if mine.call(it[:block_id]) }
|
|
594
|
+
executions = latest.transform_values do |hash|
|
|
595
|
+
live = session.execution(hash[:id])
|
|
596
|
+
output = output_for(run, live, hash)
|
|
597
|
+
expected = steps.filter_map { it.expected_output(hash[:block_id]) }.first
|
|
598
|
+
(live ? live.to_h : hash).merge(output:, success: hash[:state] == "finished" && hash[:exit_status] == 0,
|
|
599
|
+
expected: Expectation.for_page(expected, output, live ? live.state : hash[:state]))
|
|
600
|
+
end
|
|
601
|
+
acks = run.acks.select { |block_id, _| mine.call(block_id) }
|
|
602
|
+
JSON.generate({ executions:, acks: }).gsub("<", '\u003c') # a JSON escape: no "<!--" or "</script>" survives
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# ------------------------------------------------------------------
|
|
606
|
+
# Checks page: every verify document on one page
|
|
607
|
+
# ------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
def self.verify(session, nonce: nil)
|
|
610
|
+
runbook = session.runbook
|
|
611
|
+
docs = runbook.verify_documents
|
|
612
|
+
checks = runbook.verify_blocks.size
|
|
613
|
+
runnable = runbook.runnable_checks.size
|
|
614
|
+
banner = if session.active?
|
|
615
|
+
""
|
|
616
|
+
else
|
|
617
|
+
"<div class=\"banner info\">#{ICONS[:alert]}<div>No run for this runbook yet. <a href=\"/\">Start the run</a> to run these checks.</div></div>"
|
|
618
|
+
end
|
|
619
|
+
toolbar = if session.active? && runnable.positive?
|
|
620
|
+
"<div class=\"btn-row verify-toolbar\"><button type=\"button\" class=\"btn primary\" data-action=\"run-all\"#{Renderer.mark_attr}>#{ICONS[:check]} Run all #{runnable} check#{'s' unless runnable == 1}</button><span class=\"meta\" data-role=\"run-all-status\" role=\"status\" aria-live=\"polite\"></span></div>"
|
|
621
|
+
else
|
|
622
|
+
""
|
|
623
|
+
end
|
|
624
|
+
sections = docs.map do |doc|
|
|
625
|
+
label = doc.position ? "Step #{h(doc.number || doc.position)}" : "verify.md"
|
|
626
|
+
<<~SECTION
|
|
627
|
+
<section class="check-doc">
|
|
628
|
+
<h2 id="check-#{h doc.slug}"><span class="badge verify">#{label}</span> <a href="#{h step_href(doc)}">#{h doc.title}</a></h2>
|
|
629
|
+
#{warnings_banner(doc.warnings)}
|
|
630
|
+
<article class="markdown-body">#{document_html(session, doc)}</article>
|
|
631
|
+
</section>
|
|
632
|
+
SECTION
|
|
633
|
+
end
|
|
634
|
+
body = <<~HTML
|
|
635
|
+
<div class="page-head">
|
|
636
|
+
<h1>#{ICONS[:check]} Checks</h1>
|
|
637
|
+
<p class="sub">#{docs.size} verify document#{'s' unless docs.size == 1} · #{checks} executable check#{'s' unless checks == 1}</p>
|
|
638
|
+
</div>
|
|
639
|
+
#{banner}
|
|
640
|
+
#{toolbar}
|
|
641
|
+
#{sections.join}
|
|
642
|
+
<script type="application/json" id="rs-prior"#{Renderer.mark_attr}>#{prior_executions_json(session, *docs)}</script>
|
|
643
|
+
HTML
|
|
644
|
+
layout(session, title: "Checks", body:, kind: :verify, nonce:)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def self.output_for(run, live, hash)
|
|
648
|
+
return live.output(tail: Web::OUTPUT_TAIL) if live
|
|
649
|
+
|
|
650
|
+
path = hash[:log] && File.join(run.blocks_dir, hash[:log])
|
|
651
|
+
Runsheets.read_tail(path, Web::OUTPUT_TAIL)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# ------------------------------------------------------------------
|
|
655
|
+
# Runsheet (run record) page and errors
|
|
656
|
+
# ------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
def self.run(session, record, nonce: nil)
|
|
659
|
+
html = Renderer.render(record.transcript, id_prefix: "run").html
|
|
660
|
+
body = <<~HTML
|
|
661
|
+
<div class="page-head">
|
|
662
|
+
<h1>#{ICONS[:log]} Runsheet #{h record.id}</h1>
|
|
663
|
+
<p class="sub">#{record.verify? ? 'verification' : 'full run'} · #{h record.status} · #{record.executions.size} execution#{'s' unless record.executions.size == 1} · #{record.failed_executions.size} failed · #{h duration_text(record.duration)} · <code>#{h record.dir}</code></p>
|
|
664
|
+
</div>
|
|
665
|
+
#{drift_panel(session, record)}
|
|
666
|
+
<article class="markdown-body">#{html}</article>
|
|
667
|
+
HTML
|
|
668
|
+
layout(session, title: "Runsheet #{record.id}", body:, kind: :run, nonce:)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# Blocks whose code has changed, or that are gone, since this run.
|
|
672
|
+
def self.drift_panel(session, record)
|
|
673
|
+
return "" if record.active?
|
|
674
|
+
|
|
675
|
+
drift = record.drift(session.runbook)
|
|
676
|
+
return "" if drift.empty?
|
|
677
|
+
|
|
678
|
+
items = drift.map do |entry|
|
|
679
|
+
if entry[:status] == :missing
|
|
680
|
+
"<li><code>#{h entry[:block_id]}</code> is no longer in the runbook.<pre class=\"diff\">#{entry[:recorded].lines.map { "<span class=\"del\">-#{h it.chomp}</span>" }.join("\n")}</pre></li>"
|
|
681
|
+
else
|
|
682
|
+
rows = entry[:diff].map do |line|
|
|
683
|
+
cls = line.added? ? "add" : line.removed? ? "del" : "ctx"
|
|
684
|
+
"<span class=\"#{cls}\">#{h line.to_s}</span>"
|
|
685
|
+
end
|
|
686
|
+
"<li><code>#{h entry[:block_id]}</code> has changed since it ran.<pre class=\"diff\">#{rows.join("\n")}</pre></li>"
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
<<~HTML
|
|
690
|
+
<section class="panel drift">
|
|
691
|
+
<h2>#{ICONS[:alert]} Runbook changed since this run</h2>
|
|
692
|
+
<p class="meta">What ran is in the record; this is how the runbook reads now.</p>
|
|
693
|
+
<ul>#{items.join}</ul>
|
|
694
|
+
</section>
|
|
695
|
+
HTML
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# A plain markdown document the runbook links to: rendered, with nothing
|
|
699
|
+
# executable, its front matter (if any) left out.
|
|
700
|
+
# Its relative links resolve from the library, or from the open
|
|
701
|
+
# runbook's root. With a runbook open the page sits in its layout; in a library with
|
|
702
|
+
# none open yet, in the library's frame beside the tree.
|
|
703
|
+
def self.document(session, path, library: nil, token: nil, nonce: nil)
|
|
704
|
+
root = library&.dir || session.runbook.root
|
|
705
|
+
text = FrontMatter.parse(File.read(path, encoding: "UTF-8")).body
|
|
706
|
+
title = Renderer.title_of(text) || File.basename(path, ".*")
|
|
707
|
+
base = File.dirname(path).delete_prefix(Runbook.real_path(root)).delete_prefix("/")
|
|
708
|
+
html = Renderer.rewrite_relative_urls(Renderer.render_plain(text), base)
|
|
709
|
+
body = <<~HTML
|
|
710
|
+
<div class="banner info">#{ICONS[:alert]}<div>A document linked from a runbook, not a step: nothing here executes.</div></div>
|
|
711
|
+
<article class="markdown-body">#{html}</article>
|
|
712
|
+
HTML
|
|
713
|
+
return layout(session, title:, body:, kind: :document, nonce:) if session&.runbook
|
|
714
|
+
|
|
715
|
+
Chooser.frame(Chooser::View.new(library:, session:, token:, node: library.root, crumb: title), title:, main: body, nonce:)
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# ------------------------------------------------------------------
|
|
719
|
+
# The session: starting it, the notebook, and its end
|
|
720
|
+
# ------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
# A page with no runbook around it: the start page and the end page.
|
|
723
|
+
def self.bare(title:, body:, token:, nonce: nil)
|
|
724
|
+
nonce_attr = nonce ? %( nonce="#{h nonce}") : ""
|
|
725
|
+
<<~HTML
|
|
726
|
+
<!DOCTYPE html>
|
|
727
|
+
<html lang="en">
|
|
728
|
+
<head>
|
|
729
|
+
<meta charset="utf-8">
|
|
730
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
731
|
+
<meta name="rs-token" content="#{h token}">
|
|
732
|
+
#{FAVICON}
|
|
733
|
+
<title>#{h title} · #{h APP_NAME}</title>
|
|
734
|
+
<style#{nonce_attr}>#{Assets.stylesheet}</style>
|
|
735
|
+
</head>
|
|
736
|
+
<body class="kind-bare">
|
|
737
|
+
<main class="bare-main">#{body}</main>
|
|
738
|
+
<script#{nonce_attr}>#{Assets.javascript}</script>
|
|
739
|
+
</body>
|
|
740
|
+
</html>
|
|
741
|
+
HTML
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# Who is starting the session, and why. +error+ explains a refused form.
|
|
745
|
+
def self.session_new(token, engineer: nil, why: nil, error: nil, nonce: nil)
|
|
746
|
+
body = <<~HTML
|
|
747
|
+
<section class="panel session-start">
|
|
748
|
+
<h1>#{ICONS[:book]} Start a session</h1>
|
|
749
|
+
<p class="meta">Everything from now until runsheets stops is one session: the runbooks you open, what you run, and your notes, written to a log as it happens.</p>
|
|
750
|
+
#{"<div class=\"banner danger\">#{ICONS[:alert]}<div>#{h error}</div></div>" if error}
|
|
751
|
+
<form method="post" action="/session">
|
|
752
|
+
<input type="hidden" name="_token" value="#{h token}">
|
|
753
|
+
<div class="field"><label for="engineer">Who are you?</label><input type="text" id="engineer" name="engineer" value="#{h engineer}" required autocomplete="name"></div>
|
|
754
|
+
<div class="field"><label for="why">Why are you starting this session?<code>the first note</code></label><textarea id="why" name="why" rows="3" required placeholder="Monthly maintenance on the app database">#{h why}</textarea></div>
|
|
755
|
+
<div class="btn-row"><button class="btn primary" type="submit">#{ICONS[:next]} Start the session</button></div>
|
|
756
|
+
</form>
|
|
757
|
+
</section>
|
|
758
|
+
HTML
|
|
759
|
+
bare(title: "Start a session", body:, token:, nonce:)
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# The notebook so far: who, why, the notes, the runs, the log.
|
|
763
|
+
def self.session(session, library, token, error: nil, nonce: nil)
|
|
764
|
+
body = session_body(session, error:)
|
|
765
|
+
return Chooser.frame(Chooser::View.new(library:, session:, token:, node: library.root, crumb: "Session"), title: "Session", main: body, nonce:) if library
|
|
766
|
+
|
|
767
|
+
layout(session, title: "Session", body:, kind: :session, nonce:)
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def self.session_body(session, error: nil)
|
|
771
|
+
<<~HTML
|
|
772
|
+
<div class="page-head">
|
|
773
|
+
<h1>#{ICONS[:log]} Session #{h session.id}</h1>
|
|
774
|
+
<p class="sub">#{h session.engineer} · #{h session.host} · started #{h session.started_at.strftime('%Y-%m-%d %H:%M:%S')} · #{h duration_text(session.elapsed)}#{" · #{h session.status}" if session.ended?}</p>
|
|
775
|
+
</div>
|
|
776
|
+
#{"<div class=\"banner danger\">#{ICONS[:alert]}<div>#{h error}</div></div>" if error}
|
|
777
|
+
#{session_notes(session)}
|
|
778
|
+
#{session_runs(session)}
|
|
779
|
+
#{session_end_panel(session) unless session.ended?}
|
|
780
|
+
#{session_log_tail(session)}
|
|
781
|
+
#{past_sessions(session)}
|
|
782
|
+
HTML
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# The sessions before this one, newest first, from their records.
|
|
786
|
+
def self.past_sessions(session, limit: 10)
|
|
787
|
+
rows = Session.past(session.runs_root, except: session.dir).first(limit).map do |data|
|
|
788
|
+
runs = Array(data["runs"]).grep(Hash).map { "#{h it['runbook']} #{h it['status']}" }
|
|
789
|
+
<<~LI
|
|
790
|
+
<li class="#{h data['status']}">
|
|
791
|
+
<span><strong>#{h data['engineer']}</strong> <code>#{h data['id']}</code></span>
|
|
792
|
+
<span class="meta verdict">#{h data['status']}</span>
|
|
793
|
+
<span class="meta">#{h data['why']}</span>
|
|
794
|
+
<span class="meta">#{runs.empty? ? 'no runs' : runs.join(', ')}</span>
|
|
795
|
+
</li>
|
|
796
|
+
LI
|
|
797
|
+
end
|
|
798
|
+
return "" if rows.empty?
|
|
799
|
+
|
|
800
|
+
"<section class=\"panel\"><h2>Earlier sessions</h2><ul class=\"history session-runs\">#{rows.join}</ul></section>"
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def self.session_notes(session)
|
|
804
|
+
items = session.notes.map { "<li><time>#{h it[:at].strftime('%H:%M:%S')}</time><div>#{h(it[:text]).gsub("\n", '<br>')}</div></li>" }
|
|
805
|
+
form = if session.ended?
|
|
806
|
+
""
|
|
807
|
+
else
|
|
808
|
+
<<~FORM
|
|
809
|
+
<form method="post" action="/session/notes" class="note-form">
|
|
810
|
+
<input type="hidden" name="_token" value="#{h session.token}">
|
|
811
|
+
<textarea id="note-text" name="note" rows="2" required aria-label="New note" placeholder="What happened, what you decided, what changed"></textarea>
|
|
812
|
+
<button class="btn" type="submit">Add note</button>
|
|
813
|
+
</form>
|
|
814
|
+
FORM
|
|
815
|
+
end
|
|
816
|
+
"<section class=\"panel\" id=\"notes\"><h2>Notes</h2><ol class=\"notes\">#{items.join}</ol>#{form}</section>"
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def self.session_runs(session)
|
|
820
|
+
return '<section class="panel"><h2>Runs</h2><p class="empty">No runbook selected yet.</p></section>' if session.runs.empty?
|
|
821
|
+
|
|
822
|
+
rows = session.runs.map do |run|
|
|
823
|
+
record = run.record
|
|
824
|
+
<<~LI
|
|
825
|
+
<li class="#{h record.status}">
|
|
826
|
+
<span><strong>#{h run.runbook.title}</strong> <code>#{h run.slug}</code></span>
|
|
827
|
+
<span class="meta verdict">#{h record.active? ? record.closing_status(run.runbook.steps.map(&:slug)) + ' so far' : record.status}</span>
|
|
828
|
+
<span class="meta">#{h run_progress(record, run.runbook)}</span>
|
|
829
|
+
<span>#{run_link(session, run)}</span>
|
|
830
|
+
</li>
|
|
831
|
+
LI
|
|
832
|
+
end
|
|
833
|
+
"<section class=\"panel\"><h2>Runs</h2><ul class=\"history session-runs\">#{rows.join}</ul></section>"
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# Back to a run: its page when it is on screen, else select it again.
|
|
837
|
+
def self.run_link(session, run)
|
|
838
|
+
return '<a class="btn" href="/">Go to it</a>' if run == session.current
|
|
839
|
+
return "" if session.ended?
|
|
840
|
+
|
|
841
|
+
"<form method=\"post\" action=\"/runs\"><input type=\"hidden\" name=\"_token\" value=\"#{h session.token}\">" \
|
|
842
|
+
"<input type=\"hidden\" name=\"slug\" value=\"#{h run.slug}\"><button class=\"btn\" type=\"submit\">Return to it</button></form>"
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# What End session asks before it does anything.
|
|
846
|
+
def self.end_question(session)
|
|
847
|
+
running = session.running.size
|
|
848
|
+
stops = running.positive? ? " #{running} process#{'es' unless running == 1} still running will be stopped." : ""
|
|
849
|
+
"End the session and stop runsheets?#{stops}"
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def self.session_end_panel(session)
|
|
853
|
+
<<~HTML
|
|
854
|
+
<section class="panel">
|
|
855
|
+
<h2>End the session</h2>
|
|
856
|
+
<p class="meta">Every run closes with the status its work earns (completed, partial, or opened), anything still running is stopped, and runsheets exits. Ctrl-C in the terminal does the same.</p>
|
|
857
|
+
<form method="post" action="/session/end" data-confirm="#{h end_question(session)}">
|
|
858
|
+
<input type="hidden" name="_token" value="#{h session.token}">
|
|
859
|
+
<button class="btn danger" type="submit">End session</button>
|
|
860
|
+
</form>
|
|
861
|
+
</section>
|
|
862
|
+
HTML
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# The last lines of the session log.
|
|
866
|
+
def self.session_log_tail(session, lines: 200)
|
|
867
|
+
path = session.log.path
|
|
868
|
+
text = Runsheets.read_tail(path, 64 * 1024).lines.last(lines).join # never the whole log
|
|
869
|
+
<<~HTML
|
|
870
|
+
<section class="panel">
|
|
871
|
+
<h2>Session log</h2>
|
|
872
|
+
<p class="meta"><code>#{h path}</code> · the last #{lines} lines; <code>tail -f</code> it for the rest</p>
|
|
873
|
+
<pre class="session-log">#{h text}</pre>
|
|
874
|
+
</section>
|
|
875
|
+
HTML
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
# What the browser shows after End session.
|
|
879
|
+
def self.session_ended(session, nonce: nil)
|
|
880
|
+
runs = session.runs.map { "<li><strong>#{h it.runbook.title}</strong> · #{h it.record.status}</li>" }
|
|
881
|
+
body = <<~HTML
|
|
882
|
+
<section class="panel session-start">
|
|
883
|
+
<h1>#{ICONS[:check]} Session ended</h1>
|
|
884
|
+
<p>#{h session.engineer} · #{h duration_text(session.elapsed)} · runsheets is stopping.</p>
|
|
885
|
+
#{"<ul>#{runs.join}</ul>" unless runs.empty?}
|
|
886
|
+
<p class="meta">Session <code>#{h session.id}</code>. The record and the log are in <code>#{h session.dir}</code>; the log is <code>#{h session.log.path}</code>.</p>
|
|
887
|
+
<p class="meta">Start runsheets again for a new session.</p>
|
|
888
|
+
</section>
|
|
889
|
+
HTML
|
|
890
|
+
bare(title: "Session ended", body:, token: session.token, nonce:)
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
# What went wrong, in words, for a status.
|
|
894
|
+
# What a browser without the session key sees: how to get in, and
|
|
895
|
+
# nothing of the session (no token, no runbook).
|
|
896
|
+
def self.locked(nonce: nil)
|
|
897
|
+
body = <<~HTML
|
|
898
|
+
<section class="panel session-start">
|
|
899
|
+
<h1>#{ICONS[:alert]} Open the link from the terminal</h1>
|
|
900
|
+
<p>This browser does not have the key for this runsheets server. Open the link runsheets printed when it started (it ends in <code>?token=…</code>); this browser then keeps the key until runsheets stops.</p>
|
|
901
|
+
<p class="meta">Every start makes a new key, so a link from an earlier start no longer works.</p>
|
|
902
|
+
</section>
|
|
903
|
+
HTML
|
|
904
|
+
bare(title: "Open the link from the terminal", body:, token: "", nonce:)
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def self.error_title(status)
|
|
908
|
+
{ 403 => "Not allowed", 404 => "Not found", 409 => "Not right now", 410 => "The session has ended",
|
|
909
|
+
422 => "Check what you entered", 500 => "Something went wrong" }.fetch(status) { "Error #{status}" }
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
# The ways back from an error: home and the session, and the library
|
|
913
|
+
# when there is one (+extra+).
|
|
914
|
+
def self.error_body(message, status, extra: [])
|
|
915
|
+
links = [%(<a class="btn" href="/">#{ICONS[:home]} Home</a>), %(<a class="btn" href="/session">#{ICONS[:log]} Session</a>), *extra]
|
|
916
|
+
<<~HTML
|
|
917
|
+
<div class="page-head error-head">
|
|
918
|
+
<h1>#{h error_title(status)}</h1>
|
|
919
|
+
<p class="sub">#{h message}</p>
|
|
920
|
+
<div class="btn-row">#{links.join}</div>
|
|
921
|
+
</div>
|
|
922
|
+
HTML
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
def self.library_link = %(<a class="btn" href="/library">#{ICONS[:book]} Runbooks</a>)
|
|
926
|
+
|
|
927
|
+
def self.error(session, message, status, nonce: nil)
|
|
928
|
+
extra = session.library ? [library_link] : []
|
|
929
|
+
layout(session, title: error_title(status), body: error_body(message, status, extra:), kind: :error, nonce:)
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# An error with no runbook on screen: in the library's frame, or alone.
|
|
933
|
+
# +session+ is nil before the session starts.
|
|
934
|
+
def self.error_page(message, status, library:, session:, nonce: nil)
|
|
935
|
+
title = error_title(status)
|
|
936
|
+
token = session&.token.to_s
|
|
937
|
+
return bare(title:, body: error_body(message, status), token:, nonce:) unless library
|
|
938
|
+
|
|
939
|
+
view = Chooser::View.new(library:, session:, token:, node: library.root, crumb: title)
|
|
940
|
+
Chooser.frame(view, title:, main: error_body(message, status, extra: [library_link]), nonce:)
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
require_relative "pages/chooser"
|
|
946
|
+
require_relative "pages/tree"
|
|
947
|
+
require_relative "pages/search"
|