workspaces 0.1.10 → 0.1.12

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.
@@ -148,7 +148,81 @@ h1 {
148
148
  .log-panel,
149
149
  .danger-zone { gap: var(--space-3); }
150
150
 
151
- details.status-panel[open] > summary { margin-block-end: var(--space-4); }
151
+
152
+ /* Lead: a friendly headline, one sentence, and at most one big button. */
153
+ .lead { gap: var(--space-4); padding-block: var(--space-8); }
154
+ .lead__text { display: grid; gap: var(--space-2); }
155
+ .lead h2 { font-size: clamp(1.4rem, 2vw + 1rem, 1.9rem); line-height: 1.2; }
156
+ .lead__summary { max-inline-size: 38rem; color: var(--muted); font-size: 1.05rem; line-height: 1.5; }
157
+ .lead__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
158
+ .lead__action:empty { display: none; }
159
+ .button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius); }
160
+
161
+ /* Disclosures: quiet by default, a clear label and a one-line hint of what is inside. */
162
+ /* A disclosure has to read as a control. The native marker is lost once a summary is a flex
163
+ container, so draw our own chevron; the whole header is the click target, with a hover
164
+ fill and a keyboard focus ring. */
165
+ /* Two classes so the narrow-screen panel padding below cannot re-add padding around the summary. */
166
+ .status-panel.disclosure { padding: 0; }
167
+ .disclosure > summary {
168
+ display: flex;
169
+ flex-wrap: wrap;
170
+ align-items: baseline;
171
+ gap: var(--space-1) var(--space-3);
172
+ padding: var(--space-4) calc(var(--space-6) + var(--space-8)) var(--space-4) var(--panel-padding);
173
+ position: relative;
174
+ list-style: none;
175
+ color: var(--text);
176
+ font-weight: 600;
177
+ border-radius: var(--radius);
178
+ cursor: pointer;
179
+ user-select: none;
180
+ transition: background-color 120ms ease;
181
+ }
182
+ .disclosure > summary::-webkit-details-marker { display: none; }
183
+ .disclosure > summary::after {
184
+ content: '';
185
+ position: absolute;
186
+ inset-inline-end: var(--panel-padding);
187
+ inset-block-start: 50%;
188
+ inline-size: 8px;
189
+ block-size: 8px;
190
+ margin-block-start: -6px;
191
+ border-inline-end: 2px solid var(--muted);
192
+ border-block-end: 2px solid var(--muted);
193
+ transform: rotate(45deg);
194
+ transition: transform 160ms ease, border-color 120ms ease;
195
+ }
196
+ .disclosure[open] > summary::after { margin-block-start: -2px; transform: rotate(225deg); }
197
+ .disclosure > summary:hover { background: color-mix(in oklab, var(--accent) 8%, transparent); }
198
+ .disclosure > summary:hover::after { border-color: var(--text); }
199
+ .disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
200
+ .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
+ .disclosure > :not(summary) { margin: var(--panel-padding); }
202
+ /* Which preset is active, readable without opening the section. Same tint as a pressed preset button. */
203
+ .badge {
204
+ display: inline-flex;
205
+ align-items: center;
206
+ gap: var(--space-2);
207
+ padding: var(--space-1) var(--space-3);
208
+ border: 1px solid color-mix(in oklab, var(--success) 55%, var(--border));
209
+ border-radius: 999px;
210
+ background: color-mix(in oklab, var(--success) 22%, transparent);
211
+ color: var(--text);
212
+ font-size: 12px;
213
+ font-weight: 600;
214
+ line-height: 1.4;
215
+ white-space: nowrap;
216
+ }
217
+ .badge::before { content: ''; inline-size: 6px; block-size: 6px; border-radius: 999px; background: var(--success); }
218
+ .disclosure__hint { color: var(--muted); font-size: 13px; font-weight: 400; }
219
+ @media (prefers-reduced-motion: reduce) { .disclosure > summary, .disclosure > summary::after { transition: none; } }
220
+ .disclosure .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
221
+ .disclosure .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
222
+ .disclosure .log-panel { padding: 0; }
223
+ .log-panel > #log { margin-block-start: 0; }
224
+ .disclosure .danger-zone h3 { color: var(--danger); font-size: 1rem; }
225
+ .disclosure h3 { font-size: 1rem; }
152
226
 
