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,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 "quoted phrase"" 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
|