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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +98 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +277 -0
  5. data/bin/runsheets +12 -0
  6. data/examples/about-the-examples.md +40 -0
  7. data/examples/directory_based_runbooks/hello/rollback.md +11 -0
  8. data/examples/directory_based_runbooks/hello/runbook.md +40 -0
  9. data/examples/directory_based_runbooks/hello/steps/010-say-hello.md +33 -0
  10. data/examples/directory_based_runbooks/hello/steps/020-inspect-ruby.md +28 -0
  11. data/examples/directory_based_runbooks/hello/steps/030-take-a-breath.md +11 -0
  12. data/examples/directory_based_runbooks/hello/steps/035-keep-a-clock-running.md +21 -0
  13. data/examples/directory_based_runbooks/hello/steps/040-exercise-failure.md +26 -0
  14. data/examples/directory_based_runbooks/hello/steps/045-check-the-greeting.md +18 -0
  15. data/examples/directory_based_runbooks/hello/verify.md +18 -0
  16. data/examples/directory_based_runbooks/staging-teardown/rollback.md +28 -0
  17. data/examples/directory_based_runbooks/staging-teardown/runbook.md +57 -0
  18. data/examples/directory_based_runbooks/staging-teardown/steps/010-confirm-nothing-to-preserve.md +18 -0
  19. data/examples/directory_based_runbooks/staging-teardown/steps/020-snapshot-the-database.md +47 -0
  20. data/examples/directory_based_runbooks/staging-teardown/steps/030-stop-the-pipeline.md +32 -0
  21. data/examples/directory_based_runbooks/staging-teardown/steps/040-drain-the-ecs-services.md +35 -0
  22. data/examples/directory_based_runbooks/staging-teardown/steps/050-record-the-final-row-counts.md +41 -0
  23. data/examples/directory_based_runbooks/staging-teardown/steps/060-delete-the-stack.md +56 -0
  24. data/examples/directory_based_runbooks/staging-teardown/verify.md +40 -0
  25. data/examples/single_md_file_runbooks/db-maintenance.md +163 -0
  26. data/examples/single_md_file_runbooks/disk-space-triage.md +90 -0
  27. data/examples/single_md_file_runbooks/disk-usage-glossary.md +48 -0
  28. data/examples/single_md_file_runbooks/policies/cleanup-policy.md +33 -0
  29. data/examples/single_md_file_runbooks/policies/images/triage-flow.svg +44 -0
  30. data/examples/single_md_file_runbooks/runbooks_with_scripts/.loki +17 -0
  31. data/examples/single_md_file_runbooks/runbooks_with_scripts/asgard-tasks.md +135 -0
  32. data/examples/single_md_file_runbooks/runbooks_with_scripts/checks.loki +27 -0
  33. data/examples/single_md_file_runbooks/runbooks_with_scripts/greet.loki +6 -0
  34. data/lib/runsheets/assets.rb +780 -0
  35. data/lib/runsheets/block.rb +118 -0
  36. data/lib/runsheets/cli.rb +424 -0
  37. data/lib/runsheets/config/defaults.yml +44 -0
  38. data/lib/runsheets/config.rb +139 -0
  39. data/lib/runsheets/diff.rb +67 -0
  40. data/lib/runsheets/execution.rb +144 -0
  41. data/lib/runsheets/executor.rb +157 -0
  42. data/lib/runsheets/expectation.rb +101 -0
  43. data/lib/runsheets/fences.rb +172 -0
  44. data/lib/runsheets/front_matter.rb +29 -0
  45. data/lib/runsheets/library.rb +268 -0
  46. data/lib/runsheets/pages/chooser.rb +408 -0
  47. data/lib/runsheets/pages/search.rb +119 -0
  48. data/lib/runsheets/pages/tree.rb +88 -0
  49. data/lib/runsheets/pages.rb +947 -0
  50. data/lib/runsheets/redactor.rb +88 -0
  51. data/lib/runsheets/renderer.rb +229 -0
  52. data/lib/runsheets/run.rb +330 -0
  53. data/lib/runsheets/run_record.rb +371 -0
  54. data/lib/runsheets/runbook.rb +301 -0
  55. data/lib/runsheets/search.rb +129 -0
  56. data/lib/runsheets/session.rb +307 -0
  57. data/lib/runsheets/session_log.rb +114 -0
  58. data/lib/runsheets/single_file.rb +93 -0
  59. data/lib/runsheets/step.rb +91 -0
  60. data/lib/runsheets/version.rb +5 -0
  61. data/lib/runsheets/web.rb +537 -0
  62. data/lib/runsheets.rb +90 -0
  63. data/sig/runsheets.rbs +4 -0
  64. 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"