workspaces 0.1.18 → 0.1.19
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 +22 -21
- data/README.md +42 -2
- data/lib/workspaces/config.rb +4 -0
- data/lib/workspaces/coordinator.rb +8 -0
- data/lib/workspaces/dashboard/app.js +21 -5
- data/lib/workspaces/dashboard/card.html.erb +42 -0
- data/lib/workspaces/dashboard/styles.css +145 -90
- data/lib/workspaces/dashboard/template.html.erb +3 -3
- data/lib/workspaces/dashboard_view.rb +96 -16
- data/lib/workspaces/front_door.rb +102 -7
- data/lib/workspaces/presentation.rb +1 -1
- data/lib/workspaces/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1663319f23323ff66ace4b0744db8bd152448d78ed38d164299bf3c8e641ef8d
|
|
4
|
+
data.tar.gz: 44dea1893b4fc1299a7bb2c064b302e556b3574baebd2b7215fad8747c6b80c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c00c46b1ac312ae72bb239d74da78fe7c8b4094e0a04d6ae0ebf44232df1050c63f0d5c8a15232daef42d7b9ba526ca71cf86c70731728878db936d98a8b939d
|
|
7
|
+
data.tar.gz: d285c6a6d3f77f94da739295120d7538babc59de94c37fa538eeb0e49c6be6eaefaefe72e2524b9ad448a41032e676fa57f445d359adf15a0bdeba55046ccecc
|
data/DESIGN.md
CHANGED
|
@@ -8,26 +8,22 @@ GET is read-only. All creation and lifecycle controls submit same-origin POST fo
|
|
|
8
8
|
|
|
9
9
|
## Visual direction
|
|
10
10
|
|
|
11
|
-
Restrained tooling UI:
|
|
11
|
+
Restrained tooling UI: a near-black page, one slightly lighter card surface with a hairline border and no shadow, a compact spacing rhythm, clear status affordances, and no decorative animation. Every text pairing is at least 4.5:1 against its surface.
|
|
12
12
|
|
|
13
13
|
## Tokens used
|
|
14
14
|
|
|
15
15
|
### Color
|
|
16
16
|
|
|
17
|
-
- `--bg`:
|
|
18
|
-
- `--
|
|
19
|
-
- `--
|
|
20
|
-
- `--text
|
|
21
|
-
- `--
|
|
22
|
-
- `--accent`: active status and primary action
|
|
23
|
-
- `--success`: completed/ready states
|
|
24
|
-
- `--danger`: failed/error states
|
|
25
|
-
- `--warning`: retry/connection warning text
|
|
17
|
+
- `--bg` `#0b0f17`: page. `--bg-elevated` `#141a26`: card surface. `--border` `#2a3447`: card edge. `--divider` `#252e41`: row dividers. `--border-strong` `#3f4b63`: the table header rule.
|
|
18
|
+
- `--control-border`: the edge of inputs, outline buttons and tabs. Currently `#3f4b63`, about 2:1 against the card. WCAG 1.4.11 asks 3:1 for a control's boundary, so this is a known shortfall, kept to match the design handoff; `#5b6b8a` or lighter reaches 3:1. It is one token, so changing it is a one-line edit.
|
|
19
|
+
- Text: `--text-strong` `#f8fafc` (headings, workspace names, input values), `--text` `#e2e8f0` (body), `--text-cell` `#cbd5e1` (table cells, logs), `--muted` `#a8b3c7` (secondary), `--placeholder` `#8a97ad`.
|
|
20
|
+
- `--accent` `#6fa8ff` with `--on-accent` `#06101f` text on buttons; `--accent-hover` `#8dbbff` for link and button hover. `--success`, `--danger` and `--warning` are unchanged.
|
|
21
|
+
- **Status pills** take three custom properties, `--pill-bg`, `--pill-text` and `--pill-dot`, set per `data-status`. Sleeping indigo, idle amber and stopped red come from the handoff. Ready green, preparing cyan, error deeper red and interrupted orange were chosen to the same contrast (text 8:1 or better, dot 3:1 or better). Any other status gets the neutral slate pair.
|
|
26
22
|
|
|
27
23
|
### Radius and depth
|
|
28
24
|
|
|
29
|
-
- `--radius`: 12px panel corners
|
|
30
|
-
- `--shadow`:
|
|
25
|
+
- `--radius`: 12px panel corners. `--radius-control`: 8px for inputs, buttons and tabs. `--control-height`: 44px.
|
|
26
|
+
- `--shadow`: none. Panels are separated by their border alone.
|
|
31
27
|
|
|
32
28
|
### Spacing scale
|
|
33
29
|
|
|
@@ -35,20 +31,25 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
35
31
|
|
|
36
32
|
### Typography
|
|
37
33
|
|
|
38
|
-
-
|
|
39
|
-
-
|
|
34
|
+
- Interface: Inter (`--font-sans`) at 15px body, 14px labels at 600, h1 32px at 700, panel h2 20px at 600, table column headers 12px at 600 uppercase with 0.06em tracking.
|
|
35
|
+
- Workspace names, input values and logs: JetBrains Mono (`--font-mono`), 14px. Both stacks fall back to system fonts, because nothing is loaded from the network.
|
|
36
|
+
- Timestamps use `font-variant-numeric: tabular-nums`.
|
|
40
37
|
|
|
41
38
|
## Component primitives
|
|
42
39
|
|
|
43
|
-
- **Creation form**: labelled
|
|
44
|
-
- **Workspace inventory**:
|
|
45
|
-
- **Status pill**:
|
|
40
|
+
- **Creation form**: four labelled fields in one wrapping row (`flex: 1 1 220px`, 16px gap), each with a real `<label for>`, then a `type="submit"` button on its own line.
|
|
41
|
+
- **Workspace inventory**: a centered column up to 1180px. The page header carries "N managed worktrees · none running" (or "· K running"), counted from the data. The table sits in an `overflow-x: auto` wrapper with `min-width: 820px`, so a phone scrolls it sideways instead of cramming or hiding columns. Rows have 16px padding; workspace names are monospaced links with no underline until hover. Times read "Oct 27, 10:48", are UTC (the headers say so) and keep the full ISO value in a `title`. A missing start reads "Never" and a missing creation time "Unavailable", in the muted colour.
|
|
42
|
+
- **Status pill**: a dot and one word (13px, 600, fully rounded), never colour alone. A busy workspace adds "(busy)" and pulses its dot, which stops under reduced motion.
|
|
46
43
|
- **Setup progress**: names and ordered states come from repository run steps; failures are highlighted. Restart shows the background launch and later readiness commands.
|
|
47
44
|
- **Actions by state**: idle/stopped offers Start; active operations show progress without lifecycle buttons; failure offers Retry; ready offers Open workspace plus rebuild/restart/stop.
|
|
48
45
|
- **PR source update**: a secondary Update from PR form only for PR-sourced workspaces; it fetches the latest PR head, fast-forwards the checkout, and preserves local edits without rebuilding or restarting. A persistent result message explains when rebuild is needed or when a retained stash needs manual recovery.
|
|
49
46
|
- **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
|
|
50
47
|
- **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
|
|
51
|
-
- **
|
|
48
|
+
- **Workspace card**: the default page for a workspace (`/workspaces/ID`, and the same card at the visitor's own address on a preview host while the workspace is not serving). One centered card, about 26rem wide: the workspace id with a gear (an icon link to `/workspaces/ID/settings`, labelled "Settings and details"), the headline and a muted sentence, the *Preset: name* badge when the saved settings match a preset, the progress bar with its step line, elapsed time and estimate, then at most one large full-width action. Idle shows a single start form with a preset select; ready shows **Open workspace**; asleep or stopped shows **Wake workspace** or **Start workspace**; failure shows the shortened error with a link to the full log. The card never opens the preview by itself.
|
|
49
|
+
- **Shared with the settings page**: the card reuses the page's element ids and `app.js`, so polling needs no second script. `app.js` tolerates the elements the card lacks (actions, steps, logs, environment).
|
|
50
|
+
- **On a preview host** (`data-base="/__workspaces"`, `data-reload-when-ready="true"`): the card is served from the workspace's own origin, so it is the smallest surface that can work. It polls `/__workspaces/status`, posts only to `/__workspaces/start|prepare|restart|resume`, shows a fixed failure line instead of error text, and reloads the current page once when the workspace is ready. Everything else (settings, environment, secrets, logs, removal) stays on the shared dashboard origin behind its same-origin check.
|
|
51
|
+
- **Settings page**: the full page described below, with a *Back to workspace* link. It is where Update from PR, keep-awake, rebuild, restart, stop, environment editing and deletion live.
|
|
52
|
+
- **Three layers** (the settings page; the card shows only the lead): 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
53
|
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
54
|
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
55
|
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.
|
|
@@ -89,6 +90,6 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
89
90
|
|
|
90
91
|
## Responsive behavior
|
|
91
92
|
|
|
92
|
-
- Single-column layout with intrinsic grid sections
|
|
93
|
-
-
|
|
94
|
-
-
|
|
93
|
+
- Single-column layout with intrinsic grid sections; panel padding is 28px and compresses to 20px at <=640px
|
|
94
|
+
- The workspace table is the one deliberate exception to "no horizontal scrolling": it keeps its columns and scrolls inside its own wrapper, rather than stacking rows and hiding the dates
|
|
95
|
+
- Everything else fits a 375px viewport
|
data/README.md
CHANGED
|
@@ -120,7 +120,9 @@ A ready workspace that sees no preview traffic for a while is put to sleep: its
|
|
|
120
120
|
managed process group, and nothing shared such as PostgreSQL or Redis) is stopped, and the
|
|
121
121
|
front door keeps answering on the same preview URL. The worktree, database, dependencies, built
|
|
122
122
|
assets, environment settings and build history are untouched. The first request to a sleeping
|
|
123
|
-
preview wakes it
|
|
123
|
+
preview wakes it. A browser opening a page is shown the waiting card (below) at that address, and
|
|
124
|
+
the card reloads the page once the workspace is ready. Any other request (an API call, an asset, a
|
|
125
|
+
form post) waits for readiness and is then forwarded.
|
|
124
126
|
|
|
125
127
|
```sh
|
|
126
128
|
WORKSPACES_SLEEP_AFTER_MINUTES=30 # default; 15 also works; 0 switches sleeping off
|
|
@@ -154,7 +156,8 @@ What counts as activity:
|
|
|
154
156
|
How waking behaves:
|
|
155
157
|
|
|
156
158
|
- Only an intentionally sleeping workspace wakes. A workspace stopped by hand, failed, or still
|
|
157
|
-
preparing is never started by traffic
|
|
159
|
+
preparing is never started by traffic. A browser opening a page is shown the waiting card; any
|
|
160
|
+
other GET is redirected to the card on the dashboard host, and other methods get `503`.
|
|
158
161
|
- Simultaneous first requests share one wake and start one server. A request is sent upstream
|
|
159
162
|
once, never replayed, and keeps its method and body.
|
|
160
163
|
- A wake runs only the server and readiness steps. If a step before the server changed in
|
|
@@ -194,6 +197,43 @@ provides `WORKSPACE_RUN_ID`). The runner also supplies `WORKSPACE_URL` and
|
|
|
194
197
|
`WORKSPACE_HOST` for applications that need browser-facing URLs and host settings.
|
|
195
198
|
`serve` does **not** start workspace backends; use `start` or `restart` for those.
|
|
196
199
|
|
|
200
|
+
### The workspace card and the settings page
|
|
201
|
+
|
|
202
|
+
`/workspaces/<id>` is one small card in the middle of the screen: a headline, the progress bar
|
|
203
|
+
while something is running, the preset in use, and the single next action. That action is **Open
|
|
204
|
+
workspace** when ready, **Wake workspace** or **Start workspace** when asleep or stopped, and
|
|
205
|
+
**Retry failed step** or **Continue setup** after a failure. A gear links to
|
|
206
|
+
`/workspaces/<id>/settings`, which is the full page: environment variables and secrets, setup
|
|
207
|
+
steps and logs, workspace details, lifecycle controls and removal. A new workspace starts from the
|
|
208
|
+
card by choosing one preset (or the defaults); use the settings page to edit variables first.
|
|
209
|
+
Update from PR lives on the settings page and returns there. Nothing opens the preview for you;
|
|
210
|
+
the card only offers the button.
|
|
211
|
+
|
|
212
|
+
The `/workspaces` list and the PR page are unchanged.
|
|
213
|
+
|
|
214
|
+
#### The waiting card on a preview host
|
|
215
|
+
|
|
216
|
+
When a browser opens a page of a workspace that is not serving (stopped, preparing, failed or
|
|
217
|
+
asleep), the preview host answers `200` with the same card instead of redirecting to the
|
|
218
|
+
dashboard, so the address in the bar is the one the visitor asked for. A sleeping workspace is
|
|
219
|
+
woken at once. The card polls its status and, when the workspace is ready, reloads the page, so the
|
|
220
|
+
visitor lands where they meant to go, query string included.
|
|
221
|
+
|
|
222
|
+
- Only browser navigations see it: a `GET` that accepts `text/html`, is not an `XMLHttpRequest`
|
|
223
|
+
and, when the browser says, has `Sec-Fetch-Mode: navigate`. API calls, assets and other methods
|
|
224
|
+
behave as before.
|
|
225
|
+
- The preview origin also serves the workspace's own app, so the card is deliberately small. Its
|
|
226
|
+
endpoints are `GET /__workspaces/status` and `POST /__workspaces/start|prepare|restart|resume`.
|
|
227
|
+
The prefix is reserved: any other path under it answers `404`, and it is never forwarded to the
|
|
228
|
+
app. Settings, environment, secrets, logs and removal are not reachable from it.
|
|
229
|
+
- The posts accept only the workspace's own preview origin (the `Origin`, or `Referer`, must match
|
|
230
|
+
`ws-<id>…`); the shared dashboard origin and other workspaces are refused. After a post the
|
|
231
|
+
visitor returns to the page they were on.
|
|
232
|
+
- The status payload carries no logs, steps, environment values or error text. A failure reads
|
|
233
|
+
"Setup failed. Open settings for the full log.", with the gear and link going to the shared
|
|
234
|
+
settings page.
|
|
235
|
+
- Polling the card is not preview traffic, so it never keeps a workspace awake.
|
|
236
|
+
|
|
197
237
|
### Re-running part of a run
|
|
198
238
|
|
|
199
239
|
When a run fails, or is interrupted, you do not have to start over. `resume` continues from the
|
data/lib/workspaces/config.rb
CHANGED
|
@@ -130,6 +130,10 @@ module Workspaces
|
|
|
130
130
|
"#{public_origin}/workspaces/#{validate_id!(id)}"
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
def self.settings_url(id)
|
|
134
|
+
"#{dashboard_url(id)}/settings"
|
|
135
|
+
end
|
|
136
|
+
|
|
133
137
|
def self.validate_preview_subdomain_prefix!(prefix)
|
|
134
138
|
parts = prefix.to_s.split('.', -1)
|
|
135
139
|
raise ArgumentError, 'WORKSPACES_SUBDOMAIN_PREFIX must not be empty' if parts.empty? || parts.any?(&:empty?)
|
|
@@ -74,6 +74,14 @@ module Workspaces
|
|
|
74
74
|
})
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# Applies one named preset (see EnvironmentOverrides#apply_preset) and starts the workspace,
|
|
78
|
+
# restarting it if it is already up.
|
|
79
|
+
def apply_environment_preset(id, name:)
|
|
80
|
+
restart = Backend.new(id).running? || sleeping?(id)
|
|
81
|
+
launch(id, restart: restart, force: true,
|
|
82
|
+
prelaunch: ->(_workspace) { EnvironmentOverrides.new(id).apply_preset(name) })
|
|
83
|
+
end
|
|
84
|
+
|
|
77
85
|
def replace_editable_environment(id, text:)
|
|
78
86
|
restart = Backend.new(id).running? || sleeping?(id)
|
|
79
87
|
launch(id, restart: restart, force: true,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const root = document.getElementById('workspace');
|
|
4
4
|
if (!root) return;
|
|
5
5
|
const node = (id) => document.getElementById(id);
|
|
6
|
-
const base = '/workspaces/' + root.dataset.id;
|
|
6
|
+
const base = root.dataset.base || '/workspaces/' + root.dataset.id;
|
|
7
7
|
const selectedLog = new URLSearchParams(window.location.search).get('log') === 'backend'
|
|
8
8
|
? 'backend'
|
|
9
9
|
: 'setup';
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
const signature = JSON.stringify(actions);
|
|
31
31
|
if (signature === actionsSignature) return;
|
|
32
32
|
actionsSignature = signature;
|
|
33
|
-
node('actions')
|
|
33
|
+
const holder = node('actions');
|
|
34
|
+
if (!holder) return;
|
|
35
|
+
holder.replaceChildren();
|
|
34
36
|
actions.forEach((action) => {
|
|
35
37
|
if (!allowedActions.includes(action.key)) return;
|
|
36
38
|
const form = document.createElement('form');
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
'button ' + (action.primary ? 'button--primary' : 'button--subtle');
|
|
42
44
|
button.textContent = action.label;
|
|
43
45
|
form.append(button);
|
|
44
|
-
|
|
46
|
+
holder.append(form);
|
|
45
47
|
});
|
|
46
48
|
toggleBusyState(busy);
|
|
47
49
|
}
|
|
@@ -213,7 +215,8 @@
|
|
|
213
215
|
const full = node('full-log');
|
|
214
216
|
if (full) writeLog(full.querySelector('pre'), data.log_tail);
|
|
215
217
|
}
|
|
216
|
-
node('updated')
|
|
218
|
+
const updated = node('updated');
|
|
219
|
+
if (updated) updated.textContent = data.updated_at || '';
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
function update(data) {
|
|
@@ -226,7 +229,8 @@
|
|
|
226
229
|
renderStatus(view);
|
|
227
230
|
renderSections(view);
|
|
228
231
|
if (node('message')) node('message').textContent = data.message || '';
|
|
229
|
-
node('source-update-message')
|
|
232
|
+
const sourceMessage = node('source-update-message');
|
|
233
|
+
if (sourceMessage) sourceMessage.textContent = data.source_update_message || '';
|
|
230
234
|
renderSteps(view, data.step_logs);
|
|
231
235
|
renderActions(view.actions);
|
|
232
236
|
if (window.WorkspaceEnvironment)
|
|
@@ -271,6 +275,9 @@
|
|
|
271
275
|
badge.textContent = view.current_preset ? 'Preset: ' + view.current_preset : '';
|
|
272
276
|
}
|
|
273
277
|
renderLeadAction(view.lead_action);
|
|
278
|
+
root.querySelectorAll('[data-only-state]').forEach((element) => {
|
|
279
|
+
element.hidden = element.dataset.onlyState !== view.state;
|
|
280
|
+
});
|
|
274
281
|
}
|
|
275
282
|
|
|
276
283
|
// The lead action is a lifecycle post (start/retry) or, when ready, the preview link
|
|
@@ -334,8 +341,17 @@
|
|
|
334
341
|
);
|
|
335
342
|
}
|
|
336
343
|
|
|
344
|
+
// A card on a preview host stands in for the page the visitor asked for; reloading brings them to it.
|
|
345
|
+
let reloading = false;
|
|
346
|
+
function reloadWhenReady(view) {
|
|
347
|
+
if (!view.ready || reloading || root.dataset.reloadWhenReady !== 'true') return;
|
|
348
|
+
reloading = true;
|
|
349
|
+
window.location.reload();
|
|
350
|
+
}
|
|
351
|
+
|
|
337
352
|
function renderStatus(view) {
|
|
338
353
|
node('preview').hidden = !view.ready;
|
|
354
|
+
reloadWhenReady(view);
|
|
339
355
|
node('error').textContent = view.error || '';
|
|
340
356
|
node('error-panel').hidden = !view.error;
|
|
341
357
|
if (window.WorkspaceEnvironment)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<main class="card-page" id="workspace" data-id="<%= @id %>" data-base="<%= action_base %>" data-reload-when-ready="<%= @preview ? 'true' : 'false' %>" 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
|
+
<section class="card" aria-labelledby="status">
|
|
3
|
+
<header class="card__header">
|
|
4
|
+
<p class="card__name"><span class="eyebrow">Workspace</span><span class="card__id"><%= @id %></span></p>
|
|
5
|
+
<a class="icon-link" href="<%= escape(settings_href) %>" aria-label="Settings and details" title="Settings and details"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M10.52 5.57L10.69 2.69L13.31 2.69L13.48 5.57A6.6 6.6 0 0 1 15.50 6.40L17.66 4.49L19.51 6.34L17.60 8.50A6.6 6.6 0 0 1 18.43 10.52L21.31 10.69L21.31 13.31L18.43 13.48A6.6 6.6 0 0 1 17.60 15.50L19.51 17.66L17.66 19.51L15.50 17.60A6.6 6.6 0 0 1 13.48 18.43L13.31 21.31L10.69 21.31L10.52 18.43A6.6 6.6 0 0 1 8.50 17.60L6.34 19.51L4.49 17.66L6.40 15.50A6.6 6.6 0 0 1 5.57 13.48L2.69 13.31L2.69 10.69L5.57 10.52A6.6 6.6 0 0 1 6.40 8.50L4.49 6.34L6.34 4.49L8.50 6.40A6.6 6.6 0 0 1 10.52 5.57ZM9.20 12.00a2.8 2.8 0 1 0 5.6 0a2.8 2.8 0 1 0 -5.6 0Z"/></svg></a>
|
|
6
|
+
</header>
|
|
7
|
+
<div class="card__text">
|
|
8
|
+
<h1 id="status" role="status"><%= escape(@presentation['headline']) %></h1>
|
|
9
|
+
<p id="summary" class="card__summary"><%= escape(@presentation['summary']) %></p>
|
|
10
|
+
<p><span class="badge" id="environment-preset-badge" <%= 'hidden' unless @presentation['current_preset'] %>><%= escape(preset_badge_text) %></span></p>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="build-progress" id="build-progress" <%= 'hidden' unless @presentation['progress'] %>>
|
|
13
|
+
<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>
|
|
14
|
+
<div class="progress-meta">
|
|
15
|
+
<p class="progress-meta__step"><span class="activity-dot" aria-hidden="true"></span><span id="progress-step"><%= escape(progress_step_text) %></span></p>
|
|
16
|
+
<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>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<form method="post" action="<%= start_form_action %>" class="card__start stack stack--snug lead__action" data-only-state="idle" <%= 'hidden' unless @presentation['state'] == 'idle' %>>
|
|
20
|
+
<% unless environment_presets.empty? %>
|
|
21
|
+
<label class="field" for="card-preset">Preset
|
|
22
|
+
<select id="card-preset" name="preset">
|
|
23
|
+
<% environment_presets.each_key do |name| %><option value="<%= escape(name) %>"><%= escape(name) %></option><% end %>
|
|
24
|
+
<option value="">Defaults</option>
|
|
25
|
+
</select>
|
|
26
|
+
</label>
|
|
27
|
+
<% end %>
|
|
28
|
+
<button class="button button--primary button--large"><%= escape(@presentation['environment_submit']) %></button>
|
|
29
|
+
</form>
|
|
30
|
+
<div class="card__actions">
|
|
31
|
+
<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>
|
|
32
|
+
<div id="lead-action" class="lead__action"><%= lead_action_form(@presentation['lead_action']) %></div>
|
|
33
|
+
</div>
|
|
34
|
+
<p id="sleep-note" class="muted" <%= 'hidden' if sleep_note.empty? %>><%= escape(sleep_note) %></p>
|
|
35
|
+
<section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
|
|
36
|
+
<h2>What went wrong</h2><pre id="error"><%= escape(@presentation['error']) %></pre>
|
|
37
|
+
<a href="<%= escape(settings_href) %>">Open the full log</a>
|
|
38
|
+
</section>
|
|
39
|
+
<p id="connection" role="status"></p>
|
|
40
|
+
</section>
|
|
41
|
+
<noscript><a href="<%= @preview ? '' : "/workspaces/#{@id}" %>">Refresh status</a></noscript>
|
|
42
|
+
</main>
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
:root {
|
|
2
|
-
|
|
3
|
-
--bg
|
|
4
|
-
--
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
2
|
+
/* Surfaces. Page, then card, then the hairlines that divide them. */
|
|
3
|
+
--bg: #0b0f17;
|
|
4
|
+
--bg-elevated: #141a26;
|
|
5
|
+
--border: #2a3447;
|
|
6
|
+
--divider: #252e41;
|
|
7
|
+
--border-strong: #3f4b63;
|
|
8
|
+
/* The edge of an input or outline button. WCAG asks 3:1 for a control's boundary against what is
|
|
9
|
+
behind it; #3f4b63 is about 2:1 on the card. One token, so raising it is a one-line change. */
|
|
10
|
+
--control-border: #3f4b63;
|
|
11
|
+
/* Text. Every pairing with a surface is at least 4.5:1. */
|
|
12
|
+
--text-strong: #f8fafc;
|
|
13
|
+
--text: #e2e8f0;
|
|
14
|
+
--text-cell: #cbd5e1;
|
|
15
|
+
--muted: #a8b3c7;
|
|
16
|
+
--placeholder: #8a97ad;
|
|
17
|
+
--accent: #6fa8ff;
|
|
18
|
+
--accent-hover: #8dbbff;
|
|
19
|
+
--on-accent: #06101f;
|
|
8
20
|
--success: #2fbf8f;
|
|
9
21
|
--danger: #ff6f74;
|
|
10
22
|
--warning: #f1bb53;
|
|
11
23
|
--radius: 12px;
|
|
12
|
-
--
|
|
24
|
+
--radius-control: 8px;
|
|
25
|
+
--control-height: 44px;
|
|
26
|
+
--shadow: none;
|
|
27
|
+
--font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
28
|
+
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
13
29
|
--space-1: 4px;
|
|
14
30
|
--space-2: 8px;
|
|
15
31
|
--space-3: 12px;
|
|
@@ -18,7 +34,7 @@
|
|
|
18
34
|
--space-6: 24px;
|
|
19
35
|
--space-8: 32px;
|
|
20
36
|
--space-10: 40px;
|
|
21
|
-
--panel-padding:
|
|
37
|
+
--panel-padding: 28px;
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
* {
|
|
@@ -29,6 +45,8 @@
|
|
|
29
45
|
which tag was used. Reset them once; every gap is declared by a stack rule below
|
|
30
46
|
using the --space-* scale. */
|
|
31
47
|
h1, h2, h3, h4, p, ul, ol, dl, dd, pre, fieldset, figure { margin: 0; }
|
|
48
|
+
h2 { font-size: 20px; font-weight: 600; line-height: 1.3; color: var(--text-strong); }
|
|
49
|
+
label { font-size: 14px; font-weight: 600; }
|
|
32
50
|
ol, ul { padding-inline-start: var(--space-6); }
|
|
33
51
|
legend { padding: 0; }
|
|
34
52
|
|
|
@@ -45,9 +63,20 @@ legend { padding: 0; }
|
|
|
45
63
|
.stack--loose { gap: var(--space-6); }
|
|
46
64
|
|
|
47
65
|
[hidden] { display: none !important; }
|
|
48
|
-
input
|
|
49
|
-
|
|
50
|
-
|
|
66
|
+
input:not([type='checkbox']):not([type='radio']), select {
|
|
67
|
+
max-inline-size: 100%;
|
|
68
|
+
block-size: var(--control-height);
|
|
69
|
+
padding-inline: var(--space-3);
|
|
70
|
+
color: var(--text-strong);
|
|
71
|
+
background: var(--bg);
|
|
72
|
+
border: 1px solid var(--control-border);
|
|
73
|
+
border-radius: var(--radius-control);
|
|
74
|
+
font-family: var(--font-mono);
|
|
75
|
+
font-size: 14px;
|
|
76
|
+
}
|
|
77
|
+
select { font-family: var(--font-sans); font-size: 15px; }
|
|
78
|
+
::placeholder { color: var(--placeholder); opacity: 1; }
|
|
79
|
+
input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
51
80
|
summary { cursor: pointer; color: var(--muted); }
|
|
52
81
|
summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
53
82
|
.workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
|
@@ -67,9 +96,11 @@ dd + dt { margin-block-start: var(--space-3); }
|
|
|
67
96
|
body {
|
|
68
97
|
margin: 0;
|
|
69
98
|
min-block-size: 100dvb;
|
|
70
|
-
font-family:
|
|
99
|
+
font-family: var(--font-sans);
|
|
100
|
+
font-size: 15px;
|
|
101
|
+
line-height: 1.5;
|
|
71
102
|
color: var(--text);
|
|
72
|
-
background:
|
|
103
|
+
background: var(--bg);
|
|
73
104
|
}
|
|
74
105
|
|
|
75
106
|
a {
|
|
@@ -77,7 +108,7 @@ a {
|
|
|
77
108
|
}
|
|
78
109
|
|
|
79
110
|
.dashboard {
|
|
80
|
-
inline-size: min(100% - var(--space-6),
|
|
111
|
+
inline-size: min(100% - var(--space-6), 1180px);
|
|
81
112
|
margin-inline: auto;
|
|
82
113
|
padding-block: var(--space-8);
|
|
83
114
|
display: grid;
|
|
@@ -91,6 +122,7 @@ a {
|
|
|
91
122
|
align-items: end;
|
|
92
123
|
gap: var(--space-4);
|
|
93
124
|
}
|
|
125
|
+
.dashboard__note { color: var(--muted); font-size: 15px; font-variant-numeric: tabular-nums; }
|
|
94
126
|
|
|
95
127
|
.eyebrow {
|
|
96
128
|
margin: 0;
|
|
@@ -102,9 +134,13 @@ a {
|
|
|
102
134
|
|
|
103
135
|
h1 {
|
|
104
136
|
margin: 0;
|
|
105
|
-
|
|
137
|
+
color: var(--text-strong);
|
|
138
|
+
font-size: 32px;
|
|
139
|
+
font-weight: 700;
|
|
106
140
|
line-height: 1.2;
|
|
107
141
|
}
|
|
142
|
+
/* A workspace id as the page title: long and monospaced, so smaller, and allowed to wrap. */
|
|
143
|
+
.mono-title { font-family: var(--font-mono); font-size: clamp(1.1rem, 2vw + 0.5rem, 1.5rem); }
|
|
108
144
|
|
|
109
145
|
.header-message {
|
|
110
146
|
margin: var(--space-2) 0 0;
|
|
@@ -152,8 +188,8 @@ h1 {
|
|
|
152
188
|
/* Lead: a friendly headline, one sentence, and at most one big button. */
|
|
153
189
|
.lead { gap: var(--space-4); padding-block: var(--space-8); }
|
|
154
190
|
.lead__text { display: grid; gap: var(--space-2); }
|
|
155
|
-
.lead h2 { font-size:
|
|
156
|
-
.lead__summary { max-inline-size: 38rem; color: var(--muted); font-size:
|
|
191
|
+
.lead h2 { font-size: 24px; line-height: 1.25; }
|
|
192
|
+
.lead__summary { max-inline-size: 38rem; color: var(--muted); font-size: 15px; line-height: 1.5; }
|
|
157
193
|
.lead__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
|
158
194
|
.lead__actions:empty { display: none; }
|
|
159
195
|
|
|
@@ -167,7 +203,7 @@ h1 {
|
|
|
167
203
|
.progress-meta__time { font-variant-numeric: tabular-nums; }
|
|
168
204
|
@media (prefers-reduced-motion: reduce) { .progress-bar__fill { transition: none; } }
|
|
169
205
|
.lead__action:empty { display: none; }
|
|
170
|
-
.button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius); }
|
|
206
|
+
.button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius-control); }
|
|
171
207
|
|
|
172
208
|
/* Disclosures: quiet by default, a clear label and a one-line hint of what is inside. */
|
|
173
209
|
/* A disclosure has to read as a control. The native marker is lost once a summary is a flex
|
|
@@ -247,35 +283,6 @@ h1 {
|
|
|
247
283
|
font-size: 13px;
|
|
248
284
|
}
|
|
249
285
|
|
|
250
|
-
.status-pill {
|
|
251
|
-
display: inline-flex;
|
|
252
|
-
align-items: center;
|
|
253
|
-
gap: var(--space-2);
|
|
254
|
-
margin: 0;
|
|
255
|
-
font-weight: 600;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
.status-dot {
|
|
259
|
-
inline-size: 10px;
|
|
260
|
-
block-size: 10px;
|
|
261
|
-
border-radius: 999px;
|
|
262
|
-
background: var(--warning);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
.status-pill[data-status='ready'] .status-dot {
|
|
266
|
-
background: var(--success);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
.status-pill[data-status='sleeping'] .status-dot {
|
|
270
|
-
background: var(--muted);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
.status-pill[data-status='error'] .status-dot,
|
|
274
|
-
.status-pill[data-status='interrupted'] .status-dot,
|
|
275
|
-
.status-pill[data-status='stopped'] .status-dot {
|
|
276
|
-
background: var(--danger);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
286
|
.status-value {
|
|
280
287
|
margin: 0;
|
|
281
288
|
font-weight: 600;
|
|
@@ -376,11 +383,11 @@ h1 {
|
|
|
376
383
|
min-block-size: 12rem;
|
|
377
384
|
resize: vertical;
|
|
378
385
|
padding: var(--space-3);
|
|
379
|
-
color: var(--text);
|
|
386
|
+
color: var(--text-strong);
|
|
380
387
|
background: var(--bg);
|
|
381
|
-
border: 1px solid var(--border);
|
|
382
|
-
border-radius: var(--
|
|
383
|
-
font-family:
|
|
388
|
+
border: 1px solid var(--control-border);
|
|
389
|
+
border-radius: var(--radius-control);
|
|
390
|
+
font-family: var(--font-mono);
|
|
384
391
|
line-height: 1.5;
|
|
385
392
|
white-space: pre;
|
|
386
393
|
overflow: auto;
|
|
@@ -399,12 +406,14 @@ h1 {
|
|
|
399
406
|
.env-row input[type='password'],
|
|
400
407
|
.env-row .env-row__name {
|
|
401
408
|
min-inline-size: 0;
|
|
402
|
-
|
|
403
|
-
|
|
409
|
+
block-size: var(--control-height);
|
|
410
|
+
padding: 0 var(--space-3);
|
|
411
|
+
color: var(--text-strong);
|
|
404
412
|
background: var(--bg);
|
|
405
|
-
border: 1px solid var(--border);
|
|
406
|
-
border-radius: var(--
|
|
407
|
-
font-family:
|
|
413
|
+
border: 1px solid var(--control-border);
|
|
414
|
+
border-radius: var(--radius-control);
|
|
415
|
+
font-family: var(--font-mono);
|
|
416
|
+
font-size: 14px;
|
|
408
417
|
}
|
|
409
418
|
|
|
410
419
|
.env-row[data-secret] .env-row__value { border-style: dashed; }
|
|
@@ -470,9 +479,9 @@ fieldset:disabled {
|
|
|
470
479
|
display: inline-flex;
|
|
471
480
|
justify-content: center;
|
|
472
481
|
align-items: center;
|
|
473
|
-
min-block-size:
|
|
474
|
-
padding-inline: var(--space-
|
|
475
|
-
border-radius:
|
|
482
|
+
min-block-size: var(--control-height);
|
|
483
|
+
padding-inline: var(--space-5);
|
|
484
|
+
border-radius: var(--radius-control);
|
|
476
485
|
border: 1px solid transparent;
|
|
477
486
|
text-decoration: none;
|
|
478
487
|
font-size: 14px;
|
|
@@ -490,13 +499,14 @@ fieldset:disabled {
|
|
|
490
499
|
|
|
491
500
|
.button:focus-visible {
|
|
492
501
|
outline: 2px solid var(--accent);
|
|
493
|
-
outline-offset:
|
|
502
|
+
outline-offset: 1px;
|
|
494
503
|
}
|
|
495
504
|
|
|
496
505
|
.button--primary {
|
|
497
506
|
background: var(--accent);
|
|
498
|
-
color:
|
|
507
|
+
color: var(--on-accent);
|
|
499
508
|
}
|
|
509
|
+
.button--primary:hover:not(:disabled) { background: var(--accent-hover); }
|
|
500
510
|
|
|
501
511
|
.button--primary:disabled {
|
|
502
512
|
opacity: 0.55;
|
|
@@ -505,10 +515,10 @@ fieldset:disabled {
|
|
|
505
515
|
}
|
|
506
516
|
|
|
507
517
|
.button--subtle {
|
|
508
|
-
border-color: var(--border);
|
|
518
|
+
border-color: var(--control-border);
|
|
509
519
|
}
|
|
510
520
|
|
|
511
|
-
.button--small { padding: 4px 10px; font-size: 12px; }
|
|
521
|
+
.button--small { min-block-size: 32px; padding: 4px 10px; font-size: 12px; }
|
|
512
522
|
|
|
513
523
|
.step-log__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); padding: var(--space-2) var(--space-3) 0; }
|
|
514
524
|
.step-log__actions[hidden] { display: none; }
|
|
@@ -525,17 +535,55 @@ fieldset:disabled {
|
|
|
525
535
|
.danger-zone h2 { color: var(--danger); }
|
|
526
536
|
.danger-zone form { display: grid; justify-items: start; gap: var(--space-3); }
|
|
527
537
|
|
|
528
|
-
.
|
|
538
|
+
.table-scroll { overflow-x: auto; }
|
|
539
|
+
.workspace-table { inline-size: 100%; min-inline-size: 820px; border-collapse: collapse; }
|
|
529
540
|
.workspace-table th,
|
|
530
|
-
.workspace-table td { padding:
|
|
541
|
+
.workspace-table td { padding: 16px var(--space-4); text-align: start; vertical-align: middle; border-block-end: 1px solid var(--divider); overflow-wrap: anywhere; }
|
|
531
542
|
.workspace-table th:first-child,
|
|
532
543
|
.workspace-table td:first-child { padding-inline-start: 0; }
|
|
533
544
|
.workspace-table th:last-child,
|
|
534
545
|
.workspace-table td:last-child { padding-inline-end: 0; }
|
|
535
|
-
.workspace-table thead th { padding-block
|
|
536
|
-
.workspace-table tbody tr:last-child > * {
|
|
537
|
-
.workspace-table tbody th { font-weight: 600; }
|
|
538
|
-
.workspace-
|
|
546
|
+
.workspace-table thead th { padding-block: 0 12px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; border-block-end-color: var(--border-strong); }
|
|
547
|
+
.workspace-table tbody tr:last-child > * { border-block-end: 0; }
|
|
548
|
+
.workspace-table tbody th { font-family: var(--font-mono); font-size: 14px; font-weight: 600; }
|
|
549
|
+
.workspace-table tbody th a { color: var(--text-strong); text-decoration: none; }
|
|
550
|
+
.workspace-table tbody th a:hover { color: var(--accent-hover); text-decoration: underline; }
|
|
551
|
+
.workspace-table tbody th a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
|
|
552
|
+
.workspace-table td { color: var(--text-cell); }
|
|
553
|
+
.workspace-table__date { font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
554
|
+
.workspace-table__date .never { color: var(--muted); }
|
|
555
|
+
|
|
556
|
+
/* A status is a dot and a word, never colour alone. The colours come from three custom properties so a
|
|
557
|
+
new status is one rule. Unlisted statuses fall back to the neutral pair. */
|
|
558
|
+
.status-pill {
|
|
559
|
+
--pill-bg: #252e41; --pill-text: #cbd5e1; --pill-dot: #94a3b8;
|
|
560
|
+
display: inline-flex;
|
|
561
|
+
align-items: center;
|
|
562
|
+
gap: 7px;
|
|
563
|
+
margin: 0;
|
|
564
|
+
padding: 3px 10px;
|
|
565
|
+
border-radius: 999px;
|
|
566
|
+
background: var(--pill-bg);
|
|
567
|
+
color: var(--pill-text);
|
|
568
|
+
font-size: 13px;
|
|
569
|
+
font-weight: 600;
|
|
570
|
+
line-height: 1.5;
|
|
571
|
+
white-space: nowrap;
|
|
572
|
+
}
|
|
573
|
+
.status-dot { inline-size: 7px; block-size: 7px; border-radius: 999px; background: var(--pill-dot); }
|
|
574
|
+
.status-pill[data-status='sleeping'] { --pill-bg: #232c4a; --pill-text: #c7d2fe; --pill-dot: #a5b4fc; }
|
|
575
|
+
.status-pill[data-status='idle'] { --pill-bg: #3a2e10; --pill-text: #fcd34d; --pill-dot: #fbbf24; }
|
|
576
|
+
.status-pill[data-status='stopped'] { --pill-bg: #43191f; --pill-text: #fca5a5; --pill-dot: #f87171; }
|
|
577
|
+
.status-pill[data-status='ready'] { --pill-bg: #12301f; --pill-text: #86efac; --pill-dot: #4ade80; }
|
|
578
|
+
.status-pill[data-status='preparing'] { --pill-bg: #0f2f3d; --pill-text: #a5f3fc; --pill-dot: #22d3ee; }
|
|
579
|
+
.status-pill[data-status='error'] { --pill-bg: #5a1a22; --pill-text: #fecaca; --pill-dot: #f87171; }
|
|
580
|
+
.status-pill[data-status='interrupted'] { --pill-bg: #43250f; --pill-text: #fdba74; --pill-dot: #fb923c; }
|
|
581
|
+
.status-pill[data-busy='true'] .status-dot { animation: workspace-pulse 1.5s infinite; }
|
|
582
|
+
@media (prefers-reduced-motion: reduce) { .status-pill[data-busy='true'] .status-dot { animation: none; } }
|
|
583
|
+
|
|
584
|
+
/* The create form: four fields in one wrapping row, the button on its own line below. */
|
|
585
|
+
.create-fields { display: flex; flex-wrap: wrap; gap: var(--space-4); }
|
|
586
|
+
.create-fields .field { flex: 1 1 220px; max-inline-size: none; }
|
|
539
587
|
|
|
540
588
|
.field { display: grid; gap: var(--space-2); max-inline-size: 28rem; font-size: 14px; }
|
|
541
589
|
.field input { inline-size: 100%; }
|
|
@@ -550,7 +598,7 @@ ol > li + li { margin-block-start: var(--space-2); }
|
|
|
550
598
|
.muted {
|
|
551
599
|
margin: 0;
|
|
552
600
|
color: var(--muted);
|
|
553
|
-
font-size:
|
|
601
|
+
font-size: 14px;
|
|
554
602
|
}
|
|
555
603
|
|
|
556
604
|
.error-panel h2,
|
|
@@ -566,9 +614,9 @@ ol > li + li { margin-block-start: var(--space-2); }
|
|
|
566
614
|
padding: var(--space-3);
|
|
567
615
|
border-radius: 10px;
|
|
568
616
|
border: 1px solid var(--border);
|
|
569
|
-
background:
|
|
570
|
-
color:
|
|
571
|
-
font-family:
|
|
617
|
+
background: var(--bg);
|
|
618
|
+
color: var(--text-cell);
|
|
619
|
+
font-family: var(--font-mono);
|
|
572
620
|
font-size: 12px;
|
|
573
621
|
line-height: 1.4;
|
|
574
622
|
white-space: pre-wrap;
|
|
@@ -596,8 +644,8 @@ ol > li + li { margin-block-start: var(--space-2); }
|
|
|
596
644
|
.log-switch a {
|
|
597
645
|
color: var(--muted);
|
|
598
646
|
padding: var(--space-2) var(--space-3);
|
|
599
|
-
border: 1px solid var(--border);
|
|
600
|
-
border-radius: var(--
|
|
647
|
+
border: 1px solid var(--control-border);
|
|
648
|
+
border-radius: var(--radius-control);
|
|
601
649
|
text-decoration: none;
|
|
602
650
|
}
|
|
603
651
|
|
|
@@ -641,21 +689,8 @@ ol > li + li { margin-block-start: var(--space-2); }
|
|
|
641
689
|
p:empty:not([role]):not([aria-live]) { display: none; }
|
|
642
690
|
|
|
643
691
|
@media (max-width: 640px) {
|
|
644
|
-
/* Stack each row: workspace, then status. Dates are desktop-only detail. */
|
|
645
|
-
.workspace-table,
|
|
646
|
-
.workspace-table tbody { display: block; }
|
|
647
|
-
.workspace-table thead,
|
|
648
|
-
.workspace-table th.workspace-table__date,
|
|
649
|
-
.workspace-table td.workspace-table__date { display: none; }
|
|
650
|
-
.workspace-table tr { display: grid; gap: var(--space-1); padding-block: var(--space-3); border-block-end: 1px solid var(--border); }
|
|
651
|
-
.workspace-table tbody tr:first-child { padding-block-start: 0; }
|
|
652
|
-
.workspace-table tbody tr:last-child { padding-block-end: 0; border-block-end: 0; }
|
|
653
|
-
.workspace-table th,
|
|
654
|
-
.workspace-table td { display: block; padding: 0; border: 0; }
|
|
655
|
-
.workspace-table tbody tr:last-child > * { padding-block-end: 0; }
|
|
656
|
-
|
|
657
692
|
.dashboard {
|
|
658
|
-
inline-size: min(100% - var(--space-4),
|
|
693
|
+
inline-size: min(100% - var(--space-4), 1180px);
|
|
659
694
|
padding-block: var(--space-6);
|
|
660
695
|
}
|
|
661
696
|
|
|
@@ -664,7 +699,7 @@ p:empty:not([role]):not([aria-live]) { display: none; }
|
|
|
664
699
|
.error-panel,
|
|
665
700
|
.log-panel,
|
|
666
701
|
.noscript-note {
|
|
667
|
-
--panel-padding: var(--space-
|
|
702
|
+
--panel-padding: var(--space-5);
|
|
668
703
|
padding: var(--panel-padding);
|
|
669
704
|
}
|
|
670
705
|
|
|
@@ -673,3 +708,23 @@ p:empty:not([role]):not([aria-live]) { display: none; }
|
|
|
673
708
|
align-items: flex-start;
|
|
674
709
|
}
|
|
675
710
|
}
|
|
711
|
+
|
|
712
|
+
/* The card: the default page for a workspace. One small square in the middle of the screen. */
|
|
713
|
+
.card-page { min-block-size: 100dvb; display: grid; place-items: center; padding: var(--space-6) var(--space-3); }
|
|
714
|
+
.card { inline-size: min(100%, 26rem); display: grid; gap: var(--space-5); align-content: start; padding: var(--panel-padding); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
|
715
|
+
.card__header { display: flex; justify-content: space-between; align-items: start; gap: var(--space-3); }
|
|
716
|
+
.card__name { display: grid; gap: var(--space-1); min-inline-size: 0; }
|
|
717
|
+
.card__id { overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: var(--text-strong); }
|
|
718
|
+
.card__text { display: grid; gap: var(--space-2); }
|
|
719
|
+
.card h1 { font-size: 24px; line-height: 1.25; }
|
|
720
|
+
.card__summary { color: var(--muted); line-height: 1.5; }
|
|
721
|
+
.card__actions { display: grid; gap: var(--space-3); }
|
|
722
|
+
.card__actions:not(:has(*:not([hidden]))) { display: none; }
|
|
723
|
+
.card__actions .button, .card__actions form, .card__start .button { inline-size: 100%; text-align: center; }
|
|
724
|
+
.card__actions .button { display: inline-flex; justify-content: center; align-items: center; text-decoration: none; }
|
|
725
|
+
.card .button--large { padding-inline: var(--space-4); }
|
|
726
|
+
.card .setup-error pre { max-block-size: 8rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
727
|
+
.card select { inline-size: 100%; }
|
|
728
|
+
.icon-link { display: inline-flex; padding: var(--space-2); border-radius: var(--radius-control); color: var(--muted); }
|
|
729
|
+
.icon-link:hover { color: var(--text); background: color-mix(in oklab, var(--accent) 12%, transparent); }
|
|
730
|
+
.icon-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
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></div>
|
|
4
|
-
<a class="button button--subtle" href="/workspaces">All workspaces</a>
|
|
3
|
+
<div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1 class="mono-title"><%= @id %></h1></div>
|
|
4
|
+
<div class="header-actions"><a class="button button--subtle" href="/workspaces/<%= @id %>">Back to workspace</a><a class="button button--subtle" href="/workspaces">All workspaces</a></div>
|
|
5
5
|
</header>
|
|
6
6
|
<section class="status-panel lead" id="lead" data-section="lead" aria-labelledby="status" <%= 'hidden' unless section?('lead') %>>
|
|
7
7
|
<div class="lead__text">
|
|
@@ -108,5 +108,5 @@
|
|
|
108
108
|
</section>
|
|
109
109
|
</div>
|
|
110
110
|
</details>
|
|
111
|
-
<noscript><a href="/workspaces/<%= @id
|
|
111
|
+
<noscript><a href="/workspaces/<%= @id %>/settings">Refresh status</a></noscript>
|
|
112
112
|
</main>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'erb'
|
|
2
2
|
require 'json'
|
|
3
|
+
require 'time'
|
|
3
4
|
require_relative 'config'
|
|
4
5
|
require_relative 'presentation'
|
|
5
6
|
require_relative 'duration_format'
|
|
@@ -13,22 +14,42 @@ module Workspaces
|
|
|
13
14
|
def self.index(workspaces)
|
|
14
15
|
view = new({})
|
|
15
16
|
view.document('Workspaces', <<~HTML)
|
|
16
|
-
<main class="dashboard"><header class="dashboard__header"><h1>Workspaces</h1></header>
|
|
17
|
-
<section class="status-panel"><h2>Create workspace</h2>
|
|
18
|
-
<p>
|
|
17
|
+
<main class="dashboard"><header class="dashboard__header"><h1>Workspaces</h1><p class="dashboard__note">#{view.inventory_summary(workspaces)}</p></header>
|
|
18
|
+
<section class="status-panel" aria-labelledby="create-heading"><h2 id="create-heading">Create workspace</h2>
|
|
19
|
+
<p>Pick an existing branch, a PR number, or a new branch. Each submission creates its own managed worktree.</p>
|
|
19
20
|
<form method="post" action="/workspaces/create" class="stack">
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
<div class="create-fields">
|
|
22
|
+
<div class="field"><label for="create-branch">Existing branch</label><input id="create-branch" name="branch" placeholder="main" autocomplete="off" spellcheck="false"></div>
|
|
23
|
+
<div class="field"><label for="create-pr">PR number</label><input id="create-pr" name="pr" inputmode="numeric" placeholder="43828" autocomplete="off"></div>
|
|
24
|
+
<div class="field"><label for="create-new-branch">New branch</label><input id="create-new-branch" name="new_branch" placeholder="agent/fix-checkout" autocomplete="off" spellcheck="false"></div>
|
|
25
|
+
<div class="field"><label for="create-from">Start new branch from</label><input id="create-from" name="from" placeholder="HEAD" autocomplete="off" spellcheck="false"></div>
|
|
26
|
+
</div>
|
|
27
|
+
<button type="submit" class="button button--primary">Create workspace</button>
|
|
25
28
|
</form></section>
|
|
26
|
-
<section class="status-panel"><h2>Managed workspaces</h2>
|
|
29
|
+
<section class="status-panel" aria-labelledby="inventory-heading"><h2 id="inventory-heading">Managed workspaces</h2>
|
|
27
30
|
#{view.inventory_table(workspaces)}
|
|
28
31
|
</section></main>
|
|
29
32
|
HTML
|
|
30
33
|
end
|
|
31
34
|
|
|
35
|
+
# The default page for a workspace: one small card with the headline, progress and the single
|
|
36
|
+
# next action. Everything else lives on the settings page.
|
|
37
|
+
def self.card(snapshot, preview: false)
|
|
38
|
+
new(snapshot, preview: preview).render_card
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
PREVIEW_STATUS_KEYS = %w(state busy ready headline summary progress lead_action current_preset sleep open).freeze
|
|
42
|
+
PREVIEW_ERROR = 'Setup failed. Open settings for the full log.'.freeze
|
|
43
|
+
|
|
44
|
+
# What the card polls for on a preview host, where the workspace's own pages share the origin.
|
|
45
|
+
# Only what the card draws: no log, no steps, no environment and no error text.
|
|
46
|
+
def self.preview_status(snapshot)
|
|
47
|
+
view = Presentation.new(snapshot).to_h
|
|
48
|
+
slim = view.slice(*PREVIEW_STATUS_KEYS).merge('error' => view['error'] ? PREVIEW_ERROR : '')
|
|
49
|
+
{ 'presentation' => slim, 'prepare_started_at' => snapshot['prepare_started_at'],
|
|
50
|
+
'completed_at' => snapshot['completed_at'] }
|
|
51
|
+
end
|
|
52
|
+
|
|
32
53
|
def self.pull_request(number, workspaces)
|
|
33
54
|
new({}).render_pull_request(number, workspaces)
|
|
34
55
|
end
|
|
@@ -51,8 +72,9 @@ module Workspaces
|
|
|
51
72
|
SVG
|
|
52
73
|
end
|
|
53
74
|
|
|
54
|
-
def initialize(snapshot)
|
|
75
|
+
def initialize(snapshot, preview: false)
|
|
55
76
|
@snapshot = snapshot
|
|
77
|
+
@preview = preview
|
|
56
78
|
@presentation = Presentation.new(snapshot).to_h
|
|
57
79
|
end
|
|
58
80
|
|
|
@@ -63,6 +85,17 @@ module Workspaces
|
|
|
63
85
|
encoding: 'UTF-8')).result(binding))
|
|
64
86
|
end
|
|
65
87
|
|
|
88
|
+
# On a preview host the card is served from the workspace's own origin, so its controls go to the
|
|
89
|
+
# front door's reserved /__workspaces/ endpoints, it never shows error text, and it reloads the
|
|
90
|
+
# page the visitor asked for once the workspace is ready.
|
|
91
|
+
def render_card
|
|
92
|
+
@id = Config.validate_id!(@snapshot.fetch('workspace_id'))
|
|
93
|
+
@presentation = @presentation.merge('error' => PREVIEW_ERROR) if @preview && @presentation['error']
|
|
94
|
+
document("Workspace #{@id}",
|
|
95
|
+
ERB.new(File.read(File.join(__dir__, 'dashboard/card.html.erb'),
|
|
96
|
+
encoding: 'UTF-8')).result(binding))
|
|
97
|
+
end
|
|
98
|
+
|
|
66
99
|
def render_pull_request(number, workspaces)
|
|
67
100
|
@number = number
|
|
68
101
|
@workspaces = workspaces
|
|
@@ -98,25 +131,56 @@ module Workspaces
|
|
|
98
131
|
return '<p class="muted">No workspaces yet.</p>' if workspaces.empty?
|
|
99
132
|
|
|
100
133
|
<<~HTML
|
|
134
|
+
<div class="table-scroll">
|
|
101
135
|
<table class="workspace-table">
|
|
102
|
-
<thead><tr><th scope="col">Workspace</th><th scope="col">Status</th><th scope="col" class="workspace-table__date">Created</th><th scope="col" class="workspace-table__date">Last startup</th></tr></thead>
|
|
136
|
+
<thead><tr><th scope="col">Workspace</th><th scope="col">Status</th><th scope="col" class="workspace-table__date">Created (UTC)</th><th scope="col" class="workspace-table__date">Last startup (UTC)</th></tr></thead>
|
|
103
137
|
<tbody>
|
|
104
138
|
#{workspaces.map { |workspace| inventory_row(workspace) }.join}
|
|
105
139
|
</tbody>
|
|
106
140
|
</table>
|
|
141
|
+
</div>
|
|
107
142
|
HTML
|
|
108
143
|
end
|
|
109
144
|
|
|
145
|
+
# "8 managed worktrees · none running", counted from the data on the page.
|
|
146
|
+
def inventory_summary(workspaces)
|
|
147
|
+
total = workspaces.length
|
|
148
|
+
running = workspaces.count { |workspace| workspace['running'] }
|
|
149
|
+
noun = total == 1 ? 'managed worktree' : 'managed worktrees'
|
|
150
|
+
"#{total} #{noun} · #{running.zero? ? 'none running' : "#{running} running"}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# "Oct 27, 10:48" in UTC, with the full value kept in the title. Anything that is not a timestamp is
|
|
154
|
+
# shown as it came rather than guessed at.
|
|
155
|
+
def short_time(value)
|
|
156
|
+
Time.iso8601(value.to_s).utc.strftime('%b %-d, %H:%M')
|
|
157
|
+
rescue ArgumentError
|
|
158
|
+
value.to_s
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def time_cell(value, empty)
|
|
162
|
+
return "<span class=\"never\">#{empty}</span>" if value.nil? || value.to_s.empty?
|
|
163
|
+
|
|
164
|
+
"<time datetime=\"#{escape(value)}\" title=\"#{escape(value)}\">#{escape(short_time(value))}</time>"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The word shown in a status pill. What the workspace is doing is the word; whether a server is
|
|
168
|
+
# up is the dot's job elsewhere, so there is no repeated "Not running ·" prefix.
|
|
169
|
+
def status_text(workspace)
|
|
170
|
+
status = (workspace['status'] || 'idle').to_s
|
|
171
|
+
status = status.capitalize
|
|
172
|
+
workspace['active'] ? "#{status} (busy)" : status
|
|
173
|
+
end
|
|
174
|
+
|
|
110
175
|
def inventory_row(workspace)
|
|
111
176
|
id = escape(Config.validate_id!(workspace.fetch('id')))
|
|
112
177
|
status = escape(workspace['status'] || 'idle')
|
|
113
|
-
running = workspace['running'] ? 'Running' : 'Not running'
|
|
114
178
|
<<~HTML
|
|
115
179
|
<tr>
|
|
116
180
|
<th scope="row"><a href="/workspaces/#{id}">#{id}</a></th>
|
|
117
|
-
<td><span class="status-pill" data-status="#{status}"><span class="status-dot" aria-hidden="true"></span>#{
|
|
118
|
-
<td class="workspace-table__date"
|
|
119
|
-
<td class="workspace-table__date"
|
|
181
|
+
<td><span class="status-pill" data-status="#{status}" data-busy="#{workspace['active'] ? 'true' : 'false'}"><span class="status-dot" aria-hidden="true"></span>#{escape(status_text(workspace))}</span></td>
|
|
182
|
+
<td class="workspace-table__date">#{time_cell(workspace['created_at'], 'Unavailable')}</td>
|
|
183
|
+
<td class="workspace-table__date">#{time_cell(workspace['started_at'], 'Never')}</td>
|
|
120
184
|
</tr>
|
|
121
185
|
HTML
|
|
122
186
|
end
|
|
@@ -225,10 +289,26 @@ module Workspaces
|
|
|
225
289
|
def lead_action_form(action)
|
|
226
290
|
return '' unless action && action['kind'] == 'post'
|
|
227
291
|
|
|
228
|
-
"<form method=\"post\" action=\"
|
|
292
|
+
"<form method=\"post\" action=\"#{action_base}/#{action.fetch('key')}\">" \
|
|
229
293
|
"<button class=\"button button--primary button--large\">#{escape(action.fetch('label'))}</button></form>"
|
|
230
294
|
end
|
|
231
295
|
|
|
296
|
+
def action_base
|
|
297
|
+
@preview ? '/__workspaces' : "/workspaces/#{@id}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def settings_href
|
|
301
|
+
@preview ? Config.settings_url(@id) : "/workspaces/#{@id}/settings"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Where the card's start form posts. A shared card with presets saves the chosen one through the
|
|
305
|
+
# environment form; a preview card starts through its own origin.
|
|
306
|
+
def start_form_action
|
|
307
|
+
return "#{action_base}/start" if @preview || environment_presets.empty?
|
|
308
|
+
|
|
309
|
+
"/workspaces/#{@id}/environment"
|
|
310
|
+
end
|
|
311
|
+
|
|
232
312
|
def action_form(action)
|
|
233
313
|
style = action['primary'] ? 'button--primary' : 'button--subtle'
|
|
234
314
|
"<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
|
|
@@ -12,13 +12,19 @@ require_relative 'sleep_policy'
|
|
|
12
12
|
module Workspaces
|
|
13
13
|
class FrontDoor
|
|
14
14
|
WORKSPACE_ROUTE = %r{\A/workspaces/([a-z0-9]+(?:-[a-z0-9]+)*)
|
|
15
|
-
(?:/(status|start|prepare|restart|resume|stop|remove|update-pr|environment|keep-awake|allow-sleep))?\z}x
|
|
15
|
+
(?:/(status|start|prepare|restart|resume|stop|remove|update-pr|environment|keep-awake|allow-sleep|settings))?\z}x
|
|
16
16
|
|
|
17
17
|
# Rack env keys set on requests that reach a ready workspace backend, for use by
|
|
18
18
|
# middleware registered with Workspaces.configure_proxy.
|
|
19
19
|
WORKSPACE_ID_ENV = 'workspaces.workspace_id'.freeze
|
|
20
20
|
WORKSPACE_ENV = 'workspaces.workspace'.freeze
|
|
21
21
|
|
|
22
|
+
# Paths under this prefix on a preview host belong to the front door, never to the workspace's app.
|
|
23
|
+
# They serve the waiting card's status and its few lifecycle posts (see DashboardView.card).
|
|
24
|
+
RESERVED_PREFIX = '/__workspaces/'.freeze
|
|
25
|
+
RESERVED_ROUTE = %r{\A/__workspaces/(status|start|prepare|restart|resume)\z}
|
|
26
|
+
private_constant :RESERVED_PREFIX, :RESERVED_ROUTE
|
|
27
|
+
|
|
22
28
|
WAKE_POLL_SECONDS = 0.25
|
|
23
29
|
RETRY_AFTER_SECONDS = 5
|
|
24
30
|
private_constant :WAKE_POLL_SECONDS, :RETRY_AFTER_SECONDS
|
|
@@ -99,7 +105,7 @@ module Workspaces
|
|
|
99
105
|
|
|
100
106
|
def action_request(request, id, action)
|
|
101
107
|
return update_environment(request, id) if action == 'environment'
|
|
102
|
-
return status_response(request, id, action) if action.nil? || action
|
|
108
|
+
return status_response(request, id, action) if action.nil? || %w(status settings).include?(action)
|
|
103
109
|
return response(405, 'Use POST') unless request.post?
|
|
104
110
|
return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
|
|
105
111
|
return remove(request, id) if action == 'remove'
|
|
@@ -116,8 +122,10 @@ module Workspaces
|
|
|
116
122
|
return response(400, 'Unknown workspace log') unless %w(setup backend).include?(source)
|
|
117
123
|
|
|
118
124
|
snapshot = @coordinator.snapshot(id, log: source)
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
return response(200, JSON.generate(snapshot), type: 'application/json', head: request.head?) if action == 'status'
|
|
126
|
+
|
|
127
|
+
page = action == 'settings' ? DashboardView.render(snapshot) : DashboardView.card(snapshot)
|
|
128
|
+
response(200, page, type: 'text/html', head: request.head?)
|
|
121
129
|
end
|
|
122
130
|
|
|
123
131
|
# Retries from the first unfinished step by default; `from=<step>` re-runs that step and every
|
|
@@ -148,7 +156,9 @@ module Workspaces
|
|
|
148
156
|
return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
|
|
149
157
|
|
|
150
158
|
params = environment_params(request)
|
|
151
|
-
updated = if params.key?('
|
|
159
|
+
updated = if params.key?('preset')
|
|
160
|
+
start_with_preset(id, params['preset'])
|
|
161
|
+
elsif params.key?('env')
|
|
152
162
|
@coordinator.replace_environment_rows(id, rows: parse_environment_rows(params))
|
|
153
163
|
elsif params.key?('editable_env')
|
|
154
164
|
@coordinator.replace_editable_environment(id, text: parse_editable_environment(params))
|
|
@@ -164,6 +174,14 @@ module Workspaces
|
|
|
164
174
|
response(422, e.message)
|
|
165
175
|
end
|
|
166
176
|
|
|
177
|
+
# The card's start form for a new workspace: one preset (or the defaults, when left blank).
|
|
178
|
+
def start_with_preset(id, preset)
|
|
179
|
+
raise EnvironmentOverrides::Invalid, 'Choose a preset by name' unless preset.is_a?(String)
|
|
180
|
+
return @coordinator.apply_environment_preset(id, name: preset) unless preset.empty?
|
|
181
|
+
|
|
182
|
+
@coordinator.start(id)
|
|
183
|
+
end
|
|
184
|
+
|
|
167
185
|
def remove(request, id)
|
|
168
186
|
unless request.media_type == 'application/x-www-form-urlencoded'
|
|
169
187
|
return response(415, 'Deletion requires a form-encoded confirmation')
|
|
@@ -182,11 +200,12 @@ module Workspaces
|
|
|
182
200
|
response(409, 'Workspace was not removed. Check the server logs and retry when safe.')
|
|
183
201
|
end
|
|
184
202
|
|
|
203
|
+
# Update from PR is a settings-page control and reports its outcome there.
|
|
185
204
|
def update_pr(id)
|
|
186
205
|
@registry.update_pr(id)
|
|
187
|
-
redirect(Config.
|
|
206
|
+
redirect(Config.settings_url(id))
|
|
188
207
|
rescue Worktree::UpdateError
|
|
189
|
-
redirect(Config.
|
|
208
|
+
redirect(Config.settings_url(id))
|
|
190
209
|
end
|
|
191
210
|
|
|
192
211
|
def create(request)
|
|
@@ -278,6 +297,7 @@ module Workspaces
|
|
|
278
297
|
|
|
279
298
|
def proxy(id, request)
|
|
280
299
|
return response(404, 'Unknown managed workspace') unless Worktree.new(id).exists?
|
|
300
|
+
return reserved(id, request) if request.path_info.start_with?(RESERVED_PREFIX)
|
|
281
301
|
|
|
282
302
|
counted = !@sleep_policy.ignored?(request.path_info)
|
|
283
303
|
lease = acquire_lease(id, counted)
|
|
@@ -298,6 +318,7 @@ module Workspaces
|
|
|
298
318
|
phase = @coordinator.sleep_phase(id)
|
|
299
319
|
if phase
|
|
300
320
|
return sleeping_unavailable(id) unless counted
|
|
321
|
+
return waiting_card(id, phase) if navigation?(request)
|
|
301
322
|
|
|
302
323
|
outcome = await_wake(id, phase)
|
|
303
324
|
return wake_failed(id) if outcome == :failed
|
|
@@ -382,11 +403,85 @@ module Workspaces
|
|
|
382
403
|
end
|
|
383
404
|
|
|
384
405
|
def unavailable(id, request)
|
|
406
|
+
return card_response(@coordinator.snapshot(id, include_log: false)) if navigation?(request)
|
|
385
407
|
return redirect(Config.dashboard_url(id)) if safe?(request)
|
|
386
408
|
|
|
387
409
|
response(503, "Workspace not ready. Visit #{Config.dashboard_url(id)}")
|
|
388
410
|
end
|
|
389
411
|
|
|
412
|
+
# A person opening a page in a browser, as opposed to the app's own fetches, assets and API calls.
|
|
413
|
+
# Only they are shown the card; everything else keeps the plain behaviour.
|
|
414
|
+
def navigation?(request)
|
|
415
|
+
return false unless request.get?
|
|
416
|
+
return false unless request.get_header('HTTP_ACCEPT').to_s.include?('text/html')
|
|
417
|
+
return false if request.get_header('HTTP_X_REQUESTED_WITH')
|
|
418
|
+
|
|
419
|
+
mode = request.get_header('HTTP_SEC_FETCH_MODE')
|
|
420
|
+
mode.nil? || mode == 'navigate'
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# A sleeping workspace is woken at once and the visitor sees the card, not a held request, so the
|
|
424
|
+
# address they asked for stays in the bar and the card reloads it when the workspace is ready.
|
|
425
|
+
def waiting_card(id, phase)
|
|
426
|
+
@coordinator.wake(id) if phase == :sleeping
|
|
427
|
+
card_response(@coordinator.snapshot(id, include_log: false))
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def card_response(snapshot)
|
|
431
|
+
response(200, DashboardView.card(snapshot, preview: true), type: 'text/html')
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# The waiting card's own endpoints. Not counted as activity (polling must not keep a workspace awake),
|
|
435
|
+
# never forwarded, and limited to what the card does: report status and start, retry or continue.
|
|
436
|
+
def reserved(id, request)
|
|
437
|
+
match = RESERVED_ROUTE.match(request.path_info)
|
|
438
|
+
return response(404, 'Not found') unless match
|
|
439
|
+
|
|
440
|
+
action = match[1]
|
|
441
|
+
return preview_status(id, request) if action == 'status'
|
|
442
|
+
return response(405, 'Use POST') unless request.post?
|
|
443
|
+
return response(403, 'Actions require this workspace origin') unless preview_same_origin?(request, id)
|
|
444
|
+
|
|
445
|
+
preview_action(id, action, request)
|
|
446
|
+
redirect(preview_return_url(request, id))
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def preview_status(id, request)
|
|
450
|
+
return response(405, 'Use GET or HEAD') unless safe?(request)
|
|
451
|
+
|
|
452
|
+
body = JSON.generate(DashboardView.preview_status(@coordinator.snapshot(id, include_log: false)))
|
|
453
|
+
response(200, body, type: 'application/json', head: request.head?)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def preview_action(id, action, request)
|
|
457
|
+
preset = request.POST['preset'] if action == 'start' && request.media_type == 'application/x-www-form-urlencoded'
|
|
458
|
+
return start_with_preset(id, preset) if preset.is_a?(String)
|
|
459
|
+
|
|
460
|
+
perform_action(id, action, request)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def preview_same_origin?(request, id)
|
|
464
|
+
expected = Config.preview_url(id).chomp('/')
|
|
465
|
+
origin = request.get_header('HTTP_ORIGIN')
|
|
466
|
+
return origin == expected if origin
|
|
467
|
+
|
|
468
|
+
uri = URI.parse(request.referer.to_s)
|
|
469
|
+
uri.is_a?(URI::HTTP) && uri.userinfo.nil? && "#{uri.scheme}://#{uri.authority}" == expected
|
|
470
|
+
rescue URI::InvalidURIError
|
|
471
|
+
false
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Back to the page the visitor was on, so a start or retry does not lose their place.
|
|
475
|
+
def preview_return_url(request, id)
|
|
476
|
+
base = Config.preview_url(id).chomp('/')
|
|
477
|
+
uri = URI.parse(request.referer.to_s)
|
|
478
|
+
path = uri.request_uri if uri.is_a?(URI::HTTP) && "#{uri.scheme}://#{uri.authority}" == base
|
|
479
|
+
path = '/' if path.nil? || path.start_with?(RESERVED_PREFIX)
|
|
480
|
+
"#{base}#{path}"
|
|
481
|
+
rescue URI::InvalidURIError
|
|
482
|
+
"#{base}/"
|
|
483
|
+
end
|
|
484
|
+
|
|
390
485
|
# Innermost app of the proxy stack: sends the request to the workspace backend.
|
|
391
486
|
def forward_to_backend(env)
|
|
392
487
|
forward(env.fetch(WORKSPACE_ID_ENV), Rack::Request.new(env))
|
|
@@ -46,7 +46,7 @@ module Workspaces
|
|
|
46
46
|
'busy' => {
|
|
47
47
|
sections: %w(lead technical log),
|
|
48
48
|
headline: 'Setting things up…',
|
|
49
|
-
summary:
|
|
49
|
+
summary: 'This page keeps itself up to date. Open the preview once it is ready.',
|
|
50
50
|
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
51
51
|
open_environment: false, open_technical: true
|
|
52
52
|
},
|
data/lib/workspaces/version.rb
CHANGED
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.19
|
|
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-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -79,6 +79,7 @@ files:
|
|
|
79
79
|
- lib/workspaces/config.rb
|
|
80
80
|
- lib/workspaces/coordinator.rb
|
|
81
81
|
- lib/workspaces/dashboard/app.js
|
|
82
|
+
- lib/workspaces/dashboard/card.html.erb
|
|
82
83
|
- lib/workspaces/dashboard/checkout_authentication_error.html.erb
|
|
83
84
|
- lib/workspaces/dashboard/environment.js
|
|
84
85
|
- lib/workspaces/dashboard/pull_request.html.erb
|