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,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ module Pages
5
+ # The library page: the home of a server started on a directory of
6
+ # runbooks. The folder tree sits in the left pane; the right pane shows
7
+ # whatever is selected in it, a folder (its README and cards for what
8
+ # it holds) or a runbook (everything worth knowing before opening it,
9
+ # and the Open button). Nothing selected shows the root folder.
10
+ #
11
+ # Every function takes the pieces it needs, so each can be rendered
12
+ # and inspected on its own.
13
+ module Chooser
14
+ # How much of when_to_use a card shows.
15
+ BLURB = 160
16
+
17
+ # The badge each state of a runbook adds to its pane. :current is on
18
+ # screen now, :open has a run in this session, :broken does not load,
19
+ # :ready can be selected (see .action and Listing#state).
20
+ BADGES = {
21
+ current: '<span class="badge current">open now</span>',
22
+ open: '<span class="badge current">run open</span>',
23
+ broken: '<span class="badge destructive">does not load</span>',
24
+ ready: ""
25
+ }.freeze
26
+
27
+ # What the page is drawn for: the library, the session (nil when no
28
+ # runbook is open), the CSRF token, the selected node, and the crumb
29
+ # naming a page that is not a node of the tree (Search, Session).
30
+ View = Data.define(:library, :session, :token, :node, :crumb) do
31
+ def initialize(library:, session:, token:, node:, crumb: nil) = super
32
+
33
+ def listing(entry) = Listing.new(entry:, session:, token:)
34
+ end
35
+
36
+ # One runbook as this session sees it.
37
+ Listing = Data.define(:entry, :session, :token) do
38
+ # The state the runbook is in (see BADGES).
39
+ def state
40
+ if entry.slug == session&.runbook&.slug then :current
41
+ elsif !entry.ok? then :broken
42
+ elsif session&.run_for(entry.slug) then :open
43
+ else :ready
44
+ end
45
+ end
46
+
47
+ # Where this session keeps run records, or the default.
48
+ def runs_root = session&.runs_root || Runsheets.runs_dir
49
+ end
50
+
51
+ module_function
52
+
53
+ def h(value) = Renderer.h(value)
54
+
55
+ def href(slug) = slug.to_s.empty? ? "/library" : "/library/#{slug.to_s.split('/').map { Rack::Utils.escape_path(it) }.join('/')}"
56
+
57
+ # ----------------------------------------------------------------
58
+ # Page
59
+ # ----------------------------------------------------------------
60
+
61
+ # +selected+ is the slug of a runbook or a folder, or nil for the root.
62
+ def page(library, session, token, nonce: nil, selected: nil)
63
+ node = library.node(selected.to_s) || library.root
64
+ view = View.new(library:, session:, token:, node:)
65
+ title = node.folder? && node.root? ? "Runbooks" : node.title
66
+ main = node.folder? ? folder_pane(view, node) : runbook_pane(view.listing(node))
67
+ frame(view, title:, main:, nonce:)
68
+ end
69
+
70
+ # The library's page around +main+: header, the tree, and the footer.
71
+ # +query+ fills the header's search box.
72
+ def frame(view, title:, main:, nonce: nil, query: "")
73
+ library = view.library
74
+ nonce_attr = nonce ? %( nonce="#{h nonce}") : ""
75
+ <<~HTML
76
+ <!DOCTYPE html>
77
+ <html lang="en">
78
+ <head>
79
+ <meta charset="utf-8">
80
+ <meta name="viewport" content="width=device-width, initial-scale=1">
81
+ <meta name="rs-token" content="#{h view.token}">
82
+ <meta name="rs-mark" content="#{Renderer::MARK}">
83
+ #{Pages::FAVICON}
84
+ <title>#{h title} · #{h APP_NAME}</title>
85
+ <style#{nonce_attr}>#{Assets.stylesheet}</style>
86
+ </head>
87
+ <body class="kind-library">
88
+ #{header(view, query:)}
89
+ <div class="rs-shell">
90
+ #{Tree.pane(view)}
91
+ <main class="rs-main lib-main">
92
+ #{main}
93
+ </main>
94
+ </div>
95
+ <footer class="rs-footer">
96
+ <span>#{h APP_NAME} · #{h APP_FULL} · #{h library.dir}</span>
97
+ <span class="keys"><kbd>f</kbd> search <kbd>n</kbd> note <kbd>/</kbd> filter <kbd>↑</kbd><kbd>↓</kbd> move <kbd>↵</kbd> select <kbd>o</kbd> open <kbd>s</kbd> tree</span>
98
+ </footer>
99
+ <script#{nonce_attr}>#{Assets.javascript}</script>
100
+ </body>
101
+ </html>
102
+ HTML
103
+ end
104
+
105
+ def header(view, query: "")
106
+ crumb = view.crumb
107
+ library = view.library
108
+ session = view.session
109
+ <<~HTML
110
+ <header class="rs-header">
111
+ <div class="brand">
112
+ <button class="icon-btn sidebar-toggle" type="button" title="Toggle the tree [s]" aria-label="Toggle the tree">#{ICONS[:menu]}</button>
113
+ <a class="home" href="/library" title="Runbooks [h]" data-key="h">
114
+ #{ICONS[:book]}
115
+ <span class="brand-name">#{h APP_NAME}</span>
116
+ <span class="brand-tag">#{h File.basename(library.dir)}</span>
117
+ </a>
118
+ </div>
119
+ <nav class="crumbs" aria-label="Breadcrumb">#{(crumb ? ['<a href="/library">Runbooks</a>', "<span class=\"current\">#{h crumb}</span>"] : crumbs(library, view.node)).join('<span class="sep">/</span>')}</nav>
120
+ <nav class="actions">
121
+ #{Pages.search_box(query)}
122
+ #{Pages.nav_button('Back to runbook', '/', :next, key: 'b', title: session.runbook.title) if session&.runbook}
123
+ #{pill(session)}
124
+ </nav>
125
+ </header>
126
+ HTML
127
+ end
128
+
129
+ # Runbooks / folder / folder / selected. Every crumb but the last is a link.
130
+ def crumbs(library, node)
131
+ chain = node.folder? && node.root? ? [] : [*library.ancestors(node.slug), node]
132
+ links = chain.map { "<a href=\"#{h href(it.slug)}\">#{h it.title}</a>" }
133
+ links.unshift('<a href="/library">Runbooks</a>')
134
+ links[-1] = links[-1].sub(/\A<a href="[^"]*">/, '<span class="current">').sub(%r{</a>\z}, "</span>")
135
+ links
136
+ end
137
+
138
+ def pill(session)
139
+ return '<span class="run-pill"><span class="dot"></span>no session</span>' unless session
140
+
141
+ Pages.session_pill(session)
142
+ end
143
+
144
+ # ----------------------------------------------------------------
145
+ # Right pane: a folder
146
+ # ----------------------------------------------------------------
147
+
148
+ def folder_pane(view, folder)
149
+ [folder_head(view.library, folder), readme(folder),
150
+ cards_section("Folders", folder.folders.map { folder_card(it) }),
151
+ cards_section("Runbooks", folder.entries.map { runbook_card(view.listing(it)) }),
152
+ folder.root? ? HINT : ""].join("\n")
153
+ end
154
+
155
+ HINT = '<p class="lib-hint">Pick a runbook in the tree, or a card above, to read about it before opening it. ' \
156
+ "A folder's <code>README.md</code> is shown as its description.</p>"
157
+
158
+ def folder_head(library, folder)
159
+ title = folder.root? ? "Runbooks" : folder.name
160
+ where = folder.root? ? library.dir : folder.slug
161
+ folders = folder.subfolders.size
162
+ <<~HTML
163
+ <div class="page-head">
164
+ <h1>#{ICONS[:folder]} #{h title}</h1>
165
+ <p class="sub">#{count(folder.size, 'runbook')}#{" in #{count(folders, 'folder')}" if folders.positive?} · <code>#{h where}</code></p>
166
+ </div>
167
+ HTML
168
+ end
169
+
170
+ def readme(folder)
171
+ return "" unless folder.readme?
172
+
173
+ "<article class=\"markdown-body lib-readme\">#{folder.readme_html}</article>"
174
+ end
175
+
176
+ def cards_section(heading, cards)
177
+ return "" if cards.empty?
178
+
179
+ "<section class=\"lib-section\"><h2>#{h heading}</h2><div class=\"lib-grid\">#{cards.join}</div></section>"
180
+ end
181
+
182
+ def folder_card(folder)
183
+ names = folder.runbooks.first(3).map(&:title)
184
+ more = folder.size - names.size
185
+ <<~HTML
186
+ <a class="lib-card folder" href="#{h href(folder.slug)}" data-slug="#{h folder.slug}">
187
+ <h3>#{ICONS[:folder]} #{h folder.name}</h3>
188
+ <p class="desc">#{count(folder.size, 'runbook')}#{" in #{count(folder.subfolders.size, 'folder')}" if folder.folders.any?}</p>
189
+ <p class="names">#{h names.join(', ')}#{" and #{more} more" if more.positive?}</p>
190
+ </a>
191
+ HTML
192
+ end
193
+
194
+ def runbook_card(listing)
195
+ entry = listing.entry
196
+ state = listing.state
197
+ <<~HTML
198
+ <article class="lib-card runbook #{state}" data-slug="#{h entry.slug}">
199
+ <a class="card-link" href="#{h href(entry.slug)}">
200
+ <h3>#{entry.single_file? ? ICONS[:file] : ICONS[:book]} #{h entry.title}</h3>
201
+ <p class="desc">#{h blurb(entry)}</p>
202
+ <div class="badges">#{badges(listing).join}</div>
203
+ </a>
204
+ <div class="card-foot"><span class="meta">#{detail(entry)}</span>#{action(listing)}</div>
205
+ </article>
206
+ HTML
207
+ end
208
+
209
+ def blurb(entry)
210
+ return entry.error unless entry.ok?
211
+
212
+ text = entry.when_to_use.to_s.strip.gsub(/\s+/, " ")
213
+ return "No description. Add when_to_use to the front matter." if text.empty?
214
+
215
+ text.size > BLURB ? "#{text[0, BLURB].sub(/\s+\S*\z/, '')}…" : text
216
+ end
217
+
218
+ # ----------------------------------------------------------------
219
+ # Right pane: a runbook
220
+ # ----------------------------------------------------------------
221
+
222
+ def runbook_pane(listing)
223
+ runbook = listing.entry.runbook
224
+ parts = [runbook_head(listing), open_panel(listing)]
225
+ if runbook
226
+ meta = Pages.meta_rows(runbook).except("When to use")
227
+ parts << Pages.warnings_banner(runbook.warnings) << Pages.meta_table(runbook, meta) << inputs_table(runbook)
228
+ parts << steps_list(runbook) << history(listing)
229
+ parts << "<article class=\"markdown-body\">#{preview_html(runbook)}</article>"
230
+ end
231
+ parts.join("\n")
232
+ end
233
+
234
+ # The runbook's preamble as a preview: Copy works, but its blocks
235
+ # execute only on the runbook's own pages, inside its run.
236
+ def preview_html(runbook)
237
+ reason = "Open this runbook to use its blocks"
238
+ %w[execute acknowledge].reduce(runbook.preamble_html) { |html, action| Pages.lock_buttons(html, action, reason) }
239
+ end
240
+
241
+ def runbook_head(listing)
242
+ entry = listing.entry
243
+ <<~HTML
244
+ <div class="page-head">
245
+ <h1>#{entry.single_file? ? ICONS[:file] : ICONS[:book]} #{h entry.title}</h1>
246
+ <p class="sub">#{detail(entry)} · <code>#{h entry.slug}</code></p>
247
+ <div class="badges">#{badges(listing).join}</div>
248
+ </div>
249
+ HTML
250
+ end
251
+
252
+ # The description and the one action, side by side.
253
+ def open_panel(listing)
254
+ entry = listing.entry
255
+ text = if !entry.ok?
256
+ "<div class=\"banner danger\">#{ICONS[:alert]}<div><strong>This runbook does not load.</strong><br>#{h entry.error}</div></div>"
257
+ elsif entry.when_to_use
258
+ "<h2>When to use</h2><p>#{h entry.when_to_use}</p>"
259
+ else
260
+ "<h2>When to use</h2><p class=\"empty\">Not said. Add <code>when_to_use</code> to the front matter.</p>"
261
+ end
262
+ action = pane_action(listing)
263
+ <<~HTML
264
+ <section class="panel lib-open #{listing.state}">
265
+ <div class="lib-open-text">#{text}</div>
266
+ #{"<div class=\"btn-row\">#{action}</div>" unless action.empty?}
267
+ </section>
268
+ HTML
269
+ end
270
+
271
+ # The one thing the operator can do with a runbook in its state, as a
272
+ # card offers it. A ready runbook with inputs leads to its pane, where
273
+ # the inputs form is.
274
+ def action(listing, key: nil)
275
+ case listing.state
276
+ when :current then "<a class=\"btn primary\" href=\"/\">#{ICONS[:next]} Continue</a>"
277
+ when :broken then ""
278
+ when :open then select_form(listing, "Return to its run", key:)
279
+ else
280
+ return select_form(listing, "Start run", key:) if listing.entry.runbook.inputs.empty?
281
+
282
+ "<a class=\"btn primary\" href=\"#{h href(listing.entry.slug)}\">#{ICONS[:next]} Open</a>"
283
+ end
284
+ end
285
+
286
+ # The runbook pane's action answers the o key, and for a ready
287
+ # runbook carries the inputs form.
288
+ def pane_action(listing)
289
+ return action(listing, key: "o") unless listing.state == :ready
290
+
291
+ select_form(listing, "Start run", key: "o", fields: start_fields(listing))
292
+ end
293
+
294
+ # POST /runs for this runbook: establish its run, or return to it.
295
+ def select_form(listing, label, key: nil, fields: nil)
296
+ "<form method=\"post\" action=\"/runs\" class=\"lib-open-form\"><input type=\"hidden\" name=\"_token\" value=\"#{h listing.token}\">" \
297
+ "<input type=\"hidden\" name=\"slug\" value=\"#{h listing.entry.slug}\">#{fields}" \
298
+ "<button class=\"btn primary\" type=\"submit\" data-key=\"#{h key}\">#{ICONS[:next]} #{h label}</button></form>"
299
+ end
300
+
301
+ # The runbook's inputs, prefilled from earlier in the session, the
302
+ # environment and the defaults.
303
+ def start_fields(listing)
304
+ runbook = listing.entry.runbook
305
+ values = Run.resolve_inputs(runbook, {}, carried: listing.session&.carried_inputs || {})
306
+ "<div class=\"lib-inputs\">#{Pages.input_fields(runbook, values)}</div>" if runbook.inputs.any?
307
+ end
308
+
309
+ def inputs_table(runbook)
310
+ return "" if runbook.inputs.empty?
311
+
312
+ rows = runbook.inputs.map do |input|
313
+ value = input.secret? ? '<span class="badge">secret</span>' : (input.default ? "<code>#{h input.default}</code>" : '<span class="empty">none</span>')
314
+ "<tr><th><code>$#{h input.name}</code></th><td>#{h input.prompt}</td><td>#{value}</td></tr>"
315
+ end
316
+ <<~HTML
317
+ <section class="lib-section">
318
+ <h2>Inputs</h2>
319
+ <table class="meta-table inputs"><thead><tr><th>Variable</th><th>Prompt</th><th>Default</th></tr></thead><tbody>#{rows.join}</tbody></table>
320
+ </section>
321
+ HTML
322
+ end
323
+
324
+ def steps_list(runbook)
325
+ return '<p class="empty">This runbook has no steps yet.</p>' if runbook.steps.empty?
326
+
327
+ rows = runbook.steps.map do |step|
328
+ badges = ["<span class=\"badge #{h step.kind}\">#{h step.kind}</span>"]
329
+ badges << '<span class="badge destructive">destructive</span>' if step.destructive?
330
+ blocks = step.executable_blocks.size
331
+ <<~LI
332
+ <li id="step-#{h step.slug}">
333
+ <span class="num">#{h(step.number || step.position)}</span>
334
+ <span class="title">#{h step.title}<small>#{count(blocks, 'executable block')}</small></span>
335
+ <span class="badges">#{badges.join}</span>
336
+ <span></span>
337
+ </li>
338
+ LI
339
+ end
340
+ extras = runbook.extras.keys.map { "<code>#{h it}</code>" }
341
+ <<~HTML
342
+ <section class="lib-section">
343
+ <h2>Steps</h2>
344
+ <ol class="steps-list">#{rows.join}</ol>
345
+ #{"<p class=\"meta\">Also: #{extras.join(', ')}</p>" if extras.any?}
346
+ </section>
347
+ HTML
348
+ end
349
+
350
+ # The last few runs of this runbook, wherever records are kept.
351
+ def history(listing)
352
+ runs = RunRecord.list(listing.runs_root, listing.entry.slug)
353
+ return "" if runs.empty?
354
+
355
+ total = listing.entry.steps
356
+ rows = runs.first(5).map do |run|
357
+ s = run.summary
358
+ verdict = s[:status]
359
+ <<~LI
360
+ <li class="#{h verdict}">
361
+ <span>#{h s[:id]}</span>
362
+ #{run.verify? ? '<span class="badge verify">verify</span>' : '<span></span>'}
363
+ <span class="meta verdict">#{h verdict}</span>
364
+ <span class="meta">#{h run.started_at.strftime('%Y-%m-%d %H:%M')} · #{h Pages.duration_text(s[:duration])}</span>
365
+ <span class="meta">#{h Pages.history_progress(run, s, total)}</span>
366
+ <span></span>
367
+ </li>
368
+ LI
369
+ end
370
+ <<~HTML
371
+ <section class="lib-section">
372
+ <h2>Previous runs <span class="meta">#{count(runs.size, 'run')}</span></h2>
373
+ <ul class="history">#{rows.join}</ul>
374
+ </section>
375
+ HTML
376
+ end
377
+
378
+ # ----------------------------------------------------------------
379
+ # Shared bits
380
+ # ----------------------------------------------------------------
381
+
382
+ def badges(listing)
383
+ entry = listing.entry
384
+ list = ["<span class=\"badge\">#{entry.single_file? ? 'single file' : 'directory'}</span>"]
385
+ list << '<span class="badge destructive">destructive</span>' if entry.destructive?
386
+ list << BADGES.fetch(listing.state)
387
+ list.concat(entry.tags.map { "<span class=\"badge tag\">#{h it}</span>" })
388
+ list.reject(&:empty?)
389
+ end
390
+
391
+ # "6 steps · 2 warnings", or the error.
392
+ def detail(entry)
393
+ return "does not load" unless entry.ok?
394
+
395
+ parts = [count(entry.steps, "step")]
396
+ parts << count(entry.warnings.size, "warning") unless entry.warnings.empty?
397
+ parts.join(" · ")
398
+ end
399
+
400
+ def count(amount, noun) = "#{amount} #{noun}#{'s' unless amount == 1}"
401
+ end
402
+
403
+ # The library page (see Pages::Chooser.page).
404
+ def self.library(library, session, token, nonce: nil, selected: nil)
405
+ Chooser.page(library, session, token, nonce:, selected:)
406
+ end
407
+ end
408
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ module Pages
5
+ # The search page: every runbook that matches a query, best first, each
6
+ # with the documents that matched and a snippet from each. In a library
7
+ # it sits in the library's frame beside the tree; on a single runbook,
8
+ # in that runbook's layout.
9
+ module SearchPage
10
+ # Matching documents listed per runbook before "and N more".
11
+ HITS_SHOWN = 5
12
+
13
+ # What the results are drawn for: the parsed query, and the slug of
14
+ # the runbook open now (nil when none is), whose steps link straight
15
+ # to their pages.
16
+ Context = Data.define(:query, :open_slug) do
17
+ def highlight(text) = query.highlight(text)
18
+
19
+ def open?(slug) = slug == open_slug
20
+
21
+ # Where a result links. The runbook open now goes straight to the
22
+ # step's page; any other runbook to its page in the library, at the
23
+ # step when it is a numbered one.
24
+ def href(slug, step)
25
+ return Chooser.href(slug) + (step&.position ? "#step-#{step.slug}" : "") unless open?(slug)
26
+
27
+ step.nil? || step.slug == "runbook" ? "/" : Pages.step_href(step)
28
+ end
29
+ end
30
+
31
+ module_function
32
+
33
+ def h(value) = Renderer.h(value)
34
+
35
+ # The page's main pane for the query string over +results+.
36
+ def main(results, query, open_slug: nil)
37
+ context = Context.new(query: Search::Query.parse(query), open_slug:)
38
+ <<~HTML
39
+ <div class="page-head">
40
+ <h1>#{ICONS[:search]} Search</h1>
41
+ <p class="sub">#{summary(results, query)}</p>
42
+ </div>
43
+ #{form(query)}
44
+ #{list(results, context) unless context.query.empty?}
45
+ HTML
46
+ end
47
+
48
+ def summary(results, query)
49
+ return "Search the text of every runbook: titles, front matter, steps and their code." if Search.terms(query).empty?
50
+
51
+ "#{results.size} runbook#{'s' unless results.size == 1} match <strong>#{h query.strip}</strong>"
52
+ end
53
+
54
+ def form(query)
55
+ <<~HTML
56
+ <form class="search-form" action="/search" method="get" role="search">
57
+ <input type="search" name="q" value="#{h query}" placeholder="Words, or a &quot;quoted phrase&quot;" aria-label="Search runbooks" autocomplete="off" spellcheck="false" autofocus>
58
+ <button class="btn primary" type="submit">#{ICONS[:search]} Search</button>
59
+ </form>
60
+ HTML
61
+ end
62
+
63
+ def list(results, context)
64
+ return '<p class="empty">No runbook contains every word. Try fewer words, or check the spelling.</p>' if results.empty?
65
+
66
+ "<ol class=\"search-results\">#{results.map { result(it, context) }.join}</ol>"
67
+ end
68
+
69
+ def result(result, context)
70
+ runbook = result.runbook
71
+ slug = result.slug
72
+ shown = result.hits.first(HITS_SHOWN)
73
+ more = result.hits.size - shown.size
74
+ hits = shown.map { hit(slug, it, context) }.join
75
+ <<~HTML
76
+ <li class="search-result">
77
+ <h2><a href="#{h context.href(slug, nil)}">#{context.highlight(runbook.title)}</a>#{' <span class="badge current">open now</span>' if context.open?(slug)}</h2>
78
+ <p class="meta">#{h slug} · #{runbook.steps.size} step#{'s' unless runbook.steps.size == 1}#{" · #{h runbook.tags.join(', ')}" if runbook.tags.any?}</p>
79
+ #{"<ul class=\"search-hits\">#{hits}</ul>" unless hits.empty?}
80
+ #{"<p class=\"meta\">and #{more} more</p>" if more.positive?}
81
+ </li>
82
+ HTML
83
+ end
84
+
85
+ def hit(slug, hit, context)
86
+ step = hit.step
87
+ <<~HTML
88
+ <li>
89
+ <a href="#{h context.href(slug, step)}"><span class="num">#{h label(step)}</span> #{context.highlight(hit.title)}</a>
90
+ <p class="snippet">#{context.highlight(hit.snippet)}</p>
91
+ </li>
92
+ HTML
93
+ end
94
+
95
+ # "Step 020", "Preamble", or the slug of verify or rollback.
96
+ def label(step)
97
+ return "Step #{step.number || step.position}" if step.position
98
+
99
+ step.slug == "runbook" ? "Preamble" : step.slug
100
+ end
101
+ end
102
+
103
+ # The search page for +query+. In a library it searches every runbook
104
+ # that loads and sits in the library frame; otherwise it searches the
105
+ # session's runbook.
106
+ def self.search(session, library, query, token:, nonce: nil)
107
+ open_slug = session&.runbook&.slug
108
+ if library
109
+ runbooks = library.entries.select(&:ok?).map { [it.slug, it.runbook] }
110
+ main = SearchPage.main(Search.run(runbooks, query), query, open_slug:)
111
+ view = Chooser::View.new(library:, session:, token:, node: library.root, crumb: "Search")
112
+ Chooser.frame(view, title: "Search", main:, nonce:, query:)
113
+ else
114
+ main = SearchPage.main(Search.run([[open_slug, session.runbook]], query), query, open_slug:)
115
+ layout(session, title: "Search", body: main, kind: :search, nonce:, query:)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ module Pages
5
+ # The folder tree in the left pane of the library page. Folders are
6
+ # <details> elements, so they fold without script; the page script adds
7
+ # filtering and a keyboard cursor on top.
8
+ module Tree
9
+ module_function
10
+
11
+ def h(value) = Renderer.h(value)
12
+
13
+ # view is a Chooser::View.
14
+ def pane(view)
15
+ library = view.library
16
+ node = view.node
17
+ <<~HTML
18
+ <aside class="rs-sidebar lib-tree" aria-label="Runbook tree">
19
+ #{Pages.running_panel(view.session) if view.session}
20
+ <div class="lib-filter">
21
+ #{ICONS[:search]}
22
+ <input type="search" id="lib-filter" placeholder="Filter runbooks" aria-label="Filter runbooks" autocomplete="off" spellcheck="false">
23
+ <kbd>/</kbd>
24
+ </div>
25
+ <nav class="tree" id="lib-tree-nav">
26
+ <ul class="tree-root">
27
+ <li class="tree-folder root#{' active' if node.folder? && node.root?}" data-slug="">
28
+ <a class="node" href="/library">#{ICONS[:home]}<span class="name">All runbooks</span><span class="tree-mark count">#{library.size}</span></a>
29
+ </li>
30
+ #{children(library.root, view)}
31
+ </ul>
32
+ </nav>
33
+ <p class="tree-empty" id="lib-tree-empty" hidden>No runbook matches.</p>
34
+ </aside>
35
+ HTML
36
+ end
37
+
38
+ # A folder's folders, then its runbooks.
39
+ def children(folder, view)
40
+ folder.folders.map { folder(it, view) }.join +
41
+ folder.entries.map { runbook(it, view) }.join
42
+ end
43
+
44
+ # Open when it holds the selection or the runbook open now; the first
45
+ # level is open anyway so a fresh page shows the shape of the library.
46
+ def folder(folder, view)
47
+ selected = view.node
48
+ slug = folder.slug
49
+ inside = [selected.slug, view.session&.runbook&.slug].compact
50
+ open = folder.depth == 1 || selected.slug == slug || inside.any? { it.start_with?("#{slug}/") }
51
+ active = selected.folder? && selected.slug == slug
52
+ <<~HTML
53
+ <li class="tree-folder#{' active' if active}" data-slug="#{h slug}">
54
+ <details#{' open' if open}>
55
+ <summary><span class="twisty">#{ICONS[:chevron]}</span>#{ICONS[:folder]}<a class="name" href="#{h Chooser.href(slug)}" title="#{h slug}">#{h folder.name}</a><span class="tree-mark count">#{folder.size}</span></summary>
56
+ <ul>#{children(folder, view)}</ul>
57
+ </details>
58
+ </li>
59
+ HTML
60
+ end
61
+
62
+ def runbook(entry, view)
63
+ listing = view.listing(entry)
64
+ selected = view.node
65
+ slug = entry.slug
66
+ classes = ["tree-runbook", listing.state.to_s]
67
+ classes << "active" if selected.runbook? && selected.slug == slug
68
+ classes << "destructive" if entry.destructive?
69
+ search = [entry.name, entry.title, *entry.tags, slug].join(" ").downcase
70
+ <<~HTML
71
+ <li class="#{classes.join(' ')}" data-slug="#{h slug}" data-search="#{h search}">
72
+ <a class="node" href="#{h Chooser.href(slug)}" title="#{h slug}">#{entry.single_file? ? ICONS[:file] : ICONS[:book]}<span class="name">#{h entry.title}</span>#{mark(listing)}</a>
73
+ </li>
74
+ HTML
75
+ end
76
+
77
+ def mark(listing)
78
+ case listing.state
79
+ when :broken then '<span class="tree-mark broken" title="does not load">!</span>'
80
+ when :current then '<span class="tree-mark current" title="open now"><span class="dot"></span></span>'
81
+ else
82
+ steps = listing.entry.steps
83
+ "<span class=\"tree-mark count\" title=\"#{steps} steps\">#{steps}</span>"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end