153
227
  .status-row {
154
228
  display: grid;
@@ -197,60 +271,64 @@ details.status-panel[open] > summary { margin-block-end: var(--space-4); }
197
271
  color: var(--text);
198
272
  }
199
273
 
200
- .stepper {
201
- margin: 0;
202
- padding: 0;
203
- list-style: none;
204
- display: grid;
205
- gap: var(--space-2);
206
- }
207
-
208
- .step {
274
+ /* Step logs, in the style of GitHub Actions: one row per recipe step with a status icon,
275
+ its name, how long it took, and a chevron. Open a row to read just that step's output. */
276
+ .step-logs { display: grid; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
277
+ .step-log + .step-log { border-block-start: 1px solid var(--border); }
278
+ .step-log > summary {
209
279
  display: flex;
210
280
  align-items: center;
211
281
  gap: var(--space-3);
212
- color: var(--muted);
213
- }
214
-
215
- .step-bullet {
216
- inline-size: 10px;
217
- block-size: 10px;
218
- border: 2px solid var(--muted);
219
- border-radius: 999px;
220
- }
221
-
222
- .step[data-step-state='active'],
223
- .step[data-step-state='complete'] {
282
+ padding: var(--space-3) var(--space-4);
283
+ position: relative;
284
+ list-style: none;
224
285
  color: var(--text);
225
- }
226
-
227
- .step[data-step-state='active'] .step-bullet {
228
- border-color: var(--accent);
229
- background: color-mix(in oklab, var(--accent) 25%, transparent);
230
- }
231
-
232
- .step[data-step-state='complete'] .step-bullet {
233
- border-color: var(--success);
234
- background: var(--success);
235
- }
236
-
237
- .step[data-step-state='failed'] {
238
- color: color-mix(in oklab, var(--danger) 70%, var(--text));
239
- }
240
-
241
- .step[data-step-state='failed'] .step-bullet {
242
- border-color: var(--danger);
243
- background: color-mix(in oklab, var(--danger) 45%, transparent);
244
- }
245
-
246
- .step[data-step-state='unknown'] {
247
- color: color-mix(in oklab, var(--muted) 80%, var(--text));
248
- }
249
-
250
- .step[data-step-state='unknown'] .step-bullet {
251
- border-style: dashed;
252
- background: transparent;
253
- }
286
+ cursor: pointer;
287
+ user-select: none;
288
+ transition: background-color 120ms ease;
289
+ }
290
+ .step-log > summary::-webkit-details-marker { display: none; }
291
+ .step-log > summary:hover { background: color-mix(in oklab, var(--accent) 8%, transparent); }
292
+ .step-log > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
293
+ .step-log > summary::after {
294
+ content: '';
295
+ flex: none;
296
+ inline-size: 7px;
297
+ block-size: 7px;
298
+ margin-inline-start: var(--space-2);
299
+ margin-block-start: -3px;
300
+ border-inline-end: 2px solid var(--muted);
301
+ border-block-end: 2px solid var(--muted);
302
+ transform: rotate(-45deg);
303
+ transition: transform 160ms ease;
304
+ }
305
+ .step-log[open] > summary::after { margin-block-start: -3px; transform: rotate(45deg); }
306
+ .step-log[open] > summary { border-block-end: 1px solid var(--border); }
307
+ .step-log__name { flex: 1; min-inline-size: 0; overflow-wrap: anywhere; font-weight: 600; }
308
+ .step-log__outcome { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
309
+ .step-log--full > summary { color: var(--muted); }
310
+ .step-log--full .step-log__name { font-weight: 400; }
311
+ .step-log > pre { margin: 0; border: 0; border-radius: 0; max-block-size: min(38dvh, 360px); overflow: auto; }
312
+ /* An empty step reads differently by state. Only workspaces set up before step files existed
313
+ have a finished step with nothing recorded; their text is still in the full log below. */
314
+ .step-log > pre:empty::before { content: 'No output yet.'; color: var(--muted); }
315
+ .step-log[data-step-state='pending'] > pre:empty::before { content: 'Waiting to start.'; }
316
+ .step-log[data-step-state='complete'] > pre:empty::before,
317
+ .step-log[data-step-state='failed'] > pre:empty::before { content: 'No output was recorded for this step. See the full setup log below.'; }
318
+
319
+ /* The status icon: a ring for waiting, a pulsing ring while running, a check, a cross. */
320
+ .step-log__status { flex: none; inline-size: 16px; block-size: 16px; border: 2px solid var(--muted); border-radius: 999px; position: relative; }
321
+ .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; }
322
+ .step-log[data-step-state='complete'] .step-log__status { border-color: var(--success); background: var(--success); }
323
+ .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); }
324
+ .step-log[data-step-state='failed'] .step-log__status { border-color: var(--danger); background: var(--danger); }
325
+ .step-log[data-step-state='failed'] .step-log__status::before,
326
+ .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); }
327
+ .step-log[data-step-state='failed'] .step-log__status::after { transform: rotate(-45deg); }
328
+ .step-log[data-step-state='failed'] > summary .step-log__name { color: color-mix(in oklab, var(--danger) 70%, var(--text)); }
329
+ .step-log[data-step-state='pending'] > summary .step-log__name { color: var(--muted); font-weight: 400; }
330
+ .step-log[data-step-state='unknown'] .step-log__status { border-style: dashed; }
331
+ @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; } }
254
332
 
