workspaces 0.1.8 → 0.1.10
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/README.md +59 -0
- data/lib/workspaces/cli.rb +4 -1
- data/lib/workspaces/dashboard/pull_request.html.erb +4 -2
- data/lib/workspaces/dashboard/styles.css +98 -36
- data/lib/workspaces/dashboard/template.html.erb +7 -5
- data/lib/workspaces/dashboard_view.rb +32 -16
- data/lib/workspaces/front_door.rb +15 -1
- data/lib/workspaces/proxy_middleware.rb +77 -0
- 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: 1974af7e7acc6d19fe556efa4af5e2f9e05635d8df564aa6b3e7e7f8a9096370
|
|
4
|
+
data.tar.gz: d819cb05a41259c8f9b0c7e49d42155f7b072daa88bd839b44351ddec7445b51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ad1115622e9f3c85fb12c63ba8268161054cca32675a081b6d43b5a11b1b5f1a55c0491a143631277c12aa2d019e10eb228acfcdb26fecce719a45f0336c8f3f
|
|
7
|
+
data.tar.gz: 236ca0df90dc5e0403fca4d494bcbeabf90117aabbd12c12175d3eecf4d2475fc36e432430e3ece7c61c651188c234daeef041ed148812d80ef71e983fda5afe
|
data/README.md
CHANGED
|
@@ -143,6 +143,64 @@ The front door is not an authentication boundary. Its host and same-origin check
|
|
|
143
143
|
protect routing and POST actions, but a publicly reachable instance still needs
|
|
144
144
|
access control such as VPN or SSO. Do not expose workspace backend ports directly.
|
|
145
145
|
|
|
146
|
+
### Proxy middleware
|
|
147
|
+
|
|
148
|
+
The front door can run Rack middleware around traffic that is forwarded to a workspace
|
|
149
|
+
backend. The gem knows nothing about what a middleware does: the container or host
|
|
150
|
+
that runs `serve` registers its own. Typical uses are injecting a script tag, adding a
|
|
151
|
+
header, or logging.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
# my_middleware.rb
|
|
155
|
+
Workspaces.configure_proxy do
|
|
156
|
+
use MyMiddleware, option: 'value'
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Load it with `serve --require FILE` (repeatable) or by setting `WORKSPACES_REQUIRE` to
|
|
161
|
+
one or more paths separated like `PATH` entries. Environment entries load first, then
|
|
162
|
+
`--require` files in order. A missing file or an error while loading stops `serve` with
|
|
163
|
+
a non-zero exit instead of silently running without it. These files are executed with
|
|
164
|
+
the front door's privileges, so only name files the operator controls. With nothing
|
|
165
|
+
registered, the front door behaves exactly as before.
|
|
166
|
+
|
|
167
|
+
Blocks run in registration order and the first `use` is outermost. The stack wraps only
|
|
168
|
+
the step that forwards a request to a ready workspace backend. The dashboard, redirects
|
|
169
|
+
for workspaces that are not ready, and error pages never pass through it. For each
|
|
170
|
+
proxied request, two Rack env keys are available:
|
|
171
|
+
|
|
172
|
+
| Key | Value |
|
|
173
|
+
| --- | --- |
|
|
174
|
+
| `workspaces.workspace_id` | The workspace ID, such as `main-1ae4815f` |
|
|
175
|
+
| `workspaces.workspace` | The workspace snapshot hash (branch, status, related PR, and so on) |
|
|
176
|
+
|
|
177
|
+
Two details matter when rewriting response bodies. The proxy passes the browser's
|
|
178
|
+
`Accept-Encoding` to the backend, so a compressed response arrives compressed; set
|
|
179
|
+
`env['HTTP_ACCEPT_ENCODING'] = 'identity'` before calling the app, or skip responses
|
|
180
|
+
with a `Content-Encoding` header. If you change the body, drop the stale `Content-Length`
|
|
181
|
+
header. An example that appends a script tag to HTML pages:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class ScriptInjector
|
|
185
|
+
TAG = '<script src="https://example.test/widget.js" data-thread-id="%<id>s"></script>'
|
|
186
|
+
|
|
187
|
+
def initialize(app)
|
|
188
|
+
@app = app
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def call(env)
|
|
192
|
+
env['HTTP_ACCEPT_ENCODING'] = 'identity'
|
|
193
|
+
status, headers, body = @app.call(env)
|
|
194
|
+
return [status, headers, body] unless headers['content-type'].to_s.start_with?('text/html')
|
|
195
|
+
|
|
196
|
+
html = body.each.to_a.join.sub('</body>', format(TAG, id: env['workspaces.workspace_id']) + '</body>')
|
|
197
|
+
[status, headers.reject { |key, _| key.casecmp?('content-length') }, [html]]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
Workspaces.configure_proxy { use ScriptInjector }
|
|
202
|
+
```
|
|
203
|
+
|
|
146
204
|
### Local HTTPS
|
|
147
205
|
|
|
148
206
|
To serve TLS directly, install/trust a local CA and create a certificate covering
|
|
@@ -198,6 +256,7 @@ and forwarded scheme/host headers; it does not change the internal listener to T
|
|
|
198
256
|
| `WORKSPACES_PUBLIC_ORIGIN` | `http://localhost:4747` | Browser-facing dashboard origin and scheme |
|
|
199
257
|
| `WORKSPACES_BASE_DOMAIN` | `localhost` | Preview hostname suffix |
|
|
200
258
|
| `WORKSPACES_SUBDOMAIN_PREFIX` | `ws-` | Prefix before the workspace ID on preview hosts |
|
|
259
|
+
| `WORKSPACES_REQUIRE` | none | Ruby files, separated like `PATH`, loaded by `serve` to register proxy middleware |
|
|
201
260
|
|
|
202
261
|
Keep the project root, public origin, preview domain, and storage settings consistent
|
|
203
262
|
across CLI and server processes. Changing the browser-facing origin requires restarting
|
data/lib/workspaces/cli.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'rack/handler/puma'
|
|
|
5
5
|
require_relative 'registry'
|
|
6
6
|
require_relative 'coordinator'
|
|
7
7
|
require_relative 'front_door'
|
|
8
|
+
require_relative 'proxy_middleware'
|
|
8
9
|
require_relative 'tls'
|
|
9
10
|
require_relative 'child_env'
|
|
10
11
|
|
|
@@ -86,6 +87,7 @@ module Workspaces
|
|
|
86
87
|
end
|
|
87
88
|
|
|
88
89
|
def parse_tls_options(parser, options)
|
|
90
|
+
parser.on('--require PATH') { |value| (options[:require] ||= []) << value }
|
|
89
91
|
parser.on('--secure') { options[:secure] = true }
|
|
90
92
|
parser.on('--tls-cert PATH') { |value| options[:tls_cert] = value }
|
|
91
93
|
parser.on('--tls-key PATH') { |value| options[:tls_key] = value }
|
|
@@ -93,6 +95,7 @@ module Workspaces
|
|
|
93
95
|
|
|
94
96
|
def serve(_id, options)
|
|
95
97
|
tls = Tls.from_serve_options(options)&.validate!
|
|
98
|
+
ProxyMiddleware.load_files(ProxyMiddleware.env_paths + options.fetch(:require, []))
|
|
96
99
|
|
|
97
100
|
if tls
|
|
98
101
|
tls.apply_env_defaults { serve_with_tls(tls) }
|
|
@@ -196,7 +199,7 @@ module Workspaces
|
|
|
196
199
|
end
|
|
197
200
|
|
|
198
201
|
def usage
|
|
199
|
-
'Usage: bin/workspaces serve | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
|
|
202
|
+
'Usage: bin/workspaces serve [--require FILE] | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
|
|
200
203
|
'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | stop ID | ' \
|
|
201
204
|
'exec ID -- COMMAND... | remove ID [--force]'
|
|
202
205
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<main class="dashboard">
|
|
2
2
|
<header class="dashboard__header">
|
|
3
|
-
<div><p class="eyebrow">PR preview</p><h1>PR #<%= escape(@number) %></h1></div>
|
|
3
|
+
<div class="dashboard__title"><p class="eyebrow">PR preview</p><h1>PR #<%= escape(@number) %></h1></div>
|
|
4
4
|
<a href="/workspaces" class="button button--subtle">All workspaces</a>
|
|
5
5
|
</header>
|
|
6
6
|
<% unless @workspaces.empty? %>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<h2 id="existing-heading"><%= @workspaces.one? ? 'Continue in your workspace' : 'Choose a workspace' %></h2>
|
|
9
9
|
<p>Open an existing workspace to view its status, run it, or continue working.</p>
|
|
10
10
|
<% @workspaces.each do |workspace| %>
|
|
11
|
-
<article>
|
|
11
|
+
<article class="stack stack--tight">
|
|
12
12
|
<h3><%= escape(workspace.fetch('id')) %></h3>
|
|
13
13
|
<p><%= escape(workspace['branch']) %></p>
|
|
14
14
|
<a href="/workspaces/<%= escape(workspace.fetch('id')) %>" class="button button--primary" aria-label="Open workspace <%= escape(workspace.fetch('id')) %>">Open workspace</a>
|
|
@@ -24,11 +24,13 @@
|
|
|
24
24
|
<details class="status-panel">
|
|
25
25
|
<summary>Create another workspace</summary>
|
|
26
26
|
<% end %>
|
|
27
|
+
<div class="stack">
|
|
27
28
|
<p>Create a fresh workspace from the PR's current head and start its server. Existing workspaces and edits stay untouched.</p>
|
|
28
29
|
<form method="post" action="/pr/<%= escape(@number) %>">
|
|
29
30
|
<button class="button <%= @workspaces.empty? ? 'button--primary' : 'button--subtle' %>">Create and start workspace</button>
|
|
30
31
|
</form>
|
|
31
32
|
<p class="muted">Creation fetches the PR. Repository setup steps continue on the workspace dashboard.</p>
|
|
33
|
+
</div>
|
|
32
34
|
<% if @workspaces.empty? %>
|
|
33
35
|
</section>
|
|
34
36
|
<% else %>
|
|
@@ -17,27 +17,51 @@
|
|
|
17
17
|
--space-5: 20px;
|
|
18
18
|
--space-6: 24px;
|
|
19
19
|
--space-8: 32px;
|
|
20
|
+
--space-10: 40px;
|
|
21
|
+
--panel-padding: var(--space-6);
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
* {
|
|
23
25
|
box-sizing: border-box;
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/* Vertical rhythm: browsers ship per-element default margins, so spacing depended on
|
|
29
|
+
which tag was used. Reset them once; every gap is declared by a stack rule below
|
|
30
|
+
using the --space-* scale. */
|
|
31
|
+
h1, h2, h3, h4, p, ul, ol, dl, dd, pre, fieldset, figure { margin: 0; }
|
|
32
|
+
ol, ul { padding-inline-start: var(--space-6); }
|
|
33
|
+
legend { padding: 0; }
|
|
34
|
+
|
|
35
|
+
/* A stack separates its direct children by one step of the scale. Four steps only:
|
|
36
|
+
tight 8px label and control, control and its help text
|
|
37
|
+
snug 12px heading and its content, tightly related groups
|
|
38
|
+
block 16px blocks inside a panel (the default)
|
|
39
|
+
loose 24px panels on a page, and panel padding
|
|
40
|
+
Grid gaps skip [hidden] and out-of-flow children, so toggling sections never leaves
|
|
41
|
+
a stray gap behind (margins on siblings would). */
|
|
42
|
+
.stack { display: grid; gap: var(--space-4); align-content: start; }
|
|
43
|
+
.stack--tight { gap: var(--space-2); }
|
|
44
|
+
.stack--snug { gap: var(--space-3); }
|
|
45
|
+
.stack--loose { gap: var(--space-6); }
|
|
46
|
+
|
|
26
47
|
[hidden] { display: none !important; }
|
|
27
48
|
input { max-width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: var(--space-1); }
|
|
28
49
|
input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
29
50
|
textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
30
51
|
summary { cursor: pointer; color: var(--muted); }
|
|
31
52
|
summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
32
|
-
.workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3);
|
|
33
|
-
|
|
53
|
+
.workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
|
54
|
+
/* The server-rendered action forms live in a wrapper; let them flow with the sibling button. */
|
|
55
|
+
#actions { display: contents; }
|
|
56
|
+
.setup-error { --panel-padding: var(--space-4); padding: var(--panel-padding); border-color: var(--danger); box-shadow: none; }
|
|
34
57
|
.setup-error h3 { color: var(--danger); margin-block-start: 0; }
|
|
35
58
|
.step-outcome { margin-inline-start: auto; font-size: 12px; }
|
|
36
59
|
.activity-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: workspace-pulse 1.5s infinite; }
|
|
37
60
|
@keyframes workspace-pulse { 50% { opacity: 0.3; } }
|
|
38
61
|
@media (prefers-reduced-motion: reduce) { .activity-dot { animation: none; } }
|
|
39
62
|
dd, h1, code { overflow-wrap: anywhere; }
|
|
40
|
-
dd { margin-
|
|
63
|
+
dt + dd { margin-block-start: var(--space-1); }
|
|
64
|
+
dd + dt { margin-block-start: var(--space-3); }
|
|
41
65
|
#log { max-height: 400px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
42
66
|
|
|
43
67
|
body {
|
|
@@ -69,7 +93,7 @@ a {
|
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
.eyebrow {
|
|
72
|
-
margin: 0
|
|
96
|
+
margin: 0;
|
|
73
97
|
color: var(--muted);
|
|
74
98
|
font-size: 12px;
|
|
75
99
|
letter-spacing: 0.08em;
|
|
@@ -87,6 +111,11 @@ h1 {
|
|
|
87
111
|
color: var(--muted);
|
|
88
112
|
}
|
|
89
113
|
|
|
114
|
+
.dashboard__title {
|
|
115
|
+
display: grid;
|
|
116
|
+
gap: var(--space-1);
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
.header-actions {
|
|
91
120
|
display: flex;
|
|
92
121
|
gap: var(--space-3);
|
|
@@ -100,13 +129,30 @@ h1 {
|
|
|
100
129
|
background: var(--bg-elevated);
|
|
101
130
|
border: 1px solid var(--border);
|
|
102
131
|
border-radius: var(--radius);
|
|
103
|
-
padding: var(--
|
|
132
|
+
padding: var(--panel-padding);
|
|
104
133
|
box-shadow: var(--shadow);
|
|
105
134
|
}
|
|
106
135
|
|
|
107
|
-
.
|
|
136
|
+
/* Disclosure panels keep their native layout; only the open state adds spacing below. */
|
|
137
|
+
.status-panel:not(details),
|
|
138
|
+
.controls-panel,
|
|
139
|
+
.error-panel,
|
|
140
|
+
.log-panel,
|
|
141
|
+
.noscript-note {
|
|
108
142
|
display: grid;
|
|
109
143
|
gap: var(--space-4);
|
|
144
|
+
align-content: start;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.error-panel,
|
|
148
|
+
.log-panel,
|
|
149
|
+
.danger-zone { gap: var(--space-3); }
|
|
150
|
+
|
|
151
|
+
details.status-panel[open] > summary { margin-block-end: var(--space-4); }
|
|
152
|
+
|
|
153
|
+
.status-row {
|
|
154
|
+
display: grid;
|
|
155
|
+
gap: var(--space-2) var(--space-4);
|
|
110
156
|
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
|
|
111
157
|
}
|
|
112
158
|
|
|
@@ -152,7 +198,7 @@ h1 {
|
|
|
152
198
|
}
|
|
153
199
|
|
|
154
200
|
.stepper {
|
|
155
|
-
margin:
|
|
201
|
+
margin: 0;
|
|
156
202
|
padding: 0;
|
|
157
203
|
list-style: none;
|
|
158
204
|
display: grid;
|
|
@@ -217,7 +263,7 @@ h1 {
|
|
|
217
263
|
margin: 0;
|
|
218
264
|
padding: var(--space-4);
|
|
219
265
|
display: grid;
|
|
220
|
-
gap: var(--space-
|
|
266
|
+
gap: var(--space-4);
|
|
221
267
|
}
|
|
222
268
|
|
|
223
269
|
.env-legend {
|
|
@@ -226,16 +272,9 @@ h1 {
|
|
|
226
272
|
padding-inline: var(--space-2);
|
|
227
273
|
}
|
|
228
274
|
|
|
229
|
-
.env-input-label {
|
|
230
|
-
display: inline-flex;
|
|
231
|
-
align-items: center;
|
|
232
|
-
gap: var(--space-2);
|
|
233
|
-
font-size: 14px;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
275
|
.env-input-label {
|
|
237
276
|
display: block;
|
|
238
|
-
|
|
277
|
+
font-size: 14px;
|
|
239
278
|
}
|
|
240
279
|
|
|
241
280
|
.env-fieldset textarea {
|
|
@@ -265,31 +304,21 @@ fieldset:disabled {
|
|
|
265
304
|
}
|
|
266
305
|
|
|
267
306
|
.controls-panel {
|
|
268
|
-
display: grid;
|
|
269
|
-
gap: var(--space-4);
|
|
270
307
|
align-items: start;
|
|
271
308
|
}
|
|
272
309
|
|
|
273
|
-
.controls-panel h2,
|
|
274
|
-
.controls-panel h3 {
|
|
275
|
-
margin: 0;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
310
|
.workspace-actions-group,
|
|
279
311
|
.workspace-settings {
|
|
280
312
|
display: grid;
|
|
281
313
|
gap: var(--space-3);
|
|
282
314
|
}
|
|
283
315
|
|
|
284
|
-
|
|
316
|
+
/* The divider only separates two groups: skip it when settings is the only one showing. */
|
|
317
|
+
.workspace-actions-group:not([hidden]) + .workspace-settings {
|
|
285
318
|
border-top: 1px solid var(--border);
|
|
286
319
|
padding-top: var(--space-4);
|
|
287
320
|
}
|
|
288
321
|
|
|
289
|
-
.workspace-actions {
|
|
290
|
-
margin-block-start: 0;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
322
|
.workspace-actions .button,
|
|
294
323
|
.workspace-actions form {
|
|
295
324
|
margin: 0;
|
|
@@ -312,6 +341,7 @@ fieldset:disabled {
|
|
|
312
341
|
}
|
|
313
342
|
|
|
314
343
|
.button {
|
|
344
|
+
justify-self: start;
|
|
315
345
|
display: inline-flex;
|
|
316
346
|
justify-content: center;
|
|
317
347
|
align-items: center;
|
|
@@ -361,10 +391,25 @@ fieldset:disabled {
|
|
|
361
391
|
.button:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
|
|
362
392
|
|
|
363
393
|
.danger-zone { border-color: var(--danger); }
|
|
364
|
-
.danger-zone h2 {
|
|
394
|
+
.danger-zone h2 { color: var(--danger); }
|
|
365
395
|
.danger-zone form { display: grid; justify-items: start; gap: var(--space-3); }
|
|
366
|
-
|
|
367
|
-
.workspace-
|
|
396
|
+
|
|
397
|
+
.workspace-table { inline-size: 100%; border-collapse: collapse; }
|
|
398
|
+
.workspace-table th,
|
|
399
|
+
.workspace-table td { padding: var(--space-3) var(--space-4); text-align: start; vertical-align: middle; border-block-end: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
400
|
+
.workspace-table th:first-child,
|
|
401
|
+
.workspace-table td:first-child { padding-inline-start: 0; }
|
|
402
|
+
.workspace-table th:last-child,
|
|
403
|
+
.workspace-table td:last-child { padding-inline-end: 0; }
|
|
404
|
+
.workspace-table thead th { padding-block-start: 0; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; }
|
|
405
|
+
.workspace-table tbody tr:last-child > * { padding-block-end: 0; border-block-end: 0; }
|
|
406
|
+
.workspace-table tbody th { font-weight: 600; }
|
|
407
|
+
.workspace-table__date { color: var(--muted); font-size: 13px; white-space: nowrap; }
|
|
408
|
+
|
|
409
|
+
.field { display: grid; gap: var(--space-2); max-inline-size: 28rem; font-size: 14px; }
|
|
410
|
+
.field input { inline-size: 100%; }
|
|
411
|
+
|
|
412
|
+
ol:not(.stepper) > li + li { margin-block-start: var(--space-2); }
|
|
368
413
|
|
|
369
414
|
.button--success {
|
|
370
415
|
background: color-mix(in oklab, var(--success) 22%, transparent);
|
|
@@ -379,11 +424,12 @@ fieldset:disabled {
|
|
|
379
424
|
|
|
380
425
|
.error-panel h2,
|
|
381
426
|
.log-panel h2 {
|
|
382
|
-
margin: 0
|
|
427
|
+
margin: 0;
|
|
383
428
|
font-size: 1rem;
|
|
384
429
|
}
|
|
385
430
|
|
|
386
431
|
.error-panel pre,
|
|
432
|
+
.status-panel pre,
|
|
387
433
|
.log-panel pre {
|
|
388
434
|
margin: 0;
|
|
389
435
|
padding: var(--space-3);
|
|
@@ -408,14 +454,12 @@ fieldset:disabled {
|
|
|
408
454
|
justify-content: space-between;
|
|
409
455
|
align-items: baseline;
|
|
410
456
|
gap: var(--space-3);
|
|
411
|
-
margin-block-end: var(--space-3);
|
|
412
457
|
}
|
|
413
458
|
|
|
414
459
|
.log-switch {
|
|
415
460
|
display: flex;
|
|
416
461
|
flex-wrap: wrap;
|
|
417
462
|
gap: var(--space-2);
|
|
418
|
-
margin-block-end: var(--space-3);
|
|
419
463
|
}
|
|
420
464
|
|
|
421
465
|
.log-switch a {
|
|
@@ -450,7 +494,9 @@ fieldset:disabled {
|
|
|
450
494
|
display: none !important;
|
|
451
495
|
}
|
|
452
496
|
|
|
453
|
-
.visually-hidden
|
|
497
|
+
.visually-hidden,
|
|
498
|
+
[role='status']:empty,
|
|
499
|
+
[aria-live]:empty {
|
|
454
500
|
position: absolute !important;
|
|
455
501
|
inline-size: 1px;
|
|
456
502
|
block-size: 1px;
|
|
@@ -461,7 +507,22 @@ fieldset:disabled {
|
|
|
461
507
|
border: 0;
|
|
462
508
|
}
|
|
463
509
|
|
|
510
|
+
p:empty:not([role]):not([aria-live]) { display: none; }
|
|
511
|
+
|
|
464
512
|
@media (max-width: 640px) {
|
|
513
|
+
/* Stack each row: workspace, then status. Dates are desktop-only detail. */
|
|
514
|
+
.workspace-table,
|
|
515
|
+
.workspace-table tbody { display: block; }
|
|
516
|
+
.workspace-table thead,
|
|
517
|
+
.workspace-table th.workspace-table__date,
|
|
518
|
+
.workspace-table td.workspace-table__date { display: none; }
|
|
519
|
+
.workspace-table tr { display: grid; gap: var(--space-1); padding-block: var(--space-3); border-block-end: 1px solid var(--border); }
|
|
520
|
+
.workspace-table tbody tr:first-child { padding-block-start: 0; }
|
|
521
|
+
.workspace-table tbody tr:last-child { padding-block-end: 0; border-block-end: 0; }
|
|
522
|
+
.workspace-table th,
|
|
523
|
+
.workspace-table td { display: block; padding: 0; border: 0; }
|
|
524
|
+
.workspace-table tbody tr:last-child > * { padding-block-end: 0; }
|
|
525
|
+
|
|
465
526
|
.dashboard {
|
|
466
527
|
inline-size: min(100% - var(--space-4), 960px);
|
|
467
528
|
padding-block: var(--space-6);
|
|
@@ -472,7 +533,8 @@ fieldset:disabled {
|
|
|
472
533
|
.error-panel,
|
|
473
534
|
.log-panel,
|
|
474
535
|
.noscript-note {
|
|
475
|
-
padding: var(--space-4);
|
|
536
|
+
--panel-padding: var(--space-4);
|
|
537
|
+
padding: var(--panel-padding);
|
|
476
538
|
}
|
|
477
539
|
|
|
478
540
|
.log-header {
|
|
@@ -1,6 +1,6 @@
|
|
|
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'] %>">
|
|
2
2
|
<header class="dashboard__header">
|
|
3
|
-
<div><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p><%= field('branch') %></p></div>
|
|
3
|
+
<div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p class="muted"><%= field('branch') %></p></div>
|
|
4
4
|
<a class="button button--subtle" href="/workspaces">All workspaces</a>
|
|
5
5
|
</header>
|
|
6
6
|
<section class="status-panel" aria-labelledby="status">
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
</div>
|
|
33
33
|
<div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
|
|
34
34
|
<h3 id="environment-overrides">Environment</h3>
|
|
35
|
-
<form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
|
|
35
|
+
<form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
|
|
36
36
|
<fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
|
|
37
37
|
<legend class="env-legend">Workspace settings</legend>
|
|
38
38
|
<% unless environment_presets.empty? %>
|
|
39
|
-
<div data-preset-controls hidden>
|
|
39
|
+
<div class="stack stack--tight" data-preset-controls hidden>
|
|
40
40
|
<p class="env-input-label" id="environment-preset-label">Preset</p>
|
|
41
41
|
<div class="env-preset-buttons" role="group" aria-labelledby="environment-preset-label" aria-describedby="environment-preset-help">
|
|
42
42
|
<% environment_presets.each_key do |name| %>
|
|
@@ -46,8 +46,10 @@
|
|
|
46
46
|
<p class="muted" id="environment-preset-help">Presets decide which app this workspace runs. Select one, then use <strong data-submit-label><%= escape(@presentation['environment_submit']) %></strong> to apply it.</p>
|
|
47
47
|
</div>
|
|
48
48
|
<% end %>
|
|
49
|
-
<
|
|
50
|
-
|
|
49
|
+
<div class="stack stack--tight">
|
|
50
|
+
<label class="env-input-label" for="editable-env">Environment variables</label>
|
|
51
|
+
<textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"><%= escape(editable_environment_text) %></textarea>
|
|
52
|
+
</div>
|
|
51
53
|
</fieldset>
|
|
52
54
|
<div class="env-actions">
|
|
53
55
|
<button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>><%= escape(@presentation['environment_submit']) %></button>
|
|
@@ -15,16 +15,16 @@ module Workspaces
|
|
|
15
15
|
<main class="dashboard"><header class="dashboard__header"><h1>Workspaces</h1></header>
|
|
16
16
|
<section class="status-panel"><h2>Create workspace</h2>
|
|
17
17
|
<p>Choose a branch, PR number, or new branch. Every submission creates a separate managed worktree.</p>
|
|
18
|
-
<form method="post" action="/workspaces/create">
|
|
19
|
-
<
|
|
20
|
-
<
|
|
21
|
-
<
|
|
22
|
-
<
|
|
18
|
+
<form method="post" action="/workspaces/create" class="stack">
|
|
19
|
+
<label class="field">Existing branch <input name="branch" placeholder="main"></label>
|
|
20
|
+
<label class="field">PR number <input name="pr" inputmode="numeric" placeholder="43828"></label>
|
|
21
|
+
<label class="field">New branch <input name="new_branch" placeholder="agent/fix-checkout"></label>
|
|
22
|
+
<label class="field">Start new branch from <input name="from" placeholder="HEAD"></label>
|
|
23
23
|
<button class="button button--primary">Create workspace</button>
|
|
24
24
|
</form></section>
|
|
25
|
-
<section class="status-panel"><h2>Managed workspaces</h2
|
|
26
|
-
#{
|
|
27
|
-
</
|
|
25
|
+
<section class="status-panel"><h2>Managed workspaces</h2>
|
|
26
|
+
#{view.inventory_table(workspaces)}
|
|
27
|
+
</section></main>
|
|
28
28
|
HTML
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -90,17 +90,33 @@ module Workspaces
|
|
|
90
90
|
ERB::Util.html_escape(value.to_s)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
# Tabular data gets a real table. The workspace ID is both the row header and the
|
|
94
|
+
# link, so no separate name/branch/link columns are needed. Narrow screens hide the
|
|
95
|
+
# date columns and stack each row (see styles.css).
|
|
96
|
+
def inventory_table(workspaces)
|
|
97
|
+
return '<p class="muted">No workspaces yet.</p>' if workspaces.empty?
|
|
98
|
+
|
|
99
|
+
<<~HTML
|
|
100
|
+
<table class="workspace-table">
|
|
101
|
+
<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>
|
|
102
|
+
<tbody>
|
|
103
|
+
#{workspaces.map { |workspace| inventory_row(workspace) }.join}
|
|
104
|
+
</tbody>
|
|
105
|
+
</table>
|
|
106
|
+
HTML
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def inventory_row(workspace)
|
|
94
110
|
id = escape(Config.validate_id!(workspace.fetch('id')))
|
|
111
|
+
status = escape(workspace['status'] || 'idle')
|
|
95
112
|
running = workspace['running'] ? 'Running' : 'Not running'
|
|
96
113
|
<<~HTML
|
|
97
|
-
<
|
|
98
|
-
<a href="/workspaces/#{id}">#{id}</a>
|
|
99
|
-
<span class="
|
|
100
|
-
<
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
</li>
|
|
114
|
+
<tr>
|
|
115
|
+
<th scope="row"><a href="/workspaces/#{id}">#{id}</a></th>
|
|
116
|
+
<td><span class="status-pill" data-status="#{status}"><span class="status-dot" aria-hidden="true"></span>#{running} · #{status}#{workspace['active'] ? ' (busy)' : ''}</span></td>
|
|
117
|
+
<td class="workspace-table__date"><time datetime="#{escape(workspace['created_at'])}">#{escape(workspace['created_at'] || 'Unavailable')}</time></td>
|
|
118
|
+
<td class="workspace-table__date"><time datetime="#{escape(workspace['started_at'])}">#{escape(workspace['started_at'] || 'Never')}</time></td>
|
|
119
|
+
</tr>
|
|
104
120
|
HTML
|
|
105
121
|
end
|
|
106
122
|
|
|
@@ -5,15 +5,22 @@ require 'net/http'
|
|
|
5
5
|
require_relative 'coordinator'
|
|
6
6
|
require_relative 'dashboard_view'
|
|
7
7
|
require_relative 'environment_overrides'
|
|
8
|
+
require_relative 'proxy_middleware'
|
|
8
9
|
|
|
9
10
|
module Workspaces
|
|
10
11
|
class FrontDoor
|
|
11
12
|
WORKSPACE_ROUTE = %r{\A/workspaces/([a-z0-9]+(?:-[a-z0-9]+)*)
|
|
12
13
|
(?:/(status|start|prepare|restart|stop|remove|update-pr|environment))?\z}x
|
|
13
14
|
|
|
15
|
+
# Rack env keys set on requests that reach a ready workspace backend, for use by
|
|
16
|
+
# middleware registered with Workspaces.configure_proxy.
|
|
17
|
+
WORKSPACE_ID_ENV = 'workspaces.workspace_id'.freeze
|
|
18
|
+
WORKSPACE_ENV = 'workspaces.workspace'.freeze
|
|
19
|
+
|
|
14
20
|
def initialize(coordinator: Coordinator.new, registry: Registry.new)
|
|
15
21
|
@coordinator = coordinator
|
|
16
22
|
@registry = registry
|
|
23
|
+
@forwarder = ProxyMiddleware.build(method(:forward_to_backend))
|
|
17
24
|
end
|
|
18
25
|
|
|
19
26
|
def call(env)
|
|
@@ -230,7 +237,9 @@ module Workspaces
|
|
|
230
237
|
snapshot = @coordinator.snapshot(id, include_log: false)
|
|
231
238
|
return unavailable(id, request) unless snapshot['status'] == 'ready' && !snapshot['active']
|
|
232
239
|
|
|
233
|
-
|
|
240
|
+
request.env[WORKSPACE_ID_ENV] = id
|
|
241
|
+
request.env[WORKSPACE_ENV] = snapshot
|
|
242
|
+
@forwarder.call(request.env)
|
|
234
243
|
rescue IOError, SystemCallError, Timeout::Error => e
|
|
235
244
|
response(502, "Workspace backend unavailable: #{e.class}")
|
|
236
245
|
end
|
|
@@ -241,6 +250,11 @@ module Workspaces
|
|
|
241
250
|
response(503, "Workspace not ready. Visit #{Config.dashboard_url(id)}")
|
|
242
251
|
end
|
|
243
252
|
|
|
253
|
+
# Innermost app of the proxy stack: sends the request to the workspace backend.
|
|
254
|
+
def forward_to_backend(env)
|
|
255
|
+
forward(env.fetch(WORKSPACE_ID_ENV), Rack::Request.new(env))
|
|
256
|
+
end
|
|
257
|
+
|
|
244
258
|
def forward(id, request)
|
|
245
259
|
uri = URI("http://127.0.0.1:#{Backend.new(id).port}#{request.fullpath}")
|
|
246
260
|
upstream = upstream_request(id, request, uri)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require 'rack'
|
|
2
|
+
|
|
3
|
+
module Workspaces
|
|
4
|
+
# Adds a Rack middleware stack around workspace preview traffic.
|
|
5
|
+
#
|
|
6
|
+
# The gem stays unaware of what any middleware does: the process hosting the front
|
|
7
|
+
# door registers its own, for example from a file named by WORKSPACES_REQUIRE:
|
|
8
|
+
#
|
|
9
|
+
# Workspaces.configure_proxy do
|
|
10
|
+
# use MyMiddleware, option: 'value'
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Blocks are replayed in registration order into a Rack::Builder, so the first
|
|
14
|
+
# `use` is the outermost middleware. The stack wraps only the step that forwards a
|
|
15
|
+
# request to a ready workspace backend. The dashboard, redirects for workspaces that
|
|
16
|
+
# are not ready, and error responses never pass through it.
|
|
17
|
+
module ProxyMiddleware
|
|
18
|
+
ENV_VAR = 'WORKSPACES_REQUIRE'
|
|
19
|
+
|
|
20
|
+
# Raised for unusable configuration; the CLI reports it and exits non-zero.
|
|
21
|
+
class ConfigurationError < ArgumentError; end
|
|
22
|
+
|
|
23
|
+
@blocks = []
|
|
24
|
+
|
|
25
|
+
def self.register(&block)
|
|
26
|
+
raise ConfigurationError, 'Workspaces.configure_proxy requires a block' unless block
|
|
27
|
+
|
|
28
|
+
@blocks << block
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.any?
|
|
33
|
+
!@blocks.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.reset!
|
|
37
|
+
@blocks.clear
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns the endpoint untouched when nothing is registered, so an unconfigured
|
|
41
|
+
# front door behaves exactly as it did before this seam existed.
|
|
42
|
+
def self.build(endpoint)
|
|
43
|
+
blocks = @blocks.dup
|
|
44
|
+
return endpoint if blocks.empty?
|
|
45
|
+
|
|
46
|
+
Rack::Builder.new do
|
|
47
|
+
blocks.each { |block| instance_exec(&block) }
|
|
48
|
+
run endpoint
|
|
49
|
+
end.to_app
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Paths from WORKSPACES_REQUIRE, separated like PATH entries.
|
|
53
|
+
def self.env_paths
|
|
54
|
+
ENV.fetch(ENV_VAR, '').split(File::PATH_SEPARATOR).map(&:strip).reject(&:empty?)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Executes the given Ruby files so they can register middleware. These files run
|
|
58
|
+
# with the privileges of the front door; only name files the operator controls.
|
|
59
|
+
def self.load_files(paths)
|
|
60
|
+
paths.each do |path|
|
|
61
|
+
full_path = File.expand_path(path)
|
|
62
|
+
raise ConfigurationError, "Proxy middleware file not found: #{path}" unless File.file?(full_path)
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
require full_path
|
|
66
|
+
rescue ScriptError, StandardError => e
|
|
67
|
+
raise ConfigurationError, "Could not load proxy middleware #{path}: #{e.class}: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
paths
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.configure_proxy(&block)
|
|
75
|
+
ProxyMiddleware.register(&block)
|
|
76
|
+
end
|
|
77
|
+
end
|
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.10
|
|
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-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -88,6 +88,7 @@ files:
|
|
|
88
88
|
- lib/workspaces/paths.rb
|
|
89
89
|
- lib/workspaces/port_allocator.rb
|
|
90
90
|
- lib/workspaces/presentation.rb
|
|
91
|
+
- lib/workspaces/proxy_middleware.rb
|
|
91
92
|
- lib/workspaces/recipe.rb
|
|
92
93
|
- lib/workspaces/registry.rb
|
|
93
94
|
- lib/workspaces/state_store.rb
|