workspaces 0.1.12 → 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 +2 -0
- data/README.md +24 -0
- data/lib/workspaces/build_history.rb +83 -0
- data/lib/workspaces/coordinator.rb +20 -3
- data/lib/workspaces/dashboard/app.js +24 -10
- data/lib/workspaces/dashboard/styles.css +11 -0
- data/lib/workspaces/dashboard/template.html.erb +7 -1
- data/lib/workspaces/dashboard_view.rb +29 -0
- data/lib/workspaces/paths.rb +5 -0
- data/lib/workspaces/presentation.rb +9 -6
- data/lib/workspaces/progress.rb +122 -0
- data/lib/workspaces/version.rb +1 -1
- data/lib/workspaces/workflow.rb +3 -2
- 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: 1d730b2e0f349781b10fe24beefc003403f6709460fbe4e0420853988c05f1e5
|
|
4
|
+
data.tar.gz: '069ee33491c24526d1644a8877386a95720e8e61457a15296ba2da604067860a'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b2174d3fa1eeb55ce8fffe1dba27a7eaab267fed9904c9b216f4c256a150006d83dfb2100768dc49e473fd54f7a3e687f2dc9f9f159cb1c1df948d128079d7a3
|
|
7
|
+
data.tar.gz: 32fbfbb3e6c5334fafa03bb2bbc74f6039b4e3d17ff5a19a58422fa3314af9be0ae0eaace1d4579f2926c5a5c156f0efa074c1e9aeb2dae04d6b19f8be08a872
|
data/DESIGN.md
CHANGED
|
@@ -67,6 +67,8 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
67
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
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
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.
|
|
70
|
+
- **Build progress**: while a build is being prepared the lead is the headline, a progress bar (`role="progressbar"` with a text value), and one line saying *Step n of m · name*, the percentage, the elapsed time and an estimate. The server computes the numbers from the step states and per-step averages frozen when the run began (`Progress`), and the script only draws them; the elapsed clock ticks locally between polls. A step well past its usual time turns the bar amber and the estimate reads "Taking longer than usual". Without history there is no time estimate. The bar is hidden when no build is running.
|
|
71
|
+
- **Build history**: every finished run is appended to `build-history.jsonl` (`BuildHistory`). Estimates are the mean of a step's last 10 successful completions; failed runs are recorded but never counted. Recording is best-effort and cannot fail a build.
|
|
70
72
|
- **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
|
|
71
73
|
- **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.
|
|
72
74
|
- **Preview destination**: a small muted URL in Technical details, visible before readiness; the large Open workspace button is reserved for ready servers.
|
data/README.md
CHANGED
|
@@ -54,6 +54,30 @@ Interrupted steps have no recorded duration. Step timings are not the total
|
|
|
54
54
|
workflow wall-clock time; orchestration overhead can fall outside the steps, which is
|
|
55
55
|
why the total is measured separately and is usually a little larger than their sum.
|
|
56
56
|
|
|
57
|
+
### Build history and the progress bar
|
|
58
|
+
|
|
59
|
+
While a workspace is being prepared the dashboard shows a progress bar with the current step,
|
|
60
|
+
the percentage, the elapsed time and, once there is history, an estimate of what is left.
|
|
61
|
+
|
|
62
|
+
Every finished run is appended to `build-history.jsonl` in the Workspaces home, one JSON
|
|
63
|
+
object per line, shared by all workspaces of the project. It is written quietly and a failure
|
|
64
|
+
to write it never affects the build. Each record has the time, workspace, operation
|
|
65
|
+
(`prepare` or `restart`), result (`complete` or `failed`), `total_seconds`, the Workspaces
|
|
66
|
+
version, and each step's id, state and seconds. To see which step is getting slower:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
jq -r 'select(.result=="complete") | .at[:10] as $d | .steps[] | [$d, .id, .seconds] | @tsv' \
|
|
70
|
+
"$WORKSPACES_HOME/build-history.jsonl"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Estimates are the mean of each step over its last 10 successful runs. Failed runs are
|
|
74
|
+
recorded for the trend but never count toward "usual". They are read once when a run starts,
|
|
75
|
+
so the bar does not shift while you watch it. Percentages weigh each step by how long it
|
|
76
|
+
usually takes, count the running step by how much of its usual time has passed, never reach
|
|
77
|
+
100% until the run finishes, and hold a step that overruns below done. A step more than 1.5
|
|
78
|
+
times its usual time is flagged as taking longer than usual. With no history yet, steps weigh
|
|
79
|
+
the same and no time estimate is shown. Delete the file to start the estimates over.
|
|
80
|
+
|
|
57
81
|
## Serve the dashboard and previews
|
|
58
82
|
|
|
59
83
|
Run `serve` in a separate terminal using the **same project root and `WORKSPACES_HOME`**
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative 'version'
|
|
7
|
+
|
|
8
|
+
module Workspaces
|
|
9
|
+
# A quiet, append-only record of every finished run, plus the per-step averages derived from it.
|
|
10
|
+
#
|
|
11
|
+
# One JSON object per line in `build-history.jsonl` under the Workspaces home, shared by every
|
|
12
|
+
# workspace of the project, so it can be read with jq or loaded into a spreadsheet to see how
|
|
13
|
+
# setup times trend. Only successful runs feed the progress estimates; failed runs are recorded
|
|
14
|
+
# for the trend but never skew what "usual" means.
|
|
15
|
+
#
|
|
16
|
+
# Nothing here may break a build: recording failures are swallowed with a one-line warning.
|
|
17
|
+
module BuildHistory
|
|
18
|
+
SAMPLE_SIZE = 10
|
|
19
|
+
# The estimates only need recent runs, so a long history is read from its tail.
|
|
20
|
+
READ_BYTES = 512 * 1024
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def record(workspace_id:, operation:, result:, total_seconds:, steps:, now: Time.now.utc)
|
|
25
|
+
line = JSON.generate(
|
|
26
|
+
'at' => now.iso8601, 'workspace_id' => workspace_id, 'operation' => operation, 'result' => result,
|
|
27
|
+
'total_seconds' => total_seconds, 'workspaces_version' => VERSION,
|
|
28
|
+
'steps' => Array(steps).map { |step| { 'id' => step['key'], 'state' => step['state'], 'seconds' => step['duration_seconds'] } }
|
|
29
|
+
)
|
|
30
|
+
path = Config.build_history_path
|
|
31
|
+
FileUtils.mkdir_p(path.dirname)
|
|
32
|
+
File.open(path, 'a') do |file|
|
|
33
|
+
file.flock(File::LOCK_EX)
|
|
34
|
+
file.puts(line)
|
|
35
|
+
end
|
|
36
|
+
true
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
warn "[Workspaces] build history not recorded: #{e.class}"
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Mean seconds for each requested step id, over its last SAMPLE_SIZE completions in
|
|
43
|
+
# successful runs. Steps never seen succeed are omitted.
|
|
44
|
+
def estimates(step_ids)
|
|
45
|
+
wanted = step_ids.to_a
|
|
46
|
+
samples = Hash.new { |hash, key| hash[key] = [] }
|
|
47
|
+
records.reverse_each do |record|
|
|
48
|
+
next unless record['result'] == 'complete'
|
|
49
|
+
|
|
50
|
+
Array(record['steps']).each do |step|
|
|
51
|
+
id = step['id']
|
|
52
|
+
seconds = step['seconds']
|
|
53
|
+
next unless wanted.include?(id) && step['state'] == 'complete' && seconds.is_a?(Numeric) && seconds >= 0
|
|
54
|
+
next if samples[id].length >= SAMPLE_SIZE
|
|
55
|
+
|
|
56
|
+
samples[id] << seconds
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
samples.transform_values { |values| (values.sum / values.length).round(3) }
|
|
60
|
+
rescue StandardError
|
|
61
|
+
{}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Newest last. Unreadable or half-written lines are skipped.
|
|
65
|
+
def records
|
|
66
|
+
path = Config.build_history_path
|
|
67
|
+
return [] unless path.file?
|
|
68
|
+
|
|
69
|
+
text = File.open(path, 'rb') do |file|
|
|
70
|
+
size = file.size
|
|
71
|
+
file.seek(-READ_BYTES, IO::SEEK_END) if size > READ_BYTES
|
|
72
|
+
data = file.read.force_encoding('UTF-8')
|
|
73
|
+
size > READ_BYTES ? data.sub(/\A[^\n]*\n/, '') : data
|
|
74
|
+
end
|
|
75
|
+
text.each_line.filter_map do |line|
|
|
76
|
+
parsed = JSON.parse(line)
|
|
77
|
+
parsed if parsed.is_a?(Hash)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative 'tee_log'
|
|
|
6
6
|
require_relative 'presentation'
|
|
7
7
|
require_relative 'environment_overrides'
|
|
8
8
|
require_relative 'duration_format'
|
|
9
|
+
require_relative 'build_history'
|
|
9
10
|
|
|
10
11
|
module Workspaces
|
|
11
12
|
class Coordinator
|
|
@@ -100,6 +101,7 @@ module Workspaces
|
|
|
100
101
|
'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
|
|
101
102
|
'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
|
|
102
103
|
'steps' => entry.fetch('steps', []), 'total_seconds' => entry['total_seconds'],
|
|
104
|
+
'estimates' => entry.fetch('estimates', {}),
|
|
103
105
|
'step_logs' => include_log && log == 'setup' ? step_logs(id, entry.fetch('steps', [])) : {},
|
|
104
106
|
'backend_log_available' => backend_log_available?(id),
|
|
105
107
|
'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
|
|
@@ -151,8 +153,10 @@ module Workspaces
|
|
|
151
153
|
steps = recipe.for_operation(restart: restart).map do |step|
|
|
152
154
|
{ 'key' => step['id'], 'label' => step['name'], 'state' => 'pending', 'duration_seconds' => nil }
|
|
153
155
|
end
|
|
156
|
+
# Frozen for the whole run, so the bar does not shift under the reader and the history file
|
|
157
|
+
# is read once per run rather than once per poll.
|
|
154
158
|
update(id, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
|
|
155
|
-
'steps' => steps,
|
|
159
|
+
'steps' => steps, 'estimates' => BuildHistory.estimates(steps.map { |step| step['key'] }),
|
|
156
160
|
'operation' => restart ? 'restart' : 'prepare', 'failed_phase' => nil,
|
|
157
161
|
'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil,
|
|
158
162
|
'total_seconds' => nil)
|
|
@@ -188,17 +192,30 @@ module Workspaces
|
|
|
188
192
|
def prepare_and_boot(workspace, log, restart, started)
|
|
189
193
|
id = workspace.id
|
|
190
194
|
port = Workflow.new(workspace, log, state: @state).run!(restart: restart)
|
|
195
|
+
total = elapsed_since(started)
|
|
191
196
|
update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port,
|
|
192
|
-
'total_seconds' =>
|
|
197
|
+
'total_seconds' => total)
|
|
198
|
+
record_run(id, 'complete', total)
|
|
193
199
|
end
|
|
194
200
|
|
|
195
201
|
def publish_error(id, exception, started = nil)
|
|
196
202
|
detail = scrub("#{exception.class}: #{exception.message}", id: id)
|
|
197
203
|
$stdout.puts "[Workspace #{id}] ERROR: #{detail}"
|
|
198
204
|
File.open(Config.setup_log_path(id), 'a') { |file| file.puts "ERROR: #{detail}" }
|
|
205
|
+
total = started && elapsed_since(started)
|
|
199
206
|
update(id, 'status' => 'error', 'message' => 'Workspace preparation failed', 'last_error' => detail,
|
|
200
207
|
'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now,
|
|
201
|
-
'total_seconds' =>
|
|
208
|
+
'total_seconds' => total)
|
|
209
|
+
record_run(id, 'failed', total)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Appends to the build history. A problem here must never change how the run itself ended.
|
|
213
|
+
def record_run(id, result, total_seconds)
|
|
214
|
+
entry = @state.get(id) || {}
|
|
215
|
+
BuildHistory.record(workspace_id: id, operation: entry['operation'], result: result,
|
|
216
|
+
total_seconds: total_seconds, steps: entry['steps'])
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
202
219
|
end
|
|
203
220
|
|
|
204
221
|
def monotonic
|
|
@@ -206,16 +206,7 @@
|
|
|
206
206
|
function renderLead(view) {
|
|
207
207
|
node('status').textContent = view.headline || view.title;
|
|
208
208
|
node('summary').textContent = view.summary || '';
|
|
209
|
-
|
|
210
|
-
const activity = node('activity');
|
|
211
|
-
if (subtitle) {
|
|
212
|
-
const text = view.subtitle || '';
|
|
213
|
-
subtitle.hidden = !text;
|
|
214
|
-
// The text has its own element, so it can be set whether or not the server rendered any.
|
|
215
|
-
const target = node('subtitle-text');
|
|
216
|
-
if (target) target.textContent = text;
|
|
217
|
-
}
|
|
218
|
-
if (activity) activity.hidden = !view.busy;
|
|
209
|
+
renderProgress(view.progress);
|
|
219
210
|
const title = node('environment-title');
|
|
220
211
|
if (title && view.environment_title) title.textContent = view.environment_title;
|
|
221
212
|
const badge = node('environment-preset-badge');
|
|
@@ -229,6 +220,29 @@
|
|
|
229
220
|
// The lead action is a lifecycle post (start/retry) or, when ready, the preview link
|
|
230
221
|
// rendered separately as #preview. Rebuild only when it actually changes.
|
|
231
222
|
let leadSignature = null;
|
|
223
|
+
// The server decides the percentage, the step line and the estimate; this only draws them. The
|
|
224
|
+
// elapsed clock is ticked locally each second (see tick) so it keeps moving between polls.
|
|
225
|
+
function renderProgress(progress) {
|
|
226
|
+
const holder = node('build-progress');
|
|
227
|
+
if (!holder) return;
|
|
228
|
+
holder.hidden = !progress;
|
|
229
|
+
if (!progress) return;
|
|
230
|
+
const percent = progress.percent;
|
|
231
|
+
const bar = node('progress-bar');
|
|
232
|
+
bar.setAttribute('aria-valuenow', String(percent));
|
|
233
|
+
bar.setAttribute(
|
|
234
|
+
'aria-valuetext',
|
|
235
|
+
[progress.step_text + ' · ' + progress.step_label, progress.eta_text].filter(Boolean).join('. ')
|
|
236
|
+
);
|
|
237
|
+
bar.dataset.slow = progress.slow ? 'true' : 'false';
|
|
238
|
+
node('progress-fill').style.inlineSize = percent + '%';
|
|
239
|
+
node('progress-step').textContent = progress.step_text + ' · ' + progress.step_label;
|
|
240
|
+
node('progress-percent').textContent = percent + '%';
|
|
241
|
+
node('progress-eta').textContent = progress.eta_text ? ' · ' + progress.eta_text : '';
|
|
242
|
+
if (!Number.isFinite(started) && Number.isFinite(progress.elapsed_seconds))
|
|
243
|
+
node('elapsed').textContent = formatDuration(progress.elapsed_seconds);
|
|
244
|
+
}
|
|
245
|
+
|
|
232
246
|
function renderLeadAction(action) {
|
|
233
247
|
const holder = node('lead-action');
|
|
234
248
|
if (!holder) return;
|
|
@@ -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
|
|
|
@@ -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>
|
|
@@ -186,6 +186,35 @@ module Workspaces
|
|
|
186
186
|
text ? "#{total['state']} · #{text}" : 'everything, in order'
|
|
187
187
|
end
|
|
188
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
|
+
|
|
189
218
|
def preset_badge_text
|
|
190
219
|
name = @presentation['current_preset']
|
|
191
220
|
name ? "Preset: #{name}" : ''
|
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
|
|
@@ -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',
|
|
@@ -41,7 +43,7 @@ module Workspaces
|
|
|
41
43
|
'busy' => {
|
|
42
44
|
sections: %w(lead technical log),
|
|
43
45
|
headline: 'Setting things up…',
|
|
44
|
-
summary: "
|
|
46
|
+
summary: "We'll take you to the preview as soon as it's ready.",
|
|
45
47
|
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
46
48
|
open_environment: false, open_technical: true
|
|
47
49
|
},
|
|
@@ -91,7 +93,7 @@ module Workspaces
|
|
|
91
93
|
'actions' => actions, 'ready' => status == 'ready' && !busy?,
|
|
92
94
|
'state' => state, 'sections' => sections,
|
|
93
95
|
'headline' => definition[:headline], 'summary' => definition[:summary],
|
|
94
|
-
'
|
|
96
|
+
'lead_action' => lead_action, 'total' => total, 'progress' => progress,
|
|
95
97
|
'current_preset' => current_preset,
|
|
96
98
|
'environment_title' => definition[:environment_title],
|
|
97
99
|
'environment_submit' => definition[:environment_submit],
|
|
@@ -159,11 +161,12 @@ module Workspaces
|
|
|
159
161
|
{ 'state' => failed ? 'failed' : 'complete', 'seconds' => seconds }
|
|
160
162
|
end
|
|
161
163
|
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
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?
|
|
165
168
|
|
|
166
|
-
@snapshot['
|
|
169
|
+
Progress.new(steps: steps, estimates: @snapshot['estimates'], started_at: @snapshot['prepare_started_at']).to_h
|
|
167
170
|
end
|
|
168
171
|
|
|
169
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
|
data/lib/workspaces/version.rb
CHANGED
data/lib/workspaces/workflow.rb
CHANGED
|
@@ -86,8 +86,9 @@ module Workspaces
|
|
|
86
86
|
def progress(step, status, duration_seconds: nil)
|
|
87
87
|
@state.transaction do |data|
|
|
88
88
|
entry = data.fetch(@workspace.id)
|
|
89
|
-
entry['steps'].find { |item| item['key'] == step['id'] }
|
|
90
|
-
|
|
89
|
+
record = entry['steps'].find { |item| item['key'] == step['id'] }
|
|
90
|
+
record.merge!('state' => status, 'duration_seconds' => duration_seconds)
|
|
91
|
+
record['started_at'] = Time.now.utc.iso8601(3) if status == 'active'
|
|
91
92
|
entry['message'] = step['name']
|
|
92
93
|
entry['current_step'] = step['id']
|
|
93
94
|
entry['updated_at'] = Time.now.utc.iso8601
|
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.13
|
|
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-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -71,6 +71,7 @@ files:
|
|
|
71
71
|
- exe/workspaces
|
|
72
72
|
- lib/workspaces.rb
|
|
73
73
|
- lib/workspaces/backend.rb
|
|
74
|
+
- lib/workspaces/build_history.rb
|
|
74
75
|
- lib/workspaces/child_env.rb
|
|
75
76
|
- lib/workspaces/cli.rb
|
|
76
77
|
- lib/workspaces/command_runner.rb
|
|
@@ -89,6 +90,7 @@ files:
|
|
|
89
90
|
- lib/workspaces/paths.rb
|
|
90
91
|
- lib/workspaces/port_allocator.rb
|
|
91
92
|
- lib/workspaces/presentation.rb
|
|
93
|
+
- lib/workspaces/progress.rb
|
|
92
94
|
- lib/workspaces/proxy_middleware.rb
|
|
93
95
|
- lib/workspaces/recipe.rb
|
|
94
96
|
- lib/workspaces/registry.rb
|