workspaces 0.1.9 → 0.1.11
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 +13 -8
- data/README.md +59 -0
- data/lib/workspaces/cli.rb +4 -1
- data/lib/workspaces/dashboard/app.js +60 -16
- data/lib/workspaces/dashboard/styles.css +58 -1
- data/lib/workspaces/dashboard/template.html.erb +55 -45
- data/lib/workspaces/dashboard_view.rb +9 -0
- data/lib/workspaces/front_door.rb +15 -1
- data/lib/workspaces/presentation.rb +81 -15
- 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: fc21704825da052eb9cde1ea64cf3ab4958242f08492cf2a5b538d6fd97ebca3
|
|
4
|
+
data.tar.gz: 46dc25654e97ef6fb461058470dcfeb56e5a82720fa6df875c5b3c34b5d273a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: daa43e3151dd5bb2e4239ae61ca9322101eb8793d437a91782dfa71ec078ce42721d384bea49564c0929a087b9476ab8c76d44860ad806ab3dab7e4145352193
|
|
7
|
+
data.tar.gz: 61d43cac06ed0948041320abf6d0a876697241a26063f5aa3f85537580cea660c8e0c7af2e8589fad80a32c6d3ba8432140e959a9fee70ba791ca92f3c81534a
|
data/DESIGN.md
CHANGED
|
@@ -48,19 +48,24 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
48
48
|
- **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
49
|
- **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
|
|
50
50
|
- **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
|
|
51
|
-
- **
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
- `
|
|
51
|
+
- **Three layers**: the workspace page leads with the friendly, the deliberate, then the technical. Each layer is shown or collapsed by lifecycle state, driven by `Presentation::STATES`:
|
|
52
|
+
1. **Lead** (always visible): a plain-language headline, one sentence, and at most one primary action (a large button). The setup steps never appear here.
|
|
53
|
+
2. **Settings** (a `<details>` titled *Change settings*, or *Choose what to preview* on a brand new workspace): presets first, as buttons, then the environment variables. The one submit button names what it does (Start workspace, Save & restart, Save & start, Save & retry). It starts open only for a new workspace.
|
|
54
|
+
3. **Technical details** (one `<details>`): setup steps with timings, the live log, workspace controls (rebuild, restart, stop, update from PR), preview URL, branch and path, and the Danger Zone. It starts collapsed, and opens by itself while setup runs or after a failure so a cause is never hidden.
|
|
55
|
+
- **Lead by state**:
|
|
56
|
+
- `idle`: "Let's get your workspace running", with the settings open and *Start workspace* in the form.
|
|
57
|
+
- `busy`: "Setting things up…", with the current step as a quiet line and the elapsed time. Technical details open so progress and the log can be watched.
|
|
58
|
+
- `ready`: "Your workspace is ready" with a large **Open workspace** button. Everything else is collapsed.
|
|
59
|
+
- `stopped`: "Your workspace is paused" with a large **Start workspace** button.
|
|
60
|
+
- `error`/`interrupted`: a plain "Something didn't go to plan" with a large **Retry** button, the error shown, and technical details open.
|
|
61
|
+
- `unknown`: a minimal view with technical details open.
|
|
62
|
+
- **Disclosure behavior**: the server renders the initial open state and the polling script re-applies it from `presentation.open`, but only when the state changes. A section the user opened or closed is left alone on later polls. Sections that are not meaningful in a state are hidden, never shown disabled.
|
|
57
63
|
- **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
|
|
58
|
-
- **Actions & settings panel**: status-adjacent panel that combines routine lifecycle controls, Open workspace, Refresh status, PR update messaging, and editable environment settings so routine operations stay in one keyboard-reachable region.
|
|
59
64
|
- **Open workspace**: success-styled link available when the server is ready
|
|
60
65
|
- **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
|
|
61
66
|
- **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
|
|
62
67
|
- **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
|
|
63
|
-
- **Preview destination**: a small muted URL in
|
|
68
|
+
- **Preview destination**: a small muted URL in Technical details, visible before readiness; the large Open workspace button is reserved for ready servers.
|
|
64
69
|
- **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
|
|
65
70
|
- **Environment presets (JS-enhanced)**: optional preset selector is hidden by default and revealed only when JavaScript is active and presets exist. Selecting a preset replaces the entire draft editor content and announces unsaved state without submitting. Draft edits remain local until Save & restart.
|
|
66
71
|
- **Danger Zone**: isolated section on the detail page with explicit loss-of-uncommitted-and-untracked-work copy, an exact-workspace-ID confirmation field, and a destructive action. Deletion remains possible without JavaScript, is disabled while setup is busy, and never appears as a list-row action.
|
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
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
let actionsSignature = null;
|
|
15
15
|
function toggleBusyState(nextBusy) {
|
|
16
16
|
busy = !!nextBusy;
|
|
17
|
-
root.querySelectorAll('.workspace-actions button').forEach((button) => {
|
|
17
|
+
root.querySelectorAll('.workspace-actions button, .lead__action button').forEach((button) => {
|
|
18
18
|
button.disabled = busy;
|
|
19
19
|
});
|
|
20
20
|
if (window.WorkspaceEnvironment)
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
completed = Date.parse(data.completed_at);
|
|
96
96
|
renderStatus(view);
|
|
97
97
|
renderSections(view);
|
|
98
|
-
node('message').textContent = data.message || '';
|
|
98
|
+
if (node('message')) node('message').textContent = data.message || '';
|
|
99
99
|
node('source-update-message').textContent = data.source_update_message || '';
|
|
100
100
|
node('progress').replaceChildren(...view.steps.map(renderStep));
|
|
101
101
|
renderActions(view.actions);
|
|
@@ -109,25 +109,69 @@
|
|
|
109
109
|
root.querySelectorAll('[data-section]').forEach((element) => {
|
|
110
110
|
element.hidden = !sections[element.dataset.section];
|
|
111
111
|
});
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
controls.hidden = !sections.actions && !sections.environment;
|
|
115
|
-
const title = node('actions-settings-title');
|
|
116
|
-
if (title && view.controls_title) title.textContent = view.controls_title;
|
|
117
|
-
}
|
|
118
|
-
const hint = node('hint');
|
|
119
|
-
if (hint) {
|
|
120
|
-
hint.textContent = view.hint || '';
|
|
121
|
-
hint.hidden = !view.hint;
|
|
122
|
-
}
|
|
123
|
-
node('message').hidden = view.state === 'idle';
|
|
112
|
+
renderLead(view);
|
|
113
|
+
applyDisclosureState(view);
|
|
124
114
|
if (window.WorkspaceEnvironment)
|
|
125
115
|
window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
|
|
126
116
|
}
|
|
127
117
|
|
|
118
|
+
function renderLead(view) {
|
|
119
|
+
node('status').textContent = view.headline || view.title;
|
|
120
|
+
node('summary').textContent = view.summary || '';
|
|
121
|
+
const subtitle = node('subtitle');
|
|
122
|
+
const activity = node('activity');
|
|
123
|
+
if (subtitle) {
|
|
124
|
+
const text = view.subtitle || '';
|
|
125
|
+
subtitle.hidden = !text;
|
|
126
|
+
// The text has its own element, so it can be set whether or not the server rendered any.
|
|
127
|
+
const target = node('subtitle-text');
|
|
128
|
+
if (target) target.textContent = text;
|
|
129
|
+
}
|
|
130
|
+
if (activity) activity.hidden = !view.busy;
|
|
131
|
+
const title = node('environment-title');
|
|
132
|
+
if (title && view.environment_title) title.textContent = view.environment_title;
|
|
133
|
+
renderLeadAction(view.lead_action);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The lead action is a lifecycle post (start/retry) or, when ready, the preview link
|
|
137
|
+
// rendered separately as #preview. Rebuild only when it actually changes.
|
|
138
|
+
let leadSignature = null;
|
|
139
|
+
function renderLeadAction(action) {
|
|
140
|
+
const holder = node('lead-action');
|
|
141
|
+
if (!holder) return;
|
|
142
|
+
const signature = JSON.stringify(action || null);
|
|
143
|
+
if (signature === leadSignature) return;
|
|
144
|
+
leadSignature = signature;
|
|
145
|
+
holder.replaceChildren();
|
|
146
|
+
if (!action || action.kind !== 'post' || !allowedActions.includes(action.key)) return;
|
|
147
|
+
const form = document.createElement('form');
|
|
148
|
+
form.method = 'post';
|
|
149
|
+
form.action = base + '/' + action.key;
|
|
150
|
+
const button = document.createElement('button');
|
|
151
|
+
button.className = 'button button--primary button--large';
|
|
152
|
+
button.textContent = action.label;
|
|
153
|
+
form.append(button);
|
|
154
|
+
holder.append(form);
|
|
155
|
+
toggleBusyState(busy);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A disclosure opens by itself when its state says so (a failure must never be hidden,
|
|
159
|
+
// a fresh workspace needs its settings), but we never close one the user opened, and
|
|
160
|
+
// we only apply the state's preference when the state changes, not on every poll.
|
|
161
|
+
let lastDisclosureState = root.dataset.disclosureState || null;
|
|
162
|
+
function applyDisclosureState(view) {
|
|
163
|
+
if (view.state === lastDisclosureState) return;
|
|
164
|
+
lastDisclosureState = view.state;
|
|
165
|
+
const open = view.open || {};
|
|
166
|
+
[['environment-disclosure', open.environment], ['technical-disclosure', open.technical]].forEach(
|
|
167
|
+
([id, shouldOpen]) => {
|
|
168
|
+
const element = node(id);
|
|
169
|
+
if (element) element.open = !!shouldOpen;
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
128
174
|
function renderStatus(view) {
|
|
129
|
-
node('status').textContent = view.title;
|
|
130
|
-
node('activity').hidden = !view.busy;
|
|
131
175
|
node('preview').hidden = !view.ready;
|
|
132
176
|
node('error').textContent = view.error || '';
|
|
133
177
|
node('error-panel').hidden = !view.error;
|
|
@@ -148,7 +148,64 @@ h1 {
|
|
|
148
148
|
.log-panel,
|
|
149
149
|
.danger-zone { gap: var(--space-3); }
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
|
|
152
|
+
/* Lead: a friendly headline, one sentence, and at most one big button. */
|
|
153
|
+
.lead { gap: var(--space-4); padding-block: var(--space-8); }
|
|
154
|
+
.lead__text { display: grid; gap: var(--space-2); }
|
|
155
|
+
.lead h2 { font-size: clamp(1.4rem, 2vw + 1rem, 1.9rem); line-height: 1.2; }
|
|
156
|
+
.lead__summary { max-inline-size: 38rem; color: var(--muted); font-size: 1.05rem; line-height: 1.5; }
|
|
157
|
+
.lead__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
|
158
|
+
.lead__action:empty { display: none; }
|
|
159
|
+
.button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius); }
|
|
160
|
+
|
|
161
|
+
/* Disclosures: quiet by default, a clear label and a one-line hint of what is inside. */
|
|
162
|
+
/* A disclosure has to read as a control. The native marker is lost once a summary is a flex
|
|
163
|
+
container, so draw our own chevron; the whole header is the click target, with a hover
|
|
164
|
+
fill and a keyboard focus ring. */
|
|
165
|
+
/* Two classes so the narrow-screen panel padding below cannot re-add padding around the summary. */
|
|
166
|
+
.status-panel.disclosure { padding: 0; }
|
|
167
|
+
.disclosure > summary {
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-wrap: wrap;
|
|
170
|
+
align-items: baseline;
|
|
171
|
+
gap: var(--space-1) var(--space-3);
|
|
172
|
+
padding: var(--space-4) calc(var(--space-6) + var(--space-8)) var(--space-4) var(--panel-padding);
|
|
173
|
+
position: relative;
|
|
174
|
+
list-style: none;
|
|
175
|
+
color: var(--text);
|
|
176
|
+
font-weight: 600;
|
|
177
|
+
border-radius: var(--radius);
|
|
178
|
+
cursor: pointer;
|
|
179
|
+
user-select: none;
|
|
180
|
+
transition: background-color 120ms ease;
|
|
181
|
+
}
|
|
182
|
+
.disclosure > summary::-webkit-details-marker { display: none; }
|
|
183
|
+
.disclosure > summary::after {
|
|
184
|
+
content: '';
|
|
185
|
+
position: absolute;
|
|
186
|
+
inset-inline-end: var(--panel-padding);
|
|
187
|
+
inset-block-start: 50%;
|
|
188
|
+
inline-size: 8px;
|
|
189
|
+
block-size: 8px;
|
|
190
|
+
margin-block-start: -6px;
|
|
191
|
+
border-inline-end: 2px solid var(--muted);
|
|
192
|
+
border-block-end: 2px solid var(--muted);
|
|
193
|
+
transform: rotate(45deg);
|
|
194
|
+
transition: transform 160ms ease, border-color 120ms ease;
|
|
195
|
+
}
|
|
196
|
+
.disclosure[open] > summary::after { margin-block-start: -2px; transform: rotate(225deg); }
|
|
197
|
+
.disclosure > summary:hover { background: color-mix(in oklab, var(--accent) 8%, transparent); }
|
|
198
|
+
.disclosure > summary:hover::after { border-color: var(--text); }
|
|
199
|
+
.disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
200
|
+
.disclosure[open] > summary { border-end-start-radius: 0; border-end-end-radius: 0; border-block-end: 1px solid var(--border); margin-block-end: 0; }
|
|
201
|
+
.disclosure > :not(summary) { margin: var(--panel-padding); }
|
|
202
|
+
.disclosure__hint { color: var(--muted); font-size: 13px; font-weight: 400; }
|
|
203
|
+
@media (prefers-reduced-motion: reduce) { .disclosure > summary, .disclosure > summary::after { transition: none; } }
|
|
204
|
+
.disclosure .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
|
|
205
|
+
.disclosure .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
|
|
206
|
+
.disclosure .log-panel { padding: 0; }
|
|
207
|
+
.disclosure .danger-zone h3 { color: var(--danger); font-size: 1rem; }
|
|
208
|
+
.disclosure h3 { font-size: 1rem; }
|
|
152
209
|
|
|
153
210
|
.status-row {
|
|
154
211
|
display: grid;
|
|
@@ -1,37 +1,27 @@
|
|
|
1
|
-
<main class="dashboard" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>">
|
|
1
|
+
<main class="dashboard" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>" data-disclosure-state="<%= escape(@presentation['state']) %>">
|
|
2
2
|
<header class="dashboard__header">
|
|
3
|
-
<div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1
|
|
3
|
+
<div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1></div>
|
|
4
4
|
<a class="button button--subtle" href="/workspaces">All workspaces</a>
|
|
5
5
|
</header>
|
|
6
|
-
<section class="status-panel" aria-labelledby="status"
|
|
7
|
-
<div class="
|
|
8
|
-
<h2 id="status" role="status"><%= escape(@presentation['
|
|
9
|
-
<
|
|
6
|
+
<section class="status-panel lead" id="lead" data-section="lead" aria-labelledby="status" <%= 'hidden' unless section?('lead') %>>
|
|
7
|
+
<div class="lead__text">
|
|
8
|
+
<h2 id="status" role="status"><%= escape(@presentation['headline']) %></h2>
|
|
9
|
+
<p id="summary" class="lead__summary"><%= escape(@presentation['summary']) %></p>
|
|
10
|
+
<p id="subtitle" class="muted" <%= 'hidden' unless @presentation['subtitle'] %>><span id="subtitle-text"><%= escape(@presentation['subtitle']) %></span><span id="activity" <%= 'hidden' unless @presentation['busy'] %>> · <span class="activity-dot" aria-hidden="true"></span> <span id="elapsed">—</span></span></p>
|
|
10
11
|
</div>
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<% end %>
|
|
17
|
-
</ol>
|
|
12
|
+
<div class="lead__actions">
|
|
13
|
+
<a id="preview" class="button button--primary button--large" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
|
|
14
|
+
<div id="lead-action" class="lead__action"><%= lead_action_form(@presentation['lead_action']) %></div>
|
|
15
|
+
</div>
|
|
16
|
+
<p id="message" class="muted" hidden><%= field('message') %></p>
|
|
18
17
|
<section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
|
|
19
18
|
<h3>What went wrong</h3><pre id="error"><%= escape(@presentation['error']) %></pre>
|
|
20
19
|
</section>
|
|
21
20
|
<p id="connection" role="status"></p>
|
|
22
21
|
</section>
|
|
23
|
-
<
|
|
24
|
-
<
|
|
25
|
-
<div class="workspace-
|
|
26
|
-
<h3 id="actions-heading">Workspace actions</h3>
|
|
27
|
-
<div class="workspace-actions">
|
|
28
|
-
<a id="preview" class="button button--primary" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
|
|
29
|
-
<div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
|
|
30
|
-
</div>
|
|
31
|
-
<p id="source-update-message" role="status"><%= field('source_update_message') %></p>
|
|
32
|
-
</div>
|
|
33
|
-
<div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
|
|
34
|
-
<h3 id="environment-overrides">Environment</h3>
|
|
22
|
+
<details class="status-panel disclosure" id="environment-disclosure" data-section="environment" <%= 'open' if @presentation.dig('open', 'environment') %> <%= 'hidden' unless section?('environment') %>>
|
|
23
|
+
<summary><span id="environment-title"><%= escape(@presentation['environment_title']) %></span><span class="disclosure__hint">Presets and environment variables</span></summary>
|
|
24
|
+
<div class="workspace-settings" aria-labelledby="environment-title">
|
|
35
25
|
<form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
|
|
36
26
|
<fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
|
|
37
27
|
<legend class="env-legend">Workspace settings</legend>
|
|
@@ -57,25 +47,45 @@
|
|
|
57
47
|
<p class="muted" id="environment-status" role="status" aria-live="polite"></p>
|
|
58
48
|
</form>
|
|
59
49
|
</div>
|
|
60
|
-
</
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
50
|
+
</details>
|
|
51
|
+
<details class="status-panel disclosure" id="technical-disclosure" data-section="technical" <%= 'open' if @presentation.dig('open', 'technical') %> <%= 'hidden' unless section?('technical') %>>
|
|
52
|
+
<summary><span>Technical details</span><span class="disclosure__hint">Setup steps, logs and workspace controls</span></summary>
|
|
53
|
+
<div class="technical stack stack--loose">
|
|
54
|
+
<ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
|
|
55
|
+
<% @presentation['steps'].each do |step| %>
|
|
56
|
+
<li class="step" data-step-state="<%= step['state'] %>" <%= 'aria-current="step"' if step['state'] == 'active' %>><span class="step-bullet" aria-hidden="true"></span><span><%= escape(step['label']) %></span><span class="step-outcome"><%= escape(step_outcome(step)) %></span></li>
|
|
57
|
+
<% end %>
|
|
58
|
+
</ol>
|
|
59
|
+
<div class="workspace-actions-group stack stack--snug" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
|
|
60
|
+
<h3 id="actions-heading">Workspace controls</h3>
|
|
61
|
+
<div class="workspace-actions">
|
|
62
|
+
<div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
|
|
63
|
+
</div>
|
|
64
|
+
<p id="source-update-message" role="status"><%= field('source_update_message') %></p>
|
|
65
|
+
</div>
|
|
66
|
+
<section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
|
|
67
|
+
<div class="log-header"><h3 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h3><time id="updated"><%= field('updated_at') %></time></div>
|
|
68
|
+
<nav class="log-switch" aria-label="Log source">
|
|
69
|
+
<a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
|
|
70
|
+
<a href="?log=backend" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %>>Backend log</a>
|
|
71
|
+
</nav>
|
|
72
|
+
<pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
|
|
73
|
+
</section>
|
|
74
|
+
<section class="stack stack--snug" data-section="details" <%= 'hidden' unless section?('details') %>>
|
|
75
|
+
<h3>Workspace details</h3>
|
|
76
|
+
<dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl>
|
|
77
|
+
</section>
|
|
78
|
+
<section class="danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
|
|
79
|
+
<h3 id="danger-heading">Danger Zone</h3>
|
|
80
|
+
<p>Deleting this workspace stops its server and removes its worktree. <strong>Discard uncommitted and untracked changes</strong> permanently. The branch and committed work are preserved.</p>
|
|
81
|
+
<form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
|
|
82
|
+
<label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
|
|
83
|
+
<input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
|
|
84
|
+
<p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
|
|
85
|
+
<button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
|
|
86
|
+
</form>
|
|
87
|
+
</section>
|
|
88
|
+
</div>
|
|
89
|
+
</details>
|
|
80
90
|
<noscript><a href="/workspaces/<%= @id %>">Refresh status</a></noscript>
|
|
81
91
|
</main>
|
|
@@ -161,6 +161,15 @@ module Workspaces
|
|
|
161
161
|
JSON.generate(environment_presets)
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
+
# The lead's primary action. `open` is rendered separately as a link (it needs the
|
|
165
|
+
# preview URL and must stay a real anchor), so only lifecycle posts produce a form.
|
|
166
|
+
def lead_action_form(action)
|
|
167
|
+
return '' unless action && action['kind'] == 'post'
|
|
168
|
+
|
|
169
|
+
"<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
|
|
170
|
+
"<button class=\"button button--primary button--large\">#{escape(action.fetch('label'))}</button></form>"
|
|
171
|
+
end
|
|
172
|
+
|
|
164
173
|
def action_form(action)
|
|
165
174
|
style = action['primary'] ? 'button--primary' : 'button--subtle'
|
|
166
175
|
"<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
|
|
@@ -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)
|
|
@@ -6,8 +6,13 @@ module Workspaces
|
|
|
6
6
|
ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
|
|
7
7
|
'restart' => 'Restart server', 'stop' => 'Stop server' }.freeze
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
# Three layers, from friendly to technical. `lead` is the headline, one sentence and at
|
|
10
|
+
# most one primary action. `environment` (presets and variables) is the area for
|
|
11
|
+
# deliberately changing something. `technical` is one disclosure holding setup steps,
|
|
12
|
+
# logs, workspace details, lifecycle controls and deletion; it is never the first thing
|
|
13
|
+
# a user reads, but it opens by itself when something needs attention.
|
|
14
|
+
SECTIONS = %w(lead progress actions environment technical log details danger).freeze
|
|
15
|
+
TECHNICAL = %w(technical progress actions log details danger).freeze
|
|
11
16
|
|
|
12
17
|
# Workspace lifecycle state machine. Each state declares only what is usable and
|
|
13
18
|
# meaningful there; everything else is hidden rather than shown disabled.
|
|
@@ -20,19 +25,60 @@ module Workspaces
|
|
|
20
25
|
#
|
|
21
26
|
# `environment_submit` labels the single environment-form button, which saves the
|
|
22
27
|
# settings and (re)starts the workspace, so a brand new workspace needs only that.
|
|
28
|
+
#
|
|
29
|
+
# Per state: `headline` and `summary` are plain language; `open_environment` and
|
|
30
|
+
# `open_technical` say which disclosures start expanded. A failure always opens the
|
|
31
|
+
# technical section so the cause is visible without hunting for it.
|
|
23
32
|
STATES = {
|
|
24
33
|
'idle' => {
|
|
25
|
-
sections: %w(environment),
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
sections: %w(lead environment),
|
|
35
|
+
headline: "Let's get your workspace running",
|
|
36
|
+
summary: 'Pick the version of the site you want to preview, then start. Setup usually takes a few minutes.',
|
|
37
|
+
environment_submit: 'Start workspace', environment_title: 'Choose what to preview',
|
|
38
|
+
open_environment: true, open_technical: false
|
|
39
|
+
},
|
|
40
|
+
'busy' => {
|
|
41
|
+
sections: %w(lead technical progress log),
|
|
42
|
+
headline: 'Setting things up…',
|
|
43
|
+
summary: "This usually takes a few minutes. We'll take you to the preview when it's ready.",
|
|
44
|
+
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
45
|
+
open_environment: false, open_technical: true
|
|
46
|
+
},
|
|
47
|
+
'ready' => {
|
|
48
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
49
|
+
headline: 'Your workspace is ready',
|
|
50
|
+
summary: 'Everything is set up and running. Open it to see your changes.',
|
|
51
|
+
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
52
|
+
open_environment: false, open_technical: false
|
|
53
|
+
},
|
|
54
|
+
'stopped' => {
|
|
55
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
56
|
+
headline: 'Your workspace is paused',
|
|
57
|
+
summary: 'Start it again to pick up where you left off.',
|
|
58
|
+
environment_submit: 'Save & start', environment_title: 'Change settings',
|
|
59
|
+
open_environment: false, open_technical: false
|
|
60
|
+
},
|
|
61
|
+
'error' => {
|
|
62
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
63
|
+
headline: "Something didn't go to plan",
|
|
64
|
+
summary: 'Setup stopped before it finished. You can try again, or change settings first.',
|
|
65
|
+
environment_submit: 'Save & retry', environment_title: 'Change settings',
|
|
66
|
+
open_environment: false, open_technical: true
|
|
67
|
+
},
|
|
68
|
+
'interrupted' => {
|
|
69
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
70
|
+
headline: 'Setup was interrupted',
|
|
71
|
+
summary: 'It stopped before it finished. Try again to pick up from the start.',
|
|
72
|
+
environment_submit: 'Save & retry', environment_title: 'Change settings',
|
|
73
|
+
open_environment: false, open_technical: true
|
|
29
74
|
},
|
|
30
|
-
'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
75
|
+
'unknown' => {
|
|
76
|
+
sections: %w(lead technical log details),
|
|
77
|
+
headline: 'Workspace status unknown',
|
|
78
|
+
summary: 'Open the technical details to see what the workspace is doing.',
|
|
79
|
+
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
80
|
+
open_environment: false, open_technical: true
|
|
81
|
+
}
|
|
36
82
|
}.freeze
|
|
37
83
|
|
|
38
84
|
def initialize(snapshot)
|
|
@@ -42,9 +88,12 @@ module Workspaces
|
|
|
42
88
|
def to_h
|
|
43
89
|
{ 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
|
|
44
90
|
'actions' => actions, 'ready' => status == 'ready' && !busy?,
|
|
45
|
-
'state' => state, 'sections' => sections,
|
|
46
|
-
'
|
|
47
|
-
'
|
|
91
|
+
'state' => state, 'sections' => sections,
|
|
92
|
+
'headline' => definition[:headline], 'summary' => definition[:summary],
|
|
93
|
+
'subtitle' => subtitle, 'lead_action' => lead_action,
|
|
94
|
+
'environment_title' => definition[:environment_title],
|
|
95
|
+
'environment_submit' => definition[:environment_submit],
|
|
96
|
+
'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
|
|
48
97
|
end
|
|
49
98
|
|
|
50
99
|
private
|
|
@@ -73,6 +122,23 @@ module Workspaces
|
|
|
73
122
|
SECTIONS.to_h { |name| [name, visible.include?(name)] }
|
|
74
123
|
end
|
|
75
124
|
|
|
125
|
+
# The single most useful thing to do next, or nil when the user should just wait.
|
|
126
|
+
# `open` links straight to the preview; the others are lifecycle form posts.
|
|
127
|
+
def lead_action
|
|
128
|
+
case state
|
|
129
|
+
when 'ready' then { 'kind' => 'open', 'label' => 'Open workspace' }
|
|
130
|
+
when 'stopped' then { 'kind' => 'post', 'key' => 'start', 'label' => 'Start workspace' }
|
|
131
|
+
when 'error', 'interrupted' then retry_action.merge('kind' => 'post')
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# What is happening right now, in the quiet line under the headline.
|
|
136
|
+
def subtitle
|
|
137
|
+
return nil unless state == 'busy'
|
|
138
|
+
|
|
139
|
+
@snapshot['message']
|
|
140
|
+
end
|
|
141
|
+
|
|
76
142
|
def title
|
|
77
143
|
return 'Workspace busy' if @snapshot['active'] && !busy_status?
|
|
78
144
|
return @snapshot['message'] || 'Running setup' if status == 'preparing'
|
|
@@ -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.11
|
|
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-15 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
|