workspaces 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/DESIGN.md +9 -3
- data/README.md +31 -3
- data/lib/workspaces/build_history.rb +83 -0
- data/lib/workspaces/config.rb +3 -0
- data/lib/workspaces/coordinator.rb +72 -9
- data/lib/workspaces/dashboard/app.js +148 -42
- data/lib/workspaces/dashboard/styles.css +82 -50
- data/lib/workspaces/dashboard/template.html.erb +18 -10
- data/lib/workspaces/dashboard_view.rb +66 -4
- data/lib/workspaces/duration_format.rb +36 -0
- data/lib/workspaces/paths.rb +16 -0
- data/lib/workspaces/presentation.rb +42 -12
- data/lib/workspaces/progress.rb +122 -0
- data/lib/workspaces/step_log.rb +55 -0
- data/lib/workspaces/tee_log.rb +7 -1
- data/lib/workspaces/version.rb +1 -1
- data/lib/workspaces/workflow.rb +16 -5
- metadata +6 -2
|
@@ -155,6 +155,17 @@ h1 {
|
|
|
155
155
|
.lead h2 { font-size: clamp(1.4rem, 2vw + 1rem, 1.9rem); line-height: 1.2; }
|
|
156
156
|
.lead__summary { max-inline-size: 38rem; color: var(--muted); font-size: 1.05rem; line-height: 1.5; }
|
|
157
157
|
.lead__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
|
158
|
+
.lead__actions:empty { display: none; }
|
|
159
|
+
|
|
160
|
+
/* The build progress bar: a plain track and fill, then one line saying which step and how long. */
|
|
161
|
+
.build-progress { display: grid; gap: var(--space-3); }
|
|
162
|
+
.progress-bar { block-size: 10px; border-radius: 999px; background: color-mix(in oklab, var(--muted) 22%, transparent); overflow: hidden; }
|
|
163
|
+
.progress-bar__fill { display: block; block-size: 100%; border-radius: inherit; background: var(--accent); transition: inline-size 600ms ease; }
|
|
164
|
+
.progress-bar[data-slow='true'] .progress-bar__fill { background: var(--warning); }
|
|
165
|
+
.progress-meta { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: var(--space-1) var(--space-4); }
|
|
166
|
+
.progress-meta__step { display: flex; align-items: center; gap: var(--space-2); font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; }
|
|
167
|
+
.progress-meta__time { font-variant-numeric: tabular-nums; }
|
|
168
|
+
@media (prefers-reduced-motion: reduce) { .progress-bar__fill { transition: none; } }
|
|
158
169
|
.lead__action:empty { display: none; }
|
|
159
170
|
.button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius); }
|
|
160
171
|
|
|
@@ -199,11 +210,28 @@ h1 {
|
|
|
199
210
|
.disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
200
211
|
.disclosure[open] > summary { border-end-start-radius: 0; border-end-end-radius: 0; border-block-end: 1px solid var(--border); margin-block-end: 0; }
|
|
201
212
|
.disclosure > :not(summary) { margin: var(--panel-padding); }
|
|
213
|
+
/* Which preset is active, readable without opening the section. Same tint as a pressed preset button. */
|
|
214
|
+
.badge {
|
|
215
|
+
display: inline-flex;
|
|
216
|
+
align-items: center;
|
|
217
|
+
gap: var(--space-2);
|
|
218
|
+
padding: var(--space-1) var(--space-3);
|
|
219
|
+
border: 1px solid color-mix(in oklab, var(--success) 55%, var(--border));
|
|
220
|
+
border-radius: 999px;
|
|
221
|
+
background: color-mix(in oklab, var(--success) 22%, transparent);
|
|
222
|
+
color: var(--text);
|
|
223
|
+
font-size: 12px;
|
|
224
|
+
font-weight: 600;
|
|
225
|
+
line-height: 1.4;
|
|
226
|
+
white-space: nowrap;
|
|
227
|
+
}
|
|
228
|
+
.badge::before { content: ''; inline-size: 6px; block-size: 6px; border-radius: 999px; background: var(--success); }
|
|
202
229
|
.disclosure__hint { color: var(--muted); font-size: 13px; font-weight: 400; }
|
|
203
230
|
@media (prefers-reduced-motion: reduce) { .disclosure > summary, .disclosure > summary::after { transition: none; } }
|
|
204
231
|
.disclosure .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
|
|
205
232
|
.disclosure .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
|
|
206
233
|
.disclosure .log-panel { padding: 0; }
|
|
234
|
+
.log-panel > #log { margin-block-start: 0; }
|
|
207
235
|
.disclosure .danger-zone h3 { color: var(--danger); font-size: 1rem; }
|
|
208
236
|
.disclosure h3 { font-size: 1rem; }
|
|
209
237
|
|
|
@@ -254,60 +282,64 @@ h1 {
|
|
|
254
282
|
color: var(--text);
|
|
255
283
|
}
|
|
256
284
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
gap: var(--space-2);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
.step {
|
|
285
|
+
/* Step logs, in the style of GitHub Actions: one row per recipe step with a status icon,
|
|
286
|
+
its name, how long it took, and a chevron. Open a row to read just that step's output. */
|
|
287
|
+
.step-logs { display: grid; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|
288
|
+
.step-log + .step-log { border-block-start: 1px solid var(--border); }
|
|
289
|
+
.step-log > summary {
|
|
266
290
|
display: flex;
|
|
267
291
|
align-items: center;
|
|
268
292
|
gap: var(--space-3);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
.step-bullet {
|
|
273
|
-
inline-size: 10px;
|
|
274
|
-
block-size: 10px;
|
|
275
|
-
border: 2px solid var(--muted);
|
|
276
|
-
border-radius: 999px;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
.step[data-step-state='active'],
|
|
280
|
-
.step[data-step-state='complete'] {
|
|
293
|
+
padding: var(--space-3) var(--space-4);
|
|
294
|
+
position: relative;
|
|
295
|
+
list-style: none;
|
|
281
296
|
color: var(--text);
|
|
297
|
+
cursor: pointer;
|
|
298
|
+
user-select: none;
|
|
299
|
+
transition: background-color 120ms ease;
|
|
282
300
|
}
|
|
283
|
-
|
|
284
|
-
.step
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
.step[
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
.step
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
.step
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
301
|
+
.step-log > summary::-webkit-details-marker { display: none; }
|
|
302
|
+
.step-log > summary:hover { background: color-mix(in oklab, var(--accent) 8%, transparent); }
|
|
303
|
+
.step-log > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
304
|
+
.step-log > summary::after {
|
|
305
|
+
content: '';
|
|
306
|
+
flex: none;
|
|
307
|
+
inline-size: 7px;
|
|
308
|
+
block-size: 7px;
|
|
309
|
+
margin-inline-start: var(--space-2);
|
|
310
|
+
margin-block-start: -3px;
|
|
311
|
+
border-inline-end: 2px solid var(--muted);
|
|
312
|
+
border-block-end: 2px solid var(--muted);
|
|
313
|
+
transform: rotate(-45deg);
|
|
314
|
+
transition: transform 160ms ease;
|
|
315
|
+
}
|
|
316
|
+
.step-log[open] > summary::after { margin-block-start: -3px; transform: rotate(45deg); }
|
|
317
|
+
.step-log[open] > summary { border-block-end: 1px solid var(--border); }
|
|
318
|
+
.step-log__name { flex: 1; min-inline-size: 0; overflow-wrap: anywhere; font-weight: 600; }
|
|
319
|
+
.step-log__outcome { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
320
|
+
.step-log--full > summary { color: var(--muted); }
|
|
321
|
+
.step-log--full .step-log__name { font-weight: 400; }
|
|
322
|
+
.step-log > pre { margin: 0; border: 0; border-radius: 0; max-block-size: min(38dvh, 360px); overflow: auto; }
|
|
323
|
+
/* An empty step reads differently by state. Only workspaces set up before step files existed
|
|
324
|
+
have a finished step with nothing recorded; their text is still in the full log below. */
|
|
325
|
+
.step-log > pre:empty::before { content: 'No output yet.'; color: var(--muted); }
|
|
326
|
+
.step-log[data-step-state='pending'] > pre:empty::before { content: 'Waiting to start.'; }
|
|
327
|
+
.step-log[data-step-state='complete'] > pre:empty::before,
|
|
328
|
+
.step-log[data-step-state='failed'] > pre:empty::before { content: 'No output was recorded for this step. See the full setup log below.'; }
|
|
329
|
+
|
|
330
|
+
/* The status icon: a ring for waiting, a pulsing ring while running, a check, a cross. */
|
|
331
|
+
.step-log__status { flex: none; inline-size: 16px; block-size: 16px; border: 2px solid var(--muted); border-radius: 999px; position: relative; }
|
|
332
|
+
.step-log[data-step-state='active'] .step-log__status { border-color: var(--accent); background: color-mix(in oklab, var(--accent) 25%, transparent); animation: workspace-pulse 1.5s infinite; }
|
|
333
|
+
.step-log[data-step-state='complete'] .step-log__status { border-color: var(--success); background: var(--success); }
|
|
334
|
+
.step-log[data-step-state='complete'] .step-log__status::after { content: ''; position: absolute; inset-inline-start: 4px; inset-block-start: 1px; inline-size: 4px; block-size: 8px; border-inline-end: 2px solid var(--bg); border-block-end: 2px solid var(--bg); transform: rotate(40deg); }
|
|
335
|
+
.step-log[data-step-state='failed'] .step-log__status { border-color: var(--danger); background: var(--danger); }
|
|
336
|
+
.step-log[data-step-state='failed'] .step-log__status::before,
|
|
337
|
+
.step-log[data-step-state='failed'] .step-log__status::after { content: ''; position: absolute; inset-inline-start: 5px; inset-block-start: 2px; inline-size: 2px; block-size: 8px; background: var(--bg); transform: rotate(45deg); }
|
|
338
|
+
.step-log[data-step-state='failed'] .step-log__status::after { transform: rotate(-45deg); }
|
|
339
|
+
.step-log[data-step-state='failed'] > summary .step-log__name { color: color-mix(in oklab, var(--danger) 70%, var(--text)); }
|
|
340
|
+
.step-log[data-step-state='pending'] > summary .step-log__name { color: var(--muted); font-weight: 400; }
|
|
341
|
+
.step-log[data-step-state='unknown'] .step-log__status { border-style: dashed; }
|
|
342
|
+
@media (prefers-reduced-motion: reduce) { .step-log > summary, .step-log > summary::after, .step-log[data-step-state='active'] .step-log__status { transition: none; animation: none; } }
|
|
311
343
|
|
|
312
344
|
.env-panel {
|
|
313
345
|
display: grid;
|
|
@@ -466,7 +498,7 @@ fieldset:disabled {
|
|
|
466
498
|
.field { display: grid; gap: var(--space-2); max-inline-size: 28rem; font-size: 14px; }
|
|
467
499
|
.field input { inline-size: 100%; }
|
|
468
500
|
|
|
469
|
-
ol
|
|
501
|
+
ol > li + li { margin-block-start: var(--space-2); }
|
|
470
502
|
|
|
471
503
|
.button--success {
|
|
472
504
|
background: color-mix(in oklab, var(--success) 22%, transparent);
|
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
<div class="lead__text">
|
|
8
8
|
<h2 id="status" role="status"><%= escape(@presentation['headline']) %></h2>
|
|
9
9
|
<p id="summary" class="lead__summary"><%= escape(@presentation['summary']) %></p>
|
|
10
|
-
|
|
10
|
+
</div>
|
|
11
|
+
<div class="build-progress" id="build-progress" <%= 'hidden' unless @presentation['progress'] %>>
|
|
12
|
+
<div class="progress-bar" id="progress-bar" role="progressbar" aria-label="Setup progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= progress_percent %>" aria-valuetext="<%= escape(progress_valuetext) %>" data-slow="<%= @presentation.dig('progress', 'slow') ? 'true' : 'false' %>"><span class="progress-bar__fill" id="progress-fill" style="inline-size: <%= progress_percent %>%"></span></div>
|
|
13
|
+
<div class="progress-meta">
|
|
14
|
+
<p class="progress-meta__step"><span class="activity-dot" aria-hidden="true"></span><span id="progress-step"><%= escape(progress_step_text) %></span></p>
|
|
15
|
+
<p class="progress-meta__time muted"><span id="progress-percent"><%= progress_percent %>%</span><span aria-hidden="true"> · </span><span id="elapsed"><%= escape(progress_elapsed_text) %></span><span id="progress-eta"><%= escape(progress_eta_suffix) %></span></p>
|
|
16
|
+
</div>
|
|
11
17
|
</div>
|
|
12
18
|
<div class="lead__actions">
|
|
13
19
|
<a id="preview" class="button button--primary button--large" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
|
|
@@ -20,7 +26,7 @@
|
|
|
20
26
|
<p id="connection" role="status"></p>
|
|
21
27
|
</section>
|
|
22
28
|
<details class="status-panel disclosure" id="environment-disclosure" data-section="environment" <%= 'open' if @presentation.dig('open', 'environment') %> <%= 'hidden' unless section?('environment') %>>
|
|
23
|
-
<summary><span id="environment-title"><%= escape(@presentation['environment_title']) %></span><span class="disclosure__hint">Presets and environment variables</span></summary>
|
|
29
|
+
<summary><span id="environment-title"><%= escape(@presentation['environment_title']) %></span><span class="badge" id="environment-preset-badge" <%= 'hidden' unless @presentation['current_preset'] %>><%= escape(preset_badge_text) %></span><span class="disclosure__hint">Presets and environment variables</span></summary>
|
|
24
30
|
<div class="workspace-settings" aria-labelledby="environment-title">
|
|
25
31
|
<form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
|
|
26
32
|
<fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
|
|
@@ -51,11 +57,6 @@
|
|
|
51
57
|
<details class="status-panel disclosure" id="technical-disclosure" data-section="technical" <%= 'open' if @presentation.dig('open', 'technical') %> <%= 'hidden' unless section?('technical') %>>
|
|
52
58
|
<summary><span>Technical details</span><span class="disclosure__hint">Setup steps, logs and workspace controls</span></summary>
|
|
53
59
|
<div class="technical stack stack--loose">
|
|
54
|
-
<ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
|
|
55
|
-
<% @presentation['steps'].each do |step| %>
|
|
56
|
-
<li class="step" data-step-state="<%= step['state'] %>" <%= 'aria-current="step"' if step['state'] == 'active' %>><span class="step-bullet" aria-hidden="true"></span><span><%= escape(step['label']) %></span><span class="step-outcome"><%= escape(step_outcome(step)) %></span></li>
|
|
57
|
-
<% end %>
|
|
58
|
-
</ol>
|
|
59
60
|
<div class="workspace-actions-group stack stack--snug" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
|
|
60
61
|
<h3 id="actions-heading">Workspace controls</h3>
|
|
61
62
|
<div class="workspace-actions">
|
|
@@ -66,10 +67,17 @@
|
|
|
66
67
|
<section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
|
|
67
68
|
<div class="log-header"><h3 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h3><time id="updated"><%= field('updated_at') %></time></div>
|
|
68
69
|
<nav class="log-switch" aria-label="Log source">
|
|
69
|
-
<a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup
|
|
70
|
-
<a href="?log=backend" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %>>
|
|
70
|
+
<a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup steps</a>
|
|
71
|
+
<a href="?log=backend" id="backend-tab" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %> <%= 'hidden' unless backend_tab? %>>Web server log</a>
|
|
71
72
|
</nav>
|
|
72
|
-
<
|
|
73
|
+
<div id="steps" class="step-logs" data-step-logs <%= 'hidden' if @snapshot['log_source'] == 'backend' %>>
|
|
74
|
+
<% @presentation['steps'].each do |step| %><%= step_accordion(step) %><% end %>
|
|
75
|
+
<details class="step-log step-log--full" id="full-log" data-step-log="full">
|
|
76
|
+
<summary><span class="step-log__status" aria-hidden="true"></span><span class="step-log__name">Full setup log</span><span class="step-log__outcome" id="full-log-outcome"><%= escape(full_log_outcome) %></span></summary>
|
|
77
|
+
<pre tabindex="0" aria-label="Full setup log"><%= field('log_tail') if @snapshot['log_source'] != 'backend' %></pre>
|
|
78
|
+
</details>
|
|
79
|
+
</div>
|
|
80
|
+
<pre id="log" tabindex="0" aria-label="Web server log" <%= 'hidden' unless @snapshot['log_source'] == 'backend' %>><%= field('log_tail') if @snapshot['log_source'] == 'backend' %></pre>
|
|
73
81
|
</section>
|
|
74
82
|
<section class="stack stack--snug" data-section="details" <%= 'hidden' unless section?('details') %>>
|
|
75
83
|
<h3>Workspace details</h3>
|
|
@@ -2,6 +2,7 @@ require 'erb'
|
|
|
2
2
|
require 'json'
|
|
3
3
|
require_relative 'config'
|
|
4
4
|
require_relative 'presentation'
|
|
5
|
+
require_relative 'duration_format'
|
|
5
6
|
|
|
6
7
|
module Workspaces
|
|
7
8
|
class DashboardView
|
|
@@ -176,14 +177,75 @@ module Workspaces
|
|
|
176
177
|
"<button class=\"button #{style}\">#{escape(action.fetch('label'))}</button></form>"
|
|
177
178
|
end
|
|
178
179
|
|
|
180
|
+
# The Full setup log row's right-hand label: how the last run ended and how long it took.
|
|
181
|
+
def full_log_outcome
|
|
182
|
+
return 'in progress' if @presentation['busy']
|
|
183
|
+
|
|
184
|
+
total = @presentation['total']
|
|
185
|
+
text = total && DurationFormat.call(total['seconds'])
|
|
186
|
+
text ? "#{total['state']} · #{text}" : 'everything, in order'
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The bar's text, rendered on the server for the first paint; app.js keeps it current after that.
|
|
190
|
+
def progress
|
|
191
|
+
@presentation['progress'] || {}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def progress_percent
|
|
195
|
+
progress.fetch('percent', 0)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def progress_step_text
|
|
199
|
+
return '' if progress.empty?
|
|
200
|
+
|
|
201
|
+
"#{progress['step_text']} · #{progress['step_label']}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def progress_elapsed_text
|
|
205
|
+
DurationFormat.call(progress['elapsed_seconds']) || '—'
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def progress_eta_suffix
|
|
209
|
+
progress['eta_text'] ? " · #{progress['eta_text']}" : ''
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def progress_valuetext
|
|
213
|
+
return '' if progress.empty?
|
|
214
|
+
|
|
215
|
+
[progress_step_text, progress['eta_text']].compact.join('. ')
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def preset_badge_text
|
|
219
|
+
name = @presentation['current_preset']
|
|
220
|
+
name ? "Preset: #{name}" : ''
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def backend_tab?
|
|
224
|
+
@snapshot['log_source'] == 'backend' || @snapshot['backend_log_available']
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# One GitHub-Actions-style accordion per recipe step. A step that is running or has
|
|
228
|
+
# failed starts open; a completed or not-yet-started one starts closed.
|
|
229
|
+
def step_accordion(step)
|
|
230
|
+
key = escape(step['key'])
|
|
231
|
+
state = escape(step['state'])
|
|
232
|
+
entry = (@snapshot['step_logs'] || {})[step['key']]
|
|
233
|
+
open = %w(active failed).include?(step['state']) ? ' open' : ''
|
|
234
|
+
current = step['state'] == 'active' ? ' aria-current="step"' : ''
|
|
235
|
+
<<~HTML
|
|
236
|
+
<details class="step-log" data-step-log="#{key}" data-step-state="#{state}"#{open}#{current}>
|
|
237
|
+
<summary><span class="step-log__status" aria-hidden="true"></span><span class="step-log__name">#{escape(step['label'])}</span><span class="step-log__outcome step-outcome">#{escape(step_outcome(step))}</span></summary>
|
|
238
|
+
<pre tabindex="0" aria-label="#{escape(step['label'])} output">#{entry ? escape(entry['text']) : ''}</pre>
|
|
239
|
+
</details>
|
|
240
|
+
HTML
|
|
241
|
+
end
|
|
242
|
+
|
|
179
243
|
def step_outcome(step)
|
|
180
244
|
state = step['state'].to_s
|
|
181
245
|
return state unless %w(complete failed).include?(state)
|
|
182
246
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
"#{state} · #{format('%.3f', duration)}s"
|
|
247
|
+
text = DurationFormat.call(step['duration_seconds'])
|
|
248
|
+
text ? "#{state} · #{text}" : state
|
|
187
249
|
end
|
|
188
250
|
end
|
|
189
251
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Workspaces
|
|
4
|
+
# Human durations, written the way people read them: "0.5s", "12.3s", "1m 10s", "1h 5m 3s".
|
|
5
|
+
#
|
|
6
|
+
# The dashboard script (dashboard/app.js, formatDuration) implements the same rules, and a
|
|
7
|
+
# spec runs both against the same inputs so the page and the setup log cannot disagree.
|
|
8
|
+
module DurationFormat
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Returns nil for anything that is not a usable duration, so callers can fall back to
|
|
12
|
+
# showing no time at all instead of a wrong one.
|
|
13
|
+
def call(seconds)
|
|
14
|
+
return nil unless seconds.is_a?(Numeric) && seconds.finite? && seconds >= 0
|
|
15
|
+
|
|
16
|
+
return tenths(seconds) if seconds < 60
|
|
17
|
+
|
|
18
|
+
whole = seconds.round
|
|
19
|
+
hours, rest = whole.divmod(3600)
|
|
20
|
+
minutes, secs = rest.divmod(60)
|
|
21
|
+
return "#{hours}h #{minutes}m #{secs}s" if hours.positive?
|
|
22
|
+
|
|
23
|
+
"#{minutes}m #{secs}s"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Under a minute a decimal is still useful (0.5s reads better than 1s), but a trailing
|
|
27
|
+
# ".0" is noise: 12.0 is "12s".
|
|
28
|
+
def tenths(seconds)
|
|
29
|
+
rounded = (seconds * 10).round / 10.0
|
|
30
|
+
# 59.96 rounds to 60.0, which belongs on the minutes side.
|
|
31
|
+
return '1m 0s' if rounded >= 60
|
|
32
|
+
|
|
33
|
+
rounded == rounded.to_i ? "#{rounded.to_i}s" : "#{rounded}s"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/workspaces/paths.rb
CHANGED
|
@@ -27,6 +27,11 @@ module Workspaces
|
|
|
27
27
|
home.join('state.json')
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Every finished run, one JSON object per line, shared by all workspaces of this project.
|
|
31
|
+
def build_history_path
|
|
32
|
+
home.join('build-history.jsonl')
|
|
33
|
+
end
|
|
34
|
+
|
|
30
35
|
def log_path(workspace_id, create: true)
|
|
31
36
|
setup_log_path(workspace_id, create: create)
|
|
32
37
|
end
|
|
@@ -35,6 +40,17 @@ module Workspaces
|
|
|
35
40
|
logs_dir(create: create).join("#{workspace_id}.setup.log")
|
|
36
41
|
end
|
|
37
42
|
|
|
43
|
+
# One file per recipe step, written as the step runs. The combined setup log is unchanged.
|
|
44
|
+
def step_logs_dir(workspace_id, create: true)
|
|
45
|
+
logs_dir(create: create).join("#{workspace_id}.steps").tap { |path| path.mkpath if create }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def step_log_path(workspace_id, step_id, create: true)
|
|
49
|
+
raise ArgumentError, 'Unsafe step id' unless step_id.to_s.match?(/\A[a-z][a-z0-9_-]*\z/)
|
|
50
|
+
|
|
51
|
+
step_logs_dir(workspace_id, create: create).join("#{step_id}.log")
|
|
52
|
+
end
|
|
53
|
+
|
|
38
54
|
def backend_log_path(workspace_id, create: true)
|
|
39
55
|
logs_dir(create: create).join("#{workspace_id}.backend.log")
|
|
40
56
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative 'progress'
|
|
2
|
+
|
|
1
3
|
module Workspaces
|
|
2
4
|
class Presentation
|
|
3
5
|
TITLES = { 'idle' => 'Ready to start', 'stopped' => 'Server stopped', 'ready' => 'Workspace ready',
|
|
@@ -10,9 +12,10 @@ module Workspaces
|
|
|
10
12
|
# most one primary action. `environment` (presets and variables) is the area for
|
|
11
13
|
# deliberately changing something. `technical` is one disclosure holding setup steps,
|
|
12
14
|
# logs, workspace details, lifecycle controls and deletion; it is never the first thing
|
|
13
|
-
# a user reads, but it opens by itself when something needs attention.
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
# a user reads, but it opens by itself when something needs attention. Setup steps are
|
|
16
|
+
# not a section of their own: each is an accordion with its own log inside `log`.
|
|
17
|
+
SECTIONS = %w(lead actions environment technical log details danger).freeze
|
|
18
|
+
TECHNICAL = %w(technical actions log details danger).freeze
|
|
16
19
|
|
|
17
20
|
# Workspace lifecycle state machine. Each state declares only what is usable and
|
|
18
21
|
# meaningful there; everything else is hidden rather than shown disabled.
|
|
@@ -38,9 +41,9 @@ module Workspaces
|
|
|
38
41
|
open_environment: true, open_technical: false
|
|
39
42
|
},
|
|
40
43
|
'busy' => {
|
|
41
|
-
sections: %w(lead technical
|
|
44
|
+
sections: %w(lead technical log),
|
|
42
45
|
headline: 'Setting things up…',
|
|
43
|
-
summary: "
|
|
46
|
+
summary: "We'll take you to the preview as soon as it's ready.",
|
|
44
47
|
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
45
48
|
open_environment: false, open_technical: true
|
|
46
49
|
},
|
|
@@ -90,7 +93,8 @@ module Workspaces
|
|
|
90
93
|
'actions' => actions, 'ready' => status == 'ready' && !busy?,
|
|
91
94
|
'state' => state, 'sections' => sections,
|
|
92
95
|
'headline' => definition[:headline], 'summary' => definition[:summary],
|
|
93
|
-
'
|
|
96
|
+
'lead_action' => lead_action, 'total' => total, 'progress' => progress,
|
|
97
|
+
'current_preset' => current_preset,
|
|
94
98
|
'environment_title' => definition[:environment_title],
|
|
95
99
|
'environment_submit' => definition[:environment_submit],
|
|
96
100
|
'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
|
|
@@ -117,8 +121,7 @@ module Workspaces
|
|
|
117
121
|
end
|
|
118
122
|
|
|
119
123
|
def sections
|
|
120
|
-
visible = definition[:sections]
|
|
121
|
-
visible.delete('progress') if steps.empty? || %w(idle stopped).include?(state)
|
|
124
|
+
visible = definition[:sections]
|
|
122
125
|
SECTIONS.to_h { |name| [name, visible.include?(name)] }
|
|
123
126
|
end
|
|
124
127
|
|
|
@@ -132,11 +135,38 @@ module Workspaces
|
|
|
132
135
|
end
|
|
133
136
|
end
|
|
134
137
|
|
|
135
|
-
#
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
# The preset whose values exactly equal the workspace's saved editable settings, or nil when
|
|
139
|
+
# there are no presets or the settings match none (for example after a hand edit). Presets
|
|
140
|
+
# arrive already completed with the recipe defaults, so equality is a plain comparison.
|
|
141
|
+
def current_preset
|
|
142
|
+
presets = @snapshot['environment_presets']
|
|
143
|
+
current = @snapshot['editable_environment']
|
|
144
|
+
return nil unless presets.is_a?(Hash) && current.is_a?(Hash) && !current.empty?
|
|
145
|
+
|
|
146
|
+
normalized = stringify(current)
|
|
147
|
+
presets.find { |_name, values| values.is_a?(Hash) && stringify(values) == normalized }&.first&.to_s
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def stringify(values)
|
|
151
|
+
values.to_h { |name, value| [name.to_s, value.to_s] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# How long the last run took and how it ended, or nil while one is in progress or when no
|
|
155
|
+
# total was recorded (interrupted runs, and workspaces set up before totals were persisted).
|
|
156
|
+
def total
|
|
157
|
+
seconds = @snapshot['total_seconds']
|
|
158
|
+
return nil if busy? || !seconds.is_a?(Numeric)
|
|
159
|
+
|
|
160
|
+
failed = status == 'error' || steps.any? { |step| step['state'] == 'failed' }
|
|
161
|
+
{ 'state' => failed ? 'failed' : 'complete', 'seconds' => seconds }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The progress bar's numbers, only while a build is being prepared (not for a lone command
|
|
165
|
+
# on a ready workspace, which has no steps of its own to measure).
|
|
166
|
+
def progress
|
|
167
|
+
return nil unless status == 'preparing' && !steps.empty?
|
|
138
168
|
|
|
139
|
-
@snapshot['
|
|
169
|
+
Progress.new(steps: steps, estimates: @snapshot['estimates'], started_at: @snapshot['prepare_started_at']).to_h
|
|
140
170
|
end
|
|
141
171
|
|
|
142
172
|
def title
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative 'duration_format'
|
|
5
|
+
|
|
6
|
+
module Workspaces
|
|
7
|
+
# A deliberately simple read on how far along a run is, for a progress bar.
|
|
8
|
+
#
|
|
9
|
+
# With history: each step carries weight equal to its usual duration, finished steps count in
|
|
10
|
+
# full, and the running step counts by how much of its usual time has passed. Without history
|
|
11
|
+
# every step weighs the same and only finished steps count. The bar never claims 100% while
|
|
12
|
+
# work remains, and a step that overruns its usual time is held back so the bar waits for it.
|
|
13
|
+
class Progress
|
|
14
|
+
MIN_WEIGHT = 1.0
|
|
15
|
+
MAX_PERCENT = 99
|
|
16
|
+
STEP_CAP = 0.95
|
|
17
|
+
SLOW_FACTOR = 1.5
|
|
18
|
+
|
|
19
|
+
def initialize(steps:, estimates:, started_at:, now: Time.now)
|
|
20
|
+
@steps = steps
|
|
21
|
+
@estimates = estimates || {}
|
|
22
|
+
@started_at = parse(started_at)
|
|
23
|
+
@now = now
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
return nil if @steps.empty?
|
|
28
|
+
|
|
29
|
+
{ 'percent' => percent, 'step_label' => current_step['label'], 'step_text' => "Step #{current_index + 1} of #{@steps.length}",
|
|
30
|
+
'elapsed_seconds' => elapsed, 'eta_text' => eta_text, 'slow' => slow?, 'estimated' => estimated? }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def percent
|
|
36
|
+
done = 0.0
|
|
37
|
+
@steps.each_with_index do |step, index|
|
|
38
|
+
case step['state']
|
|
39
|
+
when 'complete' then done += weights[index]
|
|
40
|
+
when 'active' then done += weights[index] * active_fraction(step, weights[index])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
[(done / weights.sum * 100).floor, MAX_PERCENT].min
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def active_fraction(step, weight)
|
|
47
|
+
return 0.0 unless estimated?
|
|
48
|
+
|
|
49
|
+
[step_elapsed(step) / weight, STEP_CAP].min
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def estimated?
|
|
53
|
+
@steps.any? { |step| estimate(step) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def estimate(step)
|
|
57
|
+
value = @estimates[step['key']]
|
|
58
|
+
value.is_a?(Numeric) && value.positive? ? [value, MIN_WEIGHT].max : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A step with no history of its own borrows the average of the ones that have it.
|
|
62
|
+
def weights
|
|
63
|
+
@weights ||= begin
|
|
64
|
+
known = @steps.filter_map { |step| estimate(step) }
|
|
65
|
+
fallback = known.empty? ? 1.0 : known.sum / known.length
|
|
66
|
+
@steps.map { |step| estimate(step) || fallback }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def current_index
|
|
71
|
+
@current_index ||= @steps.index { |step| step['state'] == 'active' } ||
|
|
72
|
+
@steps.index { |step| step['state'] == 'pending' } || (@steps.length - 1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def current_step
|
|
76
|
+
@steps[current_index]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def step_elapsed(step)
|
|
80
|
+
started = parse(step['started_at'])
|
|
81
|
+
started ? [@now - started, 0].max : 0.0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def slow?
|
|
85
|
+
step = @steps.find { |item| item['state'] == 'active' }
|
|
86
|
+
estimated? && !step.nil? && step_elapsed(step) > weights[@steps.index(step)] * SLOW_FACTOR
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def remaining
|
|
90
|
+
@steps.each_with_index.sum do |step, index|
|
|
91
|
+
case step['state']
|
|
92
|
+
when 'pending' then weights[index]
|
|
93
|
+
when 'active' then [weights[index] - step_elapsed(step), 0].max
|
|
94
|
+
else 0
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def eta_text
|
|
100
|
+
return nil unless estimated?
|
|
101
|
+
return 'Taking longer than usual' if slow?
|
|
102
|
+
return 'Almost done' if remaining < 5
|
|
103
|
+
|
|
104
|
+
"About #{DurationFormat.call(round_eta(remaining))} left"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A countdown that changes by single seconds reads as noise; round to what a person would say.
|
|
108
|
+
def round_eta(seconds)
|
|
109
|
+
seconds >= 60 ? (seconds / 10.0).round * 10 : (seconds / 5.0).round * 5
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def elapsed
|
|
113
|
+
@started_at ? [(@now - @started_at).floor, 0].max : nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse(value)
|
|
117
|
+
value && Time.parse(value.to_s)
|
|
118
|
+
rescue ArgumentError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Workspaces
|
|
4
|
+
# Splits a run's output by recipe step.
|
|
5
|
+
#
|
|
6
|
+
# Wraps the combined log (a TeeLog, or anything with puts/print). Everything still goes to
|
|
7
|
+
# the combined log exactly as before; while a step is current, the same text is also written
|
|
8
|
+
# to that step's own file so the dashboard can show each step under its own heading.
|
|
9
|
+
#
|
|
10
|
+
# Text arriving through this object has already been scrubbed by the wrapped log, so we hand
|
|
11
|
+
# it the raw text and copy what it actually wrote, rather than scrubbing twice.
|
|
12
|
+
class StepLog
|
|
13
|
+
def initialize(log, workspace_id)
|
|
14
|
+
@log = log
|
|
15
|
+
@workspace_id = workspace_id
|
|
16
|
+
@file = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Begins a step's file, truncating any output from an earlier run of the same step.
|
|
20
|
+
def begin_step(step_id)
|
|
21
|
+
finish_step
|
|
22
|
+
@file = File.open(Config.step_log_path(@workspace_id, step_id), 'w')
|
|
23
|
+
@file.sync = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def finish_step
|
|
27
|
+
@file&.close
|
|
28
|
+
@file = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def puts(message = '')
|
|
32
|
+
text = scrubbed(message)
|
|
33
|
+
@log.puts(message)
|
|
34
|
+
@file&.puts(text)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def print(message)
|
|
38
|
+
text = scrubbed(message)
|
|
39
|
+
@log.print(message)
|
|
40
|
+
@file&.print(text)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Clears every step file; called when a run begins so a restart never shows stale steps.
|
|
44
|
+
def self.reset(workspace_id)
|
|
45
|
+
dir = Config.step_logs_dir(workspace_id, create: false)
|
|
46
|
+
Dir.glob(dir.join('*.log')).each { |path| File.delete(path) } if dir.directory?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def scrubbed(message)
|
|
52
|
+
@log.respond_to?(:scrub_text) ? @log.scrub_text(message) : message.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|