255
333
  .env-panel {
256
334
  display: grid;
@@ -409,7 +487,7 @@ fieldset:disabled {
409
487
  .field { display: grid; gap: var(--space-2); max-inline-size: 28rem; font-size: 14px; }
410
488
  .field input { inline-size: 100%; }
411
489
 
412
- ol:not(.stepper) > li + li { margin-block-start: var(--space-2); }
490
+ ol > li + li { margin-block-start: var(--space-2); }
413
491
 
414
492
  .button--success {
415
493
  background: color-mix(in oklab, var(--success) 22%, transparent);
@@ -1,37 +1,27 @@
1
- <main class="dashboard" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>">
1
+ <main class="dashboard" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>" data-disclosure-state="<%= escape(@presentation['state']) %>">
2
2
  <header class="dashboard__header">
3
- <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p class="muted"><%= field('branch') %></p></div>
3
+ <div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1></div>
4
4
  <a class="button button--subtle" href="/workspaces">All workspaces</a>
5
5
  </header>
6
- <section class="status-panel" aria-labelledby="status">
7
- <div class="status-row">
8
- <h2 id="status" role="status"><%= escape(@presentation['title']) %></h2>
9
- <span id="activity" <%= 'hidden' unless @presentation['busy'] %>><span class="activity-dot" aria-hidden="true"></span> In progress · <span id="elapsed">—</span></span>
6
+ <section class="status-panel lead" id="lead" data-section="lead" aria-labelledby="status" <%= 'hidden' unless section?('lead') %>>
7
+ <div class="lead__text">
8
+ <h2 id="status" role="status"><%= escape(@presentation['headline']) %></h2>
9
+ <p id="summary" class="lead__summary"><%= escape(@presentation['summary']) %></p>
10
+ <p id="subtitle" class="muted" <%= 'hidden' unless @presentation['subtitle'] %>><span id="subtitle-text"><%= escape(@presentation['subtitle']) %></span><span id="activity" <%= 'hidden' unless @presentation['busy'] %>> · <span class="activity-dot" aria-hidden="true"></span> <span id="elapsed">—</span></span></p>
10
11
  </div>
11
- <p id="hint" <%= 'hidden' unless @presentation['hint'] %>><%= escape(@presentation['hint']) %></p>
12
- <p id="message" <%= 'hidden' if @presentation['state'] == 'idle' %>><%= field('message') %></p>
13
- <ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
14
- <% @presentation['steps'].each do |step| %>
15
- <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>
16
- <% end %>
17
- </ol>
12
+ <div class="lead__actions">
13
+ <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>
14
+ <div id="lead-action" class="lead__action"><%= lead_action_form(@presentation['lead_action']) %></div>
15
+ </div>
16
+ <p id="message" class="muted" hidden><%= field('message') %></p>
18
17
  <section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
19
18
  <h3>What went wrong</h3><pre id="error"><%= escape(@presentation['error']) %></pre>
20
19
  </section>
21
20
  <p id="connection" role="status"></p>
22
21
  </section>
23
- <section class="status-panel controls-panel" id="controls" data-section-group aria-labelledby="actions-settings-title" <%= 'hidden' unless section?('actions') || section?('environment') %>>
24
- <h2 id="actions-settings-title"><%= escape(@presentation['controls_title']) %></h2>
25
- <div class="workspace-actions-group" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
26
- <h3 id="actions-heading">Workspace actions</h3>
27
- <div class="workspace-actions">
28
- <a id="preview" class="button button--primary" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
29
- <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
30
- </div>
31
- <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
32
- </div>
33
- <div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
34
- <h3 id="environment-overrides">Environment</h3>
22
+ <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="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
+ <div class="workspace-settings" aria-labelledby="environment-title">
35
25
  <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
36
26
  <fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
37
27
  <legend class="env-legend">Workspace settings</legend>
@@ -57,25 +47,47 @@
57
47
  <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
58
48
  </form>
59
49
  </div>
60
- </section>
61
- <section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
62
- <div class="log-header"><h2 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h2><time id="updated"><%= field('updated_at') %></time></div>
63
- <nav class="log-switch" aria-label="Log source">
64
- <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
65
- <a href="?log=backend" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %>>Backend log</a>
66
- </nav>
67
- <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
68
- </section>
69
- <details class="status-panel" data-section="details" <%= 'hidden' unless section?('details') %>><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl></details>
70
- <section class="status-panel danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
71
- <h2 id="danger-heading">Danger Zone</h2>
72
- <p>Deleting this workspace stops its server and removes its worktree. <strong>Discard uncommitted and untracked changes</strong> permanently. The branch and committed work are preserved.</p>
73
- <form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
74
- <label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
75
- <input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
76
- <p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
77
- <button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
78
- </form>
79
- </section>
50
+ </details>
51
+ <details class="status-panel disclosure" id="technical-disclosure" data-section="technical" <%= 'open' if @presentation.dig('open', 'technical') %> <%= 'hidden' unless section?('technical') %>>
52
+ <summary><span>Technical details</span><span class="disclosure__hint">Setup steps, logs and workspace controls</span></summary>
53
+ <div class="technical stack stack--loose">
54
+ <div class="workspace-actions-group stack stack--snug" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
55
+ <h3 id="actions-heading">Workspace controls</h3>
56
+ <div class="workspace-actions">
57
+ <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
58
+ </div>
59
+ <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
60
+ </div>
61
+ <section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
62
+ <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>
63
+ <nav class="log-switch" aria-label="Log source">
64
+ <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup steps</a>
65
+ <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>
66
+ </nav>
67
+ <div id="steps" class="step-logs" data-step-logs <%= 'hidden' if @snapshot['log_source'] == 'backend' %>>
68
+ <% @presentation['steps'].each do |step| %><%= step_accordion(step) %><% end %>
69
+ <details class="step-log step-log--full" id="full-log" data-step-log="full">
70
+ <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>
71
+ <pre tabindex="0" aria-label="Full setup log"><%= field('log_tail') if @snapshot['log_source'] != 'backend' %></pre>
72
+ </details>
73
+ </div>
74
+ <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>
75
+ </section>
76
+ <section class="stack stack--snug" data-section="details" <%= 'hidden' unless section?('details') %>>
77
+ <h3>Workspace details</h3>
78
+ <dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl>
79
+ </section>
80
+ <section class="danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
81
+ <h3 id="danger-heading">Danger Zone</h3>
82
+ <p>Deleting this workspace stops its server and removes its worktree. <strong>Discard uncommitted and untracked changes</strong> permanently. The branch and committed work are preserved.</p>
83
+ <form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
84
+ <label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
85
+ <input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
86
+ <p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
87
+ <button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
88
+ </form>
89
+ </section>
90
+ </div>
91
+ </details>
80
92
  <noscript><a href="/workspaces/<%= @id %>">Refresh status</a></noscript>
81
93
  </main>
@@ -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
@@ -161,20 +162,61 @@ module Workspaces
161
162
  JSON.generate(environment_presets)
162
163
  end
163
164
 
165
+ # The lead's primary action. `open` is rendered separately as a link (it needs the
166
+ # preview URL and must stay a real anchor), so only lifecycle posts produce a form.
167
+ def lead_action_form(action)
168
+ return '' unless action && action['kind'] == 'post'
169
+
170
+ "<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
171
+ "<button class=\"button button--primary button--large\">#{escape(action.fetch('label'))}</button></form>"
172
+ end
173
+
164
174
  def action_form(action)
165
175
  style = action['primary'] ? 'button--primary' : 'button--subtle'
166
176
  "<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
167
177
  "<button class=\"button #{style}\">#{escape(action.fetch('label'))}</button></form>"
168
178
  end
169
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
+ def preset_badge_text
190
+ name = @presentation['current_preset']
191
+ name ? "Preset: #{name}" : ''
192
+ end
193
+
194
+ def backend_tab?
195
+ @snapshot['log_source'] == 'backend' || @snapshot['backend_log_available']
196
+ end
197
+
198
+ # One GitHub-Actions-style accordion per recipe step. A step that is running or has
199
+ # failed starts open; a completed or not-yet-started one starts closed.
200
+ def step_accordion(step)
201
+ key = escape(step['key'])
202
+ state = escape(step['state'])
203
+ entry = (@snapshot['step_logs'] || {})[step['key']]
204
+ open = %w(active failed).include?(step['state']) ? ' open' : ''
205
+ current = step['state'] == 'active' ? ' aria-current="step"' : ''
206
+ <<~HTML
207
+ <details class="step-log" data-step-log="#{key}" data-step-state="#{state}"#{open}#{current}>
208
+ <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>
209
+ <pre tabindex="0" aria-label="#{escape(step['label'])} output">#{entry ? escape(entry['text']) : ''}</pre>
210
+ </details>
211
+ HTML
212
+ end
213
+
170
214
  def step_outcome(step)
171
215
  state = step['state'].to_s
172
216
  return state unless %w(complete failed).include?(state)
173
217
 
174
- duration = step['duration_seconds']
175
- return state unless duration.is_a?(Numeric) && duration.finite? && duration >= 0
176
-
177
- "#{state} · #{format('%.3f', duration)}s"
218
+ text = DurationFormat.call(step['duration_seconds'])
219
+ text ? "#{state} · #{text}" : state
178
220
  end
179
221
  end
180
222
  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
@@ -35,6 +35,17 @@ module Workspaces
35
35
  logs_dir(create: create).join("#{workspace_id}.setup.log")
36
36
  end
37
37
 
38
+ # One file per recipe step, written as the step runs. The combined setup log is unchanged.
39
+ def step_logs_dir(workspace_id, create: true)
40
+ logs_dir(create: create).join("#{workspace_id}.steps").tap { |path| path.mkpath if create }
41
+ end
42
+
43
+ def step_log_path(workspace_id, step_id, create: true)
44
+ raise ArgumentError, 'Unsafe step id' unless step_id.to_s.match?(/\A[a-z][a-z0-9_-]*\z/)
45
+
46
+ step_logs_dir(workspace_id, create: create).join("#{step_id}.log")
47
+ end
48
+
38
49
  def backend_log_path(workspace_id, create: true)
39
50
  logs_dir(create: create).join("#{workspace_id}.backend.log")
40
51
  end