workspaces 0.1.11 → 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.
- checksums.yaml +4 -4
- data/DESIGN.md +7 -3
- data/README.md +7 -3
- data/lib/workspaces/config.rb +3 -0
- data/lib/workspaces/coordinator.rb +54 -8
- data/lib/workspaces/dashboard/app.js +124 -32
- data/lib/workspaces/dashboard/styles.css +71 -50
- data/lib/workspaces/dashboard/template.html.erb +11 -9
- data/lib/workspaces/dashboard_view.rb +37 -4
- data/lib/workspaces/duration_format.rb +36 -0
- data/lib/workspaces/paths.rb +11 -0
- data/lib/workspaces/presentation.rb +34 -7
- 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 +13 -3
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1425a9356962c4990de3a5a0f5ffb1b8660325bcff165bfb8336fb7c468696ef
|
|
4
|
+
data.tar.gz: 878aff1b14d9ec8f13902a9ac3abd35e47cce3536d640d25b15a8e5a50426936
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '059361fdfac7d7328f4f50d31efeb647656375582a4d178f4f0d6f7c62affae21eaf53f67cf9590da272f29803fdefa904ebba36e18c8b570b97b12ad9c86ca3'
|
|
7
|
+
data.tar.gz: c623574a0d2105f00b22ed460c597941e09fead329a335d47f01c9967d65ec84da098e23c08c4fdb0ace7358c6d7c929f4d5fa6a228bf21fb12a960a75d4b8c8
|
data/DESIGN.md
CHANGED
|
@@ -50,8 +50,8 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
50
50
|
- **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
|
|
51
51
|
- **Three layers**: the workspace page leads with the friendly, the deliberate, then the technical. Each layer is shown or collapsed by lifecycle state, driven by `Presentation::STATES`:
|
|
52
52
|
1. **Lead** (always visible): a plain-language headline, one sentence, and at most one primary action (a large button). The setup steps never appear here.
|
|
53
|
-
2. **Settings** (a `<details>` titled *Change settings*, or *Choose what to preview* on a brand new workspace): presets first, as buttons, then the environment variables. The one submit button names what it does (Start workspace, Save & restart, Save & start, Save & retry). It starts open only for a new workspace.
|
|
54
|
-
3. **Technical details** (one `<details>`): setup
|
|
53
|
+
2. **Settings** (a `<details>` titled *Change settings*, with a *Preset: name* badge in its header whenever the saved settings exactly match a preset, so the choice is visible while it is closed, or *Choose what to preview* on a brand new workspace): presets first, as buttons, then the environment variables. The one submit button names what it does (Start workspace, Save & restart, Save & start, Save & retry). It starts open only for a new workspace.
|
|
54
|
+
3. **Technical details** (one `<details>`): setup step logs with timings, the web server log, workspace controls (rebuild, restart, stop, update from PR), preview URL, branch and path, and the Danger Zone. It starts collapsed, and opens by itself while setup runs or after a failure so a cause is never hidden.
|
|
55
55
|
- **Lead by state**:
|
|
56
56
|
- `idle`: "Let's get your workspace running", with the settings open and *Start workspace* in the form.
|
|
57
57
|
- `busy`: "Setting things up…", with the current step as a quiet line and the elapsed time. Technical details open so progress and the log can be watched.
|
|
@@ -63,8 +63,12 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
63
63
|
- **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
|
|
64
64
|
- **Open workspace**: success-styled link available when the server is ready
|
|
65
65
|
- **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
|
|
66
|
+
- **Step logs (GitHub Actions style)**: setup output is split by recipe step. Each step is a `<details>` row with a status icon (ring, pulsing ring, check or cross), its name, how long it took and a chevron; opening it shows only that step's output. A step that is running or has failed starts open and a completed or waiting one starts closed. The script updates rows in place by step id, and applies the open preference only when a step's state changes, so a row the reader toggled is never reopened or reclosed and what they are reading is never rebuilt.
|
|
67
|
+
- **Capture**: `StepLog` wraps the combined log. Every line still goes to the combined `<id>.setup.log`; while a step is current the same scrubbed text also goes to `logs/<id>.steps/<step-id>.log`. A step's file is truncated when it begins, the files are cleared when a run starts, and a failing step records its error message in its own file.
|
|
68
|
+
- **Read**: the snapshot carries `step_logs` (the tail of each step that has output, with a `truncated` flag) for the setup view only, and `backend_log_available`.
|
|
69
|
+
- **Tabs**: *Setup steps* (the accordions) and *Web server log*. The server tab appears only once the backend log has content. The combined log stays reachable as a final, collapsed *Full setup log* row, which also covers workspaces set up before step files existed.
|
|
66
70
|
- **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
|
|
67
|
-
- **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected
|
|
71
|
+
- **Log source switcher**: two native links inside one log panel (Setup steps, Web server log), with current selection announced through `aria-current`; only the selected view is loaded and refreshed. Setup steps is the default.
|
|
68
72
|
- **Preview destination**: a small muted URL in Technical details, visible before readiness; the large Open workspace button is reserved for ready servers.
|
|
69
73
|
- **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
|
|
70
74
|
- **Environment presets (JS-enhanced)**: optional preset selector is hidden by default and revealed only when JavaScript is active and presets exist. Selecting a preset replaces the entire draft editor content and announces unsaved state without submitting. Draft edits remain local until Save & restart.
|
data/README.md
CHANGED
|
@@ -42,13 +42,17 @@ create from a pull request with `create --pr NUMBER`, or create a new branch wit
|
|
|
42
42
|
### Step timings
|
|
43
43
|
|
|
44
44
|
Per-step durations are persisted in seconds in workspace step status JSON, so they
|
|
45
|
-
remain available after the command exits
|
|
46
|
-
the workspace setup log, and the
|
|
45
|
+
remain available after the command exits, along with the wall-clock `total_seconds` of
|
|
46
|
+
the last run. Timings also appear in CLI progress, the workspace setup log, and the
|
|
47
|
+
dashboard. Everywhere they are shown as `0.5s`, `12.3s`, `1m 10s` or `1h 5m 3s`, never
|
|
48
|
+
as a large bare seconds count. The dashboard's Full setup log row shows the total
|
|
49
|
+
(`complete · 2m 17s`).
|
|
47
50
|
|
|
48
51
|
A background step measures only process launch, not the server's lifetime or
|
|
49
52
|
readiness. The following readiness step measures its own wait separately.
|
|
50
53
|
Interrupted steps have no recorded duration. Step timings are not the total
|
|
51
|
-
workflow wall-clock time; orchestration overhead can fall outside the steps
|
|
54
|
+
workflow wall-clock time; orchestration overhead can fall outside the steps, which is
|
|
55
|
+
why the total is measured separately and is usually a little larger than their sum.
|
|
52
56
|
|
|
53
57
|
## Serve the dashboard and previews
|
|
54
58
|
|
data/lib/workspaces/config.rb
CHANGED
|
@@ -10,6 +10,9 @@ module Workspaces
|
|
|
10
10
|
|
|
11
11
|
SAFE_LOG_BYTES = 8 * 1024
|
|
12
12
|
SAFE_LOG_LINES = 100
|
|
13
|
+
# Each step shows its own tail, so the per-step bounds are smaller than the combined log's.
|
|
14
|
+
STEP_LOG_BYTES = 6 * 1024
|
|
15
|
+
STEP_LOG_LINES = 80
|
|
13
16
|
DEFAULT_HOME = File.expand_path('~/.workspaces')
|
|
14
17
|
DEFAULT_SUBDOMAIN_PREFIX = 'ws-'
|
|
15
18
|
DEFAULT_FRONT_DOOR_PORT = 4747
|
|
@@ -5,6 +5,7 @@ require_relative 'workflow'
|
|
|
5
5
|
require_relative 'tee_log'
|
|
6
6
|
require_relative 'presentation'
|
|
7
7
|
require_relative 'environment_overrides'
|
|
8
|
+
require_relative 'duration_format'
|
|
8
9
|
|
|
9
10
|
module Workspaces
|
|
10
11
|
class Coordinator
|
|
@@ -98,7 +99,9 @@ module Workspaces
|
|
|
98
99
|
'source_update_message' => entry['source_update_message'],
|
|
99
100
|
'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
|
|
100
101
|
'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
|
|
101
|
-
'steps' => entry.fetch('steps', []),
|
|
102
|
+
'steps' => entry.fetch('steps', []), 'total_seconds' => entry['total_seconds'],
|
|
103
|
+
'step_logs' => include_log && log == 'setup' ? step_logs(id, entry.fetch('steps', [])) : {},
|
|
104
|
+
'backend_log_available' => backend_log_available?(id),
|
|
102
105
|
'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
|
|
103
106
|
'environment_keys' => environment_keys(id),
|
|
104
107
|
'editable_environment' => editable_environment(id),
|
|
@@ -151,7 +154,8 @@ module Workspaces
|
|
|
151
154
|
update(id, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
|
|
152
155
|
'steps' => steps,
|
|
153
156
|
'operation' => restart ? 'restart' : 'prepare', 'failed_phase' => nil,
|
|
154
|
-
'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil
|
|
157
|
+
'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil,
|
|
158
|
+
'total_seconds' => nil)
|
|
155
159
|
end
|
|
156
160
|
|
|
157
161
|
def spawn_worker(workspace, restart, lock)
|
|
@@ -168,28 +172,41 @@ module Workspaces
|
|
|
168
172
|
|
|
169
173
|
def run(workspace, restart:)
|
|
170
174
|
id = workspace.id
|
|
175
|
+
started = monotonic
|
|
171
176
|
File.open(Config.setup_log_path(id), 'a') do |file|
|
|
172
177
|
file.sync = true
|
|
173
178
|
log = TeeLog.new(id, file)
|
|
174
179
|
log.puts "==> #{restart ? 'restarting' : 'preparing'} workspace #{id}; branch and working files unchanged"
|
|
175
|
-
prepare_and_boot(workspace, log, restart)
|
|
180
|
+
prepare_and_boot(workspace, log, restart, started)
|
|
176
181
|
end
|
|
177
182
|
rescue StandardError => e
|
|
178
|
-
publish_error(id, e)
|
|
183
|
+
publish_error(id, e, started)
|
|
179
184
|
end
|
|
180
185
|
|
|
181
|
-
|
|
186
|
+
# The wall-clock time of the whole run, boot and readiness included. Step durations add up to
|
|
187
|
+
# less than this because orchestration between steps is not inside any step.
|
|
188
|
+
def prepare_and_boot(workspace, log, restart, started)
|
|
182
189
|
id = workspace.id
|
|
183
190
|
port = Workflow.new(workspace, log, state: @state).run!(restart: restart)
|
|
184
|
-
update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port
|
|
191
|
+
update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port,
|
|
192
|
+
'total_seconds' => elapsed_since(started))
|
|
185
193
|
end
|
|
186
194
|
|
|
187
|
-
def publish_error(id, exception)
|
|
195
|
+
def publish_error(id, exception, started = nil)
|
|
188
196
|
detail = scrub("#{exception.class}: #{exception.message}", id: id)
|
|
189
197
|
$stdout.puts "[Workspace #{id}] ERROR: #{detail}"
|
|
190
198
|
File.open(Config.setup_log_path(id), 'a') { |file| file.puts "ERROR: #{detail}" }
|
|
191
199
|
update(id, 'status' => 'error', 'message' => 'Workspace preparation failed', 'last_error' => detail,
|
|
192
|
-
'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now
|
|
200
|
+
'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now,
|
|
201
|
+
'total_seconds' => started && elapsed_since(started))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def monotonic
|
|
205
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def elapsed_since(started)
|
|
209
|
+
(monotonic - started).round(3)
|
|
193
210
|
end
|
|
194
211
|
|
|
195
212
|
def locked?(id)
|
|
@@ -210,6 +227,35 @@ module Workspaces
|
|
|
210
227
|
scrub(raw, id: id).lines.last(Config::SAFE_LOG_LINES).join
|
|
211
228
|
end
|
|
212
229
|
|
|
230
|
+
# Output for each recipe step, keyed by step id. Only steps that have written something
|
|
231
|
+
# appear, so a pending step has no entry and the page can show it as not started.
|
|
232
|
+
def step_logs(id, steps)
|
|
233
|
+
steps.each_with_object({}) do |step, logs|
|
|
234
|
+
path = Config.step_log_path(id, step['key'], create: false)
|
|
235
|
+
next unless path.file?
|
|
236
|
+
|
|
237
|
+
logs[step['key']] = tail_file(path, id, bytes: Config::STEP_LOG_BYTES, lines: Config::STEP_LOG_LINES)
|
|
238
|
+
end
|
|
239
|
+
rescue ArgumentError
|
|
240
|
+
{}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def backend_log_available?(id)
|
|
244
|
+
path = Config.backend_log_path(id, create: false)
|
|
245
|
+
path.file? && !path.zero?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# The tail of a log file, scrubbed, with whether earlier output was cut off.
|
|
249
|
+
def tail_file(path, id, bytes:, lines:)
|
|
250
|
+
size = path.size
|
|
251
|
+
raw = File.open(path, 'rb') do |file|
|
|
252
|
+
file.seek(-bytes, IO::SEEK_END) if size > bytes
|
|
253
|
+
file.read
|
|
254
|
+
end
|
|
255
|
+
kept = scrub(raw, id: id).lines
|
|
256
|
+
{ 'text' => kept.last(lines).join, 'truncated' => size > bytes || kept.length > lines }
|
|
257
|
+
end
|
|
258
|
+
|
|
213
259
|
def scrub(value, id:)
|
|
214
260
|
scrubbed = value.to_s
|
|
215
261
|
scrubbed = EnvironmentOverrides.new(id).scrub(scrubbed)
|
|
@@ -46,43 +46,131 @@
|
|
|
46
46
|
toggleBusyState(busy);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
49
|
+
// Each recipe step is a disclosure element. The server renders the first paint; after that the
|
|
50
|
+
// poll updates these nodes in place, keyed by step id, so nothing the user is reading
|
|
51
|
+
// is rebuilt. A step opens by itself when it starts running or fails, and closes when it
|
|
52
|
+
// completes, but only on a state change, and never after the user toggled it by hand.
|
|
53
|
+
const userToggled = new Set();
|
|
54
|
+
const stepState = {};
|
|
55
|
+
|
|
56
|
+
function ensureStep(container, step, before) {
|
|
57
|
+
const existing = container.querySelector('[data-step-log="' + step.key + '"]');
|
|
58
|
+
if (existing) return existing;
|
|
59
|
+
const details = document.createElement('details');
|
|
60
|
+
details.className = 'step-log';
|
|
61
|
+
details.dataset.stepLog = step.key;
|
|
62
|
+
const summary = document.createElement('summary');
|
|
63
|
+
const status = document.createElement('span');
|
|
64
|
+
status.className = 'step-log__status';
|
|
65
|
+
status.setAttribute('aria-hidden', 'true');
|
|
66
|
+
const name = document.createElement('span');
|
|
67
|
+
name.className = 'step-log__name';
|
|
59
68
|
const outcome = document.createElement('span');
|
|
60
|
-
outcome.className = 'step-outcome';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
outcome.className = 'step-log__outcome step-outcome';
|
|
70
|
+
summary.append(status, name, outcome);
|
|
71
|
+
const output = document.createElement('pre');
|
|
72
|
+
output.tabIndex = 0;
|
|
73
|
+
details.append(summary, output);
|
|
74
|
+
details.addEventListener('toggle', () => {
|
|
75
|
+
if (details.dataset.programmatic === 'true') return;
|
|
76
|
+
userToggled.add(step.key);
|
|
77
|
+
});
|
|
78
|
+
container.insertBefore(details, before || null);
|
|
79
|
+
return details;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderSteps(view, stepLogs) {
|
|
83
|
+
const container = node('steps');
|
|
84
|
+
if (!container) return;
|
|
85
|
+
const steps = view.steps || [];
|
|
86
|
+
const full = node('full-log');
|
|
87
|
+
const keep = new Set(steps.map((step) => step.key).concat(['full']));
|
|
88
|
+
container.querySelectorAll('[data-step-log]').forEach((element) => {
|
|
89
|
+
if (!keep.has(element.dataset.stepLog)) container.removeChild(element);
|
|
90
|
+
});
|
|
91
|
+
renderFullLogOutcome(view);
|
|
92
|
+
steps.forEach((step) => {
|
|
93
|
+
const details = ensureStep(container, step, full);
|
|
94
|
+
details.dataset.stepState = step.state;
|
|
95
|
+
if (step.state === 'active') details.setAttribute('aria-current', 'step');
|
|
96
|
+
else details.removeAttribute('aria-current');
|
|
97
|
+
const summary = details.querySelector('summary');
|
|
98
|
+
const parts = summary.children;
|
|
99
|
+
parts[1].textContent = step.label;
|
|
100
|
+
parts[2].textContent = stepOutcome(step);
|
|
101
|
+
const output = details.querySelector('pre');
|
|
102
|
+
output.setAttribute('aria-label', step.label + ' output');
|
|
103
|
+
const entry = stepLogs && stepLogs[step.key];
|
|
104
|
+
if (entry && output.textContent !== entry.text) writeLog(output, entry.text);
|
|
105
|
+
applyStepOpenState(details, step);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The Full setup log row reads "in progress" while running, then how the run ended and how
|
|
110
|
+
// long it took. Without a recorded total it keeps its plain label.
|
|
111
|
+
function renderFullLogOutcome(view) {
|
|
112
|
+
const label = node('full-log-outcome');
|
|
113
|
+
if (!label) return;
|
|
114
|
+
const total = view.total;
|
|
115
|
+
const text = total && formatDuration(total.seconds);
|
|
116
|
+
label.textContent = view.busy
|
|
117
|
+
? 'in progress'
|
|
118
|
+
: text
|
|
119
|
+
? total.state + ' · ' + text
|
|
120
|
+
: 'everything, in order';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applyStepOpenState(details, step) {
|
|
124
|
+
const previous = stepState[step.key];
|
|
125
|
+
stepState[step.key] = step.state;
|
|
126
|
+
if (previous === step.state || userToggled.has(step.key)) return;
|
|
127
|
+
const shouldOpen = step.state === 'active' || step.state === 'failed';
|
|
128
|
+
if (details.open === shouldOpen) return;
|
|
129
|
+
details.dataset.programmatic = 'true';
|
|
130
|
+
details.open = shouldOpen;
|
|
131
|
+
// The toggle event fires asynchronously; clear the guard after it has been delivered.
|
|
132
|
+
setTimeout(() => { details.dataset.programmatic = 'false'; }, 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Writing a log must not yank a reader who scrolled up; follow the tail only if already there.
|
|
136
|
+
function writeLog(pre, text) {
|
|
137
|
+
const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 18;
|
|
138
|
+
pre.textContent = text || '';
|
|
139
|
+
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Durations read the way people say them: 0.5s, 12.3s, 1m 10s, 1h 5m 3s. This mirrors
|
|
143
|
+
// Workspaces::DurationFormat in Ruby; a spec runs both over the same inputs.
|
|
144
|
+
function formatDuration(seconds) {
|
|
145
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return null;
|
|
146
|
+
if (seconds < 60) {
|
|
147
|
+
const rounded = Math.round(seconds * 10) / 10;
|
|
148
|
+
if (rounded >= 60) return '1m 0s';
|
|
149
|
+
return rounded + 's';
|
|
150
|
+
}
|
|
151
|
+
const whole = Math.round(seconds);
|
|
152
|
+
const hours = Math.floor(whole / 3600);
|
|
153
|
+
const minutes = Math.floor((whole % 3600) / 60);
|
|
154
|
+
const secs = whole % 60;
|
|
155
|
+
return hours > 0 ? hours + 'h ' + minutes + 'm ' + secs + 's' : minutes + 'm ' + secs + 's';
|
|
64
156
|
}
|
|
65
157
|
|
|
66
158
|
function stepOutcome(step) {
|
|
67
159
|
const state = step.state;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
(state === 'complete' || state === 'failed') &&
|
|
73
|
-
Number.isFinite(duration) &&
|
|
74
|
-
duration >= 0
|
|
75
|
-
)
|
|
76
|
-
return state + ' · ' + duration.toFixed(3) + 's';
|
|
77
|
-
return state;
|
|
160
|
+
if (state !== 'complete' && state !== 'failed') return state;
|
|
161
|
+
const text = formatDuration(step.duration_seconds);
|
|
162
|
+
return text ? state + ' · ' + text : state;
|
|
78
163
|
}
|
|
79
164
|
|
|
80
165
|
function updateLog(data) {
|
|
81
166
|
if (data.log_source !== selectedLog) return;
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
167
|
+
const tab = node('backend-tab');
|
|
168
|
+
if (tab && data.backend_log_available) tab.hidden = false;
|
|
169
|
+
if (selectedLog === 'backend') writeLog(node('log'), data.log_tail);
|
|
170
|
+
else {
|
|
171
|
+
const full = node('full-log');
|
|
172
|
+
if (full) writeLog(full.querySelector('pre'), data.log_tail);
|
|
173
|
+
}
|
|
86
174
|
node('updated').textContent = data.updated_at || '';
|
|
87
175
|
}
|
|
88
176
|
|
|
@@ -97,7 +185,7 @@
|
|
|
97
185
|
renderSections(view);
|
|
98
186
|
if (node('message')) node('message').textContent = data.message || '';
|
|
99
187
|
node('source-update-message').textContent = data.source_update_message || '';
|
|
100
|
-
|
|
188
|
+
renderSteps(view, data.step_logs);
|
|
101
189
|
renderActions(view.actions);
|
|
102
190
|
if (window.WorkspaceEnvironment)
|
|
103
191
|
window.WorkspaceEnvironment.syncFromSnapshot(data);
|
|
@@ -130,6 +218,11 @@
|
|
|
130
218
|
if (activity) activity.hidden = !view.busy;
|
|
131
219
|
const title = node('environment-title');
|
|
132
220
|
if (title && view.environment_title) title.textContent = view.environment_title;
|
|
221
|
+
const badge = node('environment-preset-badge');
|
|
222
|
+
if (badge) {
|
|
223
|
+
badge.hidden = !view.current_preset;
|
|
224
|
+
badge.textContent = view.current_preset ? 'Preset: ' + view.current_preset : '';
|
|
225
|
+
}
|
|
133
226
|
renderLeadAction(view.lead_action);
|
|
134
227
|
}
|
|
135
228
|
|
|
@@ -203,9 +296,8 @@
|
|
|
203
296
|
if (Number.isFinite(started)) {
|
|
204
297
|
const end = busy ? Date.now() : completed;
|
|
205
298
|
const seconds = Math.max(0, Math.floor((end - started) / 1000));
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
|
|
299
|
+
// The running clock counts whole seconds, so format it as one (no decimals).
|
|
300
|
+
if (Number.isFinite(seconds)) node('elapsed').textContent = formatDuration(seconds);
|
|
209
301
|
}
|
|
210
302
|
setTimeout(tick, 1000);
|
|
211
303
|
}
|
|
@@ -199,11 +199,28 @@ h1 {
|
|
|
199
199
|
.disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
200
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
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); }
|
|
202
218
|
.disclosure__hint { color: var(--muted); font-size: 13px; font-weight: 400; }
|
|
203
219
|
@media (prefers-reduced-motion: reduce) { .disclosure > summary, .disclosure > summary::after { transition: none; } }
|
|
204
220
|
.disclosure .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
|
|
205
221
|
.disclosure .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
|
|
206
222
|
.disclosure .log-panel { padding: 0; }
|
|
223
|
+
.log-panel > #log { margin-block-start: 0; }
|
|
207
224
|
.disclosure .danger-zone h3 { color: var(--danger); font-size: 1rem; }
|
|
208
225
|
.disclosure h3 { font-size: 1rem; }
|
|
209
226
|
|
|
@@ -254,60 +271,64 @@ h1 {
|
|
|
254
271
|
color: var(--text);
|
|
255
272
|
}
|
|
256
273
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
gap: var(--space-2);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
.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 {
|
|
266
279
|
display: flex;
|
|
267
280
|
align-items: center;
|
|
268
281
|
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'] {
|
|
282
|
+
padding: var(--space-3) var(--space-4);
|
|
283
|
+
position: relative;
|
|
284
|
+
list-style: none;
|
|
281
285
|
color: var(--text);
|
|
286
|
+
cursor: pointer;
|
|
287
|
+
user-select: none;
|
|
288
|
+
transition: background-color 120ms ease;
|
|
282
289
|
}
|
|
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
|
-
}
|
|
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; } }
|
|
311
332
|
|
|
312
333
|
.env-panel {
|
|
313
334
|
display: grid;
|
|
@@ -466,7 +487,7 @@ fieldset:disabled {
|
|
|
466
487
|
.field { display: grid; gap: var(--space-2); max-inline-size: 28rem; font-size: 14px; }
|
|
467
488
|
.field input { inline-size: 100%; }
|
|
468
489
|
|
|
469
|
-
ol
|
|
490
|
+
ol > li + li { margin-block-start: var(--space-2); }
|
|
470
491
|
|
|
471
492
|
.button--success {
|
|
472
493
|
background: color-mix(in oklab, var(--success) 22%, transparent);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<p id="connection" role="status"></p>
|
|
21
21
|
</section>
|
|
22
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="disclosure__hint">Presets and environment variables</span></summary>
|
|
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
24
|
<div class="workspace-settings" aria-labelledby="environment-title">
|
|
25
25
|
<form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
|
|
26
26
|
<fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
|
|
@@ -51,11 +51,6 @@
|
|
|
51
51
|
<details class="status-panel disclosure" id="technical-disclosure" data-section="technical" <%= 'open' if @presentation.dig('open', 'technical') %> <%= 'hidden' unless section?('technical') %>>
|
|
52
52
|
<summary><span>Technical details</span><span class="disclosure__hint">Setup steps, logs and workspace controls</span></summary>
|
|
53
53
|
<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
54
|
<div class="workspace-actions-group stack stack--snug" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
|
|
60
55
|
<h3 id="actions-heading">Workspace controls</h3>
|
|
61
56
|
<div class="workspace-actions">
|
|
@@ -66,10 +61,17 @@
|
|
|
66
61
|
<section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
|
|
67
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>
|
|
68
63
|
<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' %>>
|
|
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>
|
|
71
66
|
</nav>
|
|
72
|
-
<
|
|
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>
|
|
73
75
|
</section>
|
|
74
76
|
<section class="stack stack--snug" data-section="details" <%= 'hidden' unless section?('details') %>>
|
|
75
77
|
<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,46 @@ 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
|
+
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
|
+
|
|
179
214
|
def step_outcome(step)
|
|
180
215
|
state = step['state'].to_s
|
|
181
216
|
return state unless %w(complete failed).include?(state)
|
|
182
217
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
"#{state} · #{format('%.3f', duration)}s"
|
|
218
|
+
text = DurationFormat.call(step['duration_seconds'])
|
|
219
|
+
text ? "#{state} · #{text}" : state
|
|
187
220
|
end
|
|
188
221
|
end
|
|
189
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
|
data/lib/workspaces/paths.rb
CHANGED
|
@@ -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
|
|
@@ -10,9 +10,10 @@ module Workspaces
|
|
|
10
10
|
# most one primary action. `environment` (presets and variables) is the area for
|
|
11
11
|
# deliberately changing something. `technical` is one disclosure holding setup steps,
|
|
12
12
|
# 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
|
-
|
|
13
|
+
# a user reads, but it opens by itself when something needs attention. Setup steps are
|
|
14
|
+
# not a section of their own: each is an accordion with its own log inside `log`.
|
|
15
|
+
SECTIONS = %w(lead actions environment technical log details danger).freeze
|
|
16
|
+
TECHNICAL = %w(technical actions log details danger).freeze
|
|
16
17
|
|
|
17
18
|
# Workspace lifecycle state machine. Each state declares only what is usable and
|
|
18
19
|
# meaningful there; everything else is hidden rather than shown disabled.
|
|
@@ -38,7 +39,7 @@ module Workspaces
|
|
|
38
39
|
open_environment: true, open_technical: false
|
|
39
40
|
},
|
|
40
41
|
'busy' => {
|
|
41
|
-
sections: %w(lead technical
|
|
42
|
+
sections: %w(lead technical log),
|
|
42
43
|
headline: 'Setting things up…',
|
|
43
44
|
summary: "This usually takes a few minutes. We'll take you to the preview when it's ready.",
|
|
44
45
|
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
@@ -90,7 +91,8 @@ module Workspaces
|
|
|
90
91
|
'actions' => actions, 'ready' => status == 'ready' && !busy?,
|
|
91
92
|
'state' => state, 'sections' => sections,
|
|
92
93
|
'headline' => definition[:headline], 'summary' => definition[:summary],
|
|
93
|
-
'subtitle' => subtitle, 'lead_action' => lead_action,
|
|
94
|
+
'subtitle' => subtitle, 'lead_action' => lead_action, 'total' => total,
|
|
95
|
+
'current_preset' => current_preset,
|
|
94
96
|
'environment_title' => definition[:environment_title],
|
|
95
97
|
'environment_submit' => definition[:environment_submit],
|
|
96
98
|
'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
|
|
@@ -117,8 +119,7 @@ module Workspaces
|
|
|
117
119
|
end
|
|
118
120
|
|
|
119
121
|
def sections
|
|
120
|
-
visible = definition[:sections]
|
|
121
|
-
visible.delete('progress') if steps.empty? || %w(idle stopped).include?(state)
|
|
122
|
+
visible = definition[:sections]
|
|
122
123
|
SECTIONS.to_h { |name| [name, visible.include?(name)] }
|
|
123
124
|
end
|
|
124
125
|
|
|
@@ -132,6 +133,32 @@ module Workspaces
|
|
|
132
133
|
end
|
|
133
134
|
end
|
|
134
135
|
|
|
136
|
+
# The preset whose values exactly equal the workspace's saved editable settings, or nil when
|
|
137
|
+
# there are no presets or the settings match none (for example after a hand edit). Presets
|
|
138
|
+
# arrive already completed with the recipe defaults, so equality is a plain comparison.
|
|
139
|
+
def current_preset
|
|
140
|
+
presets = @snapshot['environment_presets']
|
|
141
|
+
current = @snapshot['editable_environment']
|
|
142
|
+
return nil unless presets.is_a?(Hash) && current.is_a?(Hash) && !current.empty?
|
|
143
|
+
|
|
144
|
+
normalized = stringify(current)
|
|
145
|
+
presets.find { |_name, values| values.is_a?(Hash) && stringify(values) == normalized }&.first&.to_s
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def stringify(values)
|
|
149
|
+
values.to_h { |name, value| [name.to_s, value.to_s] }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# How long the last run took and how it ended, or nil while one is in progress or when no
|
|
153
|
+
# total was recorded (interrupted runs, and workspaces set up before totals were persisted).
|
|
154
|
+
def total
|
|
155
|
+
seconds = @snapshot['total_seconds']
|
|
156
|
+
return nil if busy? || !seconds.is_a?(Numeric)
|
|
157
|
+
|
|
158
|
+
failed = status == 'error' || steps.any? { |step| step['state'] == 'failed' }
|
|
159
|
+
{ 'state' => failed ? 'failed' : 'complete', 'seconds' => seconds }
|
|
160
|
+
end
|
|
161
|
+
|
|
135
162
|
# What is happening right now, in the quiet line under the headline.
|
|
136
163
|
def subtitle
|
|
137
164
|
return nil unless state == 'busy'
|
|
@@ -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
|
data/lib/workspaces/tee_log.rb
CHANGED
|
@@ -26,10 +26,16 @@ module Workspaces
|
|
|
26
26
|
$stdout.print(prefixed(scrubbed))
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# The filtering applied to everything written. Public so StepLog can copy exactly what the
|
|
30
|
+
# combined log received, instead of reimplementing the rules.
|
|
31
|
+
def scrub_text(message)
|
|
32
|
+
@overrides.scrub(message)
|
|
33
|
+
end
|
|
34
|
+
|
|
29
35
|
private
|
|
30
36
|
|
|
31
37
|
def scrub(message)
|
|
32
|
-
|
|
38
|
+
scrub_text(message)
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def prefixed(message)
|
data/lib/workspaces/version.rb
CHANGED
data/lib/workspaces/workflow.rb
CHANGED
|
@@ -3,12 +3,15 @@ require_relative 'recipe'
|
|
|
3
3
|
require_relative 'command_runner'
|
|
4
4
|
require_relative 'backend'
|
|
5
5
|
require_relative 'environment_overrides'
|
|
6
|
+
require_relative 'step_log'
|
|
7
|
+
require_relative 'duration_format'
|
|
6
8
|
|
|
7
9
|
module Workspaces
|
|
8
10
|
class Workflow
|
|
9
11
|
def initialize(workspace, log, state: StateStore.new(Config.state_file))
|
|
10
12
|
@workspace = workspace
|
|
11
|
-
@
|
|
13
|
+
@combined_log = log
|
|
14
|
+
@log = StepLog.new(log, workspace.id)
|
|
12
15
|
@state = state
|
|
13
16
|
@backend = Backend.new(workspace.id)
|
|
14
17
|
end
|
|
@@ -18,6 +21,7 @@ module Workspaces
|
|
|
18
21
|
overrides = EnvironmentOverrides.new(@workspace.id).load
|
|
19
22
|
steps = recipe.for_operation(restart: restart)
|
|
20
23
|
publish_steps(steps)
|
|
24
|
+
StepLog.reset(@workspace.id)
|
|
21
25
|
@backend.stop!
|
|
22
26
|
context = build_context(@backend.reserve!)
|
|
23
27
|
runner = CommandRunner.new(@workspace.path, @log)
|
|
@@ -27,6 +31,8 @@ module Workspaces
|
|
|
27
31
|
rescue StandardError
|
|
28
32
|
@backend.stop!
|
|
29
33
|
raise
|
|
34
|
+
ensure
|
|
35
|
+
@log.finish_step
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
private
|
|
@@ -50,6 +56,7 @@ module Workspaces
|
|
|
50
56
|
def execute(step, recipe, context, runner, overrides)
|
|
51
57
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
58
|
progress(step, 'active')
|
|
59
|
+
@log.begin_step(step.fetch('id'))
|
|
53
60
|
@log.puts "==> #{step.fetch('name')}"
|
|
54
61
|
command = recipe.command(step, context)
|
|
55
62
|
env = recipe.env(step, context, overrides: overrides)
|
|
@@ -60,7 +67,10 @@ module Workspaces
|
|
|
60
67
|
@backend.ensure_alive! if @server_started
|
|
61
68
|
end
|
|
62
69
|
@server_started = true if step['background']
|
|
63
|
-
rescue StandardError
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
# The reason a step failed is not command output, so the step file would otherwise end
|
|
72
|
+
# without it. Record it where the step's own accordion can show it.
|
|
73
|
+
@log.puts "ERROR: #{e.message}"
|
|
64
74
|
finish_step(step, 'failed', started_at)
|
|
65
75
|
raise
|
|
66
76
|
else
|
|
@@ -70,7 +80,7 @@ module Workspaces
|
|
|
70
80
|
def finish_step(step, status, started_at)
|
|
71
81
|
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).round(3)
|
|
72
82
|
progress(step, status, duration_seconds: duration)
|
|
73
|
-
@log.puts format('==> %s %s (
|
|
83
|
+
@log.puts format('==> %s %s (%s)', step.fetch('name'), status, DurationFormat.call(duration))
|
|
74
84
|
end
|
|
75
85
|
|
|
76
86
|
def progress(step, status, duration_seconds: nil)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: workspaces
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Workspaces contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -83,6 +83,7 @@ files:
|
|
|
83
83
|
- lib/workspaces/dashboard/styles.css
|
|
84
84
|
- lib/workspaces/dashboard/template.html.erb
|
|
85
85
|
- lib/workspaces/dashboard_view.rb
|
|
86
|
+
- lib/workspaces/duration_format.rb
|
|
86
87
|
- lib/workspaces/environment_overrides.rb
|
|
87
88
|
- lib/workspaces/front_door.rb
|
|
88
89
|
- lib/workspaces/paths.rb
|
|
@@ -92,6 +93,7 @@ files:
|
|
|
92
93
|
- lib/workspaces/recipe.rb
|
|
93
94
|
- lib/workspaces/registry.rb
|
|
94
95
|
- lib/workspaces/state_store.rb
|
|
96
|
+
- lib/workspaces/step_log.rb
|
|
95
97
|
- lib/workspaces/tee_log.rb
|
|
96
98
|
- lib/workspaces/tls.rb
|
|
97
99
|
- lib/workspaces/version.rb
|