workspaces 0.1.0
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 +7 -0
- data/DESIGN.md +67 -0
- data/LICENSE +21 -0
- data/README.md +146 -0
- data/exe/workspaces +5 -0
- data/lib/workspaces/backend.rb +108 -0
- data/lib/workspaces/cli.rb +189 -0
- data/lib/workspaces/command_runner.rb +49 -0
- data/lib/workspaces/config.rb +116 -0
- data/lib/workspaces/coordinator.rb +203 -0
- data/lib/workspaces/dashboard/app.js +132 -0
- data/lib/workspaces/dashboard/checkout_authentication_error.html.erb +22 -0
- data/lib/workspaces/dashboard/environment.js +120 -0
- data/lib/workspaces/dashboard/pull_request.html.erb +37 -0
- data/lib/workspaces/dashboard/styles.css +461 -0
- data/lib/workspaces/dashboard/template.html.erb +82 -0
- data/lib/workspaces/dashboard_view.rb +96 -0
- data/lib/workspaces/environment_overrides.rb +177 -0
- data/lib/workspaces/front_door.rb +275 -0
- data/lib/workspaces/paths.rb +63 -0
- data/lib/workspaces/port_allocator.rb +35 -0
- data/lib/workspaces/presentation.rb +68 -0
- data/lib/workspaces/recipe.rb +135 -0
- data/lib/workspaces/registry.rb +55 -0
- data/lib/workspaces/state_store.rb +84 -0
- data/lib/workspaces/tee_log.rb +39 -0
- data/lib/workspaces/tls.rb +112 -0
- data/lib/workspaces/version.rb +3 -0
- data/lib/workspaces/workflow.rb +78 -0
- data/lib/workspaces/worktree.rb +218 -0
- data/lib/workspaces.rb +5 -0
- metadata +121 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require_relative 'paths'
|
|
6
|
+
|
|
7
|
+
module Workspaces
|
|
8
|
+
module Config
|
|
9
|
+
extend Paths
|
|
10
|
+
|
|
11
|
+
SAFE_LOG_BYTES = 8 * 1024
|
|
12
|
+
SAFE_LOG_LINES = 100
|
|
13
|
+
DEFAULT_HOME = File.expand_path('~/.workspaces')
|
|
14
|
+
DEFAULT_SUBDOMAIN_PREFIX = 'ws-'
|
|
15
|
+
DEFAULT_FRONT_DOOR_PORT = 4747
|
|
16
|
+
BACKEND_PORT_RANGE = (30_000..30_999)
|
|
17
|
+
|
|
18
|
+
def self.front_door_port
|
|
19
|
+
Integer(ENV.fetch('WORKSPACES_PORT', DEFAULT_FRONT_DOOR_PORT))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.front_door_host
|
|
23
|
+
public_uri.authority
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.bind_address
|
|
27
|
+
ENV.fetch('WORKSPACES_BIND', '127.0.0.1')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.public_origin
|
|
31
|
+
uri = public_uri
|
|
32
|
+
"#{uri.scheme}://#{uri.authority}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.public_uri
|
|
36
|
+
value = ENV.fetch('WORKSPACES_PUBLIC_ORIGIN', "http://localhost:#{front_door_port}")
|
|
37
|
+
uri = URI.parse(value)
|
|
38
|
+
unless valid_origin?(uri)
|
|
39
|
+
raise ArgumentError,
|
|
40
|
+
'WORKSPACES_PUBLIC_ORIGIN must be an HTTP(S) origin without credentials or a path'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
uri
|
|
44
|
+
rescue URI::InvalidURIError
|
|
45
|
+
raise ArgumentError, 'Invalid WORKSPACES_PUBLIC_ORIGIN'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.valid_origin?(uri)
|
|
49
|
+
uri.is_a?(URI::HTTP) && uri.host && ['', '/'].include?(uri.path) &&
|
|
50
|
+
[uri.userinfo, uri.query, uri.fragment].all?(&:nil?)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.preview_base_domain
|
|
54
|
+
domain = ENV.fetch('WORKSPACES_BASE_DOMAIN', 'localhost').downcase
|
|
55
|
+
labels = domain.split('.', -1)
|
|
56
|
+
valid = domain.length <= 253 && labels.all? do |label|
|
|
57
|
+
label.length <= 63 && label.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/)
|
|
58
|
+
end
|
|
59
|
+
raise ArgumentError, 'Invalid WORKSPACES_BASE_DOMAIN' unless valid
|
|
60
|
+
|
|
61
|
+
domain
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.preview_subdomain_prefix
|
|
65
|
+
prefix = ENV.fetch('WORKSPACES_SUBDOMAIN_PREFIX', DEFAULT_SUBDOMAIN_PREFIX)
|
|
66
|
+
validate_preview_subdomain_prefix!(prefix)
|
|
67
|
+
prefix
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.preview_host(workspace_id)
|
|
71
|
+
validate_id!(workspace_id)
|
|
72
|
+
hostname = "#{preview_subdomain_prefix}#{workspace_id}.#{preview_base_domain}"
|
|
73
|
+
raise ArgumentError, 'Workspace hostname exceeds DNS label limits' if hostname.split('.').any? do |label|
|
|
74
|
+
label.length > 63
|
|
75
|
+
end
|
|
76
|
+
uri = public_uri
|
|
77
|
+
uri.port == uri.default_port ? hostname : "#{hostname}:#{uri.port}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.preview_url(workspace_id)
|
|
81
|
+
"#{public_uri.scheme}://#{preview_host(workspace_id)}/"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.preview_workspace_id(host)
|
|
85
|
+
pattern = "#{Regexp.escape(preview_subdomain_prefix)}(?<workspace_id>[a-z0-9]+(?:-[a-z0-9]+)*)\\." \
|
|
86
|
+
"#{Regexp.escape(preview_base_domain)}"
|
|
87
|
+
match = /\A#{pattern}\z/i.match(host.to_s)
|
|
88
|
+
match && match[:workspace_id].downcase
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.validate_id!(id)
|
|
92
|
+
return id if id.is_a?(String) && id.length <= 48 && id.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
|
|
93
|
+
|
|
94
|
+
raise ArgumentError, 'Invalid workspace ID'
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.dashboard_url(id)
|
|
98
|
+
"#{public_origin}/workspaces/#{validate_id!(id)}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.validate_preview_subdomain_prefix!(prefix)
|
|
102
|
+
parts = prefix.to_s.split('.', -1)
|
|
103
|
+
raise ArgumentError, 'WORKSPACES_SUBDOMAIN_PREFIX must not be empty' if parts.empty? || parts.any?(&:empty?)
|
|
104
|
+
|
|
105
|
+
parts[0...-1].each do |part|
|
|
106
|
+
next if part.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Unsafe WORKSPACES_SUBDOMAIN_PREFIX: #{prefix.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
return prefix if parts.last.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9-])?\z/)
|
|
112
|
+
|
|
113
|
+
raise ArgumentError, "Unsafe WORKSPACES_SUBDOMAIN_PREFIX: #{prefix.inspect}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require_relative 'registry'
|
|
3
|
+
require_relative 'backend'
|
|
4
|
+
require_relative 'workflow'
|
|
5
|
+
require_relative 'tee_log'
|
|
6
|
+
require_relative 'presentation'
|
|
7
|
+
require_relative 'environment_overrides'
|
|
8
|
+
|
|
9
|
+
module Workspaces
|
|
10
|
+
class Coordinator
|
|
11
|
+
ACTIVE = %w(preparing).freeze
|
|
12
|
+
attr_reader :workers, :workers_mutex
|
|
13
|
+
|
|
14
|
+
def initialize(registry: Registry.new, state: StateStore.new(Config.state_file))
|
|
15
|
+
@registry = registry
|
|
16
|
+
@state = state
|
|
17
|
+
@workers = {}
|
|
18
|
+
@workers_mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start(id, force: false)
|
|
22
|
+
launch(id, restart: false, force: force)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def restart(id)
|
|
26
|
+
launch(id, restart: true, force: true)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update_environment(id, set:, remove:)
|
|
30
|
+
restart = Backend.new(id).running?
|
|
31
|
+
launch(id, restart: restart, force: true,
|
|
32
|
+
prelaunch: lambda { |_workspace|
|
|
33
|
+
Recipe.new
|
|
34
|
+
EnvironmentOverrides.new(id).apply_patch(set: set, remove: remove)
|
|
35
|
+
})
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def wait(id)
|
|
39
|
+
workers_mutex.synchronize { workers[id] }&.value
|
|
40
|
+
snapshot(id)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def snapshot(id, log: 'setup', include_log: true)
|
|
44
|
+
raise ArgumentError, 'Unknown workspace log' unless %w(setup backend).include?(log)
|
|
45
|
+
|
|
46
|
+
workspace = @registry.find(id)
|
|
47
|
+
entry = @state.get(id) || {}
|
|
48
|
+
busy = locked?(id)
|
|
49
|
+
status = resolve_status(id, entry.fetch('status', 'idle'), busy)
|
|
50
|
+
data = workspace.describe.merge(snapshot_fields(id, entry, busy, status, log, include_log))
|
|
51
|
+
data.merge('presentation' => Presentation.new(data).to_h)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def resolve_status(id, status, busy)
|
|
58
|
+
return busy ? status : 'interrupted' if ACTIVE.include?(status)
|
|
59
|
+
return status if status == 'error'
|
|
60
|
+
return 'ready' if Backend.new(id).running?
|
|
61
|
+
|
|
62
|
+
status == 'ready' ? 'stopped' : status
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def snapshot_fields(id, entry, busy, status, log, include_log)
|
|
66
|
+
{
|
|
67
|
+
'workspace_id' => id, 'status' => status, 'active' => busy,
|
|
68
|
+
'message' => status == entry['status'] ? entry['message'] : status.capitalize,
|
|
69
|
+
'prepare_started_at' => entry['prepare_started_at'], 'updated_at' => entry['updated_at'],
|
|
70
|
+
'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
|
|
71
|
+
'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
|
|
72
|
+
'steps' => entry.fetch('steps', []),
|
|
73
|
+
'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
|
|
74
|
+
'environment_keys' => environment_keys(id)
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def failure_phase(entry, status)
|
|
79
|
+
status == 'interrupted' ? entry['status'] : entry['failed_phase']
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def launch(id, restart:, force:, prelaunch: nil)
|
|
83
|
+
workspace = @registry.find(id)
|
|
84
|
+
lock = acquire_lock(id)
|
|
85
|
+
return false unless lock
|
|
86
|
+
|
|
87
|
+
return false if backend_already_running?(id, force, lock)
|
|
88
|
+
prelaunch&.call(workspace)
|
|
89
|
+
publish_starting(id, restart)
|
|
90
|
+
spawn_worker(workspace, restart, lock)
|
|
91
|
+
true
|
|
92
|
+
rescue StandardError
|
|
93
|
+
lock&.close unless lock&.closed?
|
|
94
|
+
raise
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def backend_already_running?(id, force, lock)
|
|
98
|
+
return false if force || !Backend.new(id).running?
|
|
99
|
+
|
|
100
|
+
lock.close
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def acquire_lock(id)
|
|
105
|
+
Config.locks_dir.mkpath
|
|
106
|
+
# The worker owns this duplicate; closing the request's descriptor must not release its lock.
|
|
107
|
+
File.open(Config.prepare_lock_path(id), File::RDWR | File::CREAT, 0o600) do |file|
|
|
108
|
+
file.dup if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def publish_starting(id, restart)
|
|
113
|
+
recipe = Recipe.new
|
|
114
|
+
steps = recipe.for_operation(restart: restart).map do |step|
|
|
115
|
+
{ 'key' => step['id'], 'label' => step['name'], 'state' => 'pending' }
|
|
116
|
+
end
|
|
117
|
+
update(id, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
|
|
118
|
+
'steps' => steps,
|
|
119
|
+
'operation' => restart ? 'restart' : 'prepare', 'failed_phase' => nil,
|
|
120
|
+
'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def spawn_worker(workspace, restart, lock)
|
|
124
|
+
id = workspace.id
|
|
125
|
+
workers_mutex.synchronize do
|
|
126
|
+
workers[id] = Thread.new do
|
|
127
|
+
run(workspace, restart: restart)
|
|
128
|
+
ensure
|
|
129
|
+
lock.close
|
|
130
|
+
workers_mutex.synchronize { workers.delete(id) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def run(workspace, restart:)
|
|
136
|
+
id = workspace.id
|
|
137
|
+
File.open(Config.setup_log_path(id), 'a') do |file|
|
|
138
|
+
file.sync = true
|
|
139
|
+
log = TeeLog.new(id, file)
|
|
140
|
+
log.puts "==> #{restart ? 'restarting' : 'preparing'} workspace #{id}; branch and working files unchanged"
|
|
141
|
+
prepare_and_boot(workspace, log, restart)
|
|
142
|
+
end
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
publish_error(id, e)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def prepare_and_boot(workspace, log, restart)
|
|
148
|
+
id = workspace.id
|
|
149
|
+
port = Workflow.new(workspace, log, state: @state).run!(restart: restart)
|
|
150
|
+
update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def publish_error(id, exception)
|
|
154
|
+
detail = scrub("#{exception.class}: #{exception.message}", id: id)
|
|
155
|
+
$stdout.puts "[Workspace #{id}] ERROR: #{detail}"
|
|
156
|
+
File.open(Config.setup_log_path(id), 'a') { |file| file.puts "ERROR: #{detail}" }
|
|
157
|
+
update(id, 'status' => 'error', 'message' => 'Workspace preparation failed', 'last_error' => detail,
|
|
158
|
+
'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def locked?(id)
|
|
162
|
+
path = Config.prepare_lock_path(id)
|
|
163
|
+
return false unless path.file?
|
|
164
|
+
|
|
165
|
+
File.open(path, File::RDWR) { |lock| !lock.flock(File::LOCK_EX | File::LOCK_NB) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def log_tail(id, log)
|
|
169
|
+
path = log == 'backend' ? Config.backend_log_path(id, create: false) : Config.setup_log_path(id, create: false)
|
|
170
|
+
return '' unless path.file?
|
|
171
|
+
|
|
172
|
+
raw = File.open(path, 'rb') do |file|
|
|
173
|
+
file.seek(-Config::SAFE_LOG_BYTES, IO::SEEK_END) if file.size > Config::SAFE_LOG_BYTES
|
|
174
|
+
file.read
|
|
175
|
+
end
|
|
176
|
+
scrub(raw, id: id).lines.last(Config::SAFE_LOG_LINES).join
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def scrub(value, id:)
|
|
180
|
+
scrubbed = value.to_s
|
|
181
|
+
scrubbed = EnvironmentOverrides.new(id).scrub(scrubbed)
|
|
182
|
+
scrubbed.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
183
|
+
.gsub(%r{\e\[[0-9;?]*[ -/]*[@-~]}, '')
|
|
184
|
+
.gsub(/((?:api[_-]?key|token|secret|password|authorization)[^\n:=]{0,40}[:=]\s*)[^\s"',;]+/i, '\1[FILTERED]')
|
|
185
|
+
.gsub(/(Bearer\s+)[^\s]+/i, '\1[FILTERED]')
|
|
186
|
+
.gsub(%r{(https?://[^:\s/]+:)[^@\s]+(@)}i, '\1[FILTERED]\2')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def environment_keys(id)
|
|
190
|
+
EnvironmentOverrides.new(id).keys
|
|
191
|
+
rescue EnvironmentOverrides::Invalid
|
|
192
|
+
[]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def update(id, attributes)
|
|
196
|
+
@state.set(id, attributes.merge('updated_at' => now))
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def now
|
|
200
|
+
Time.now.utc.iso8601
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
'use strict';
|
|
3
|
+
const root = document.getElementById('workspace');
|
|
4
|
+
if (!root) return;
|
|
5
|
+
const node = (id) => document.getElementById(id);
|
|
6
|
+
const base = '/workspaces/' + root.dataset.id;
|
|
7
|
+
const selectedLog = new URLSearchParams(window.location.search).get('log') === 'backend'
|
|
8
|
+
? 'backend'
|
|
9
|
+
: 'setup';
|
|
10
|
+
const allowedActions = ['start', 'prepare', 'restart', 'stop'];
|
|
11
|
+
let busy = root.dataset.busy === 'true';
|
|
12
|
+
let started = Date.parse(root.dataset.started);
|
|
13
|
+
let completed = Date.parse(root.dataset.completed);
|
|
14
|
+
let actionsSignature = null;
|
|
15
|
+
function toggleBusyState(nextBusy) {
|
|
16
|
+
root.querySelectorAll('.workspace-actions button').forEach((button) => {
|
|
17
|
+
button.disabled = nextBusy;
|
|
18
|
+
});
|
|
19
|
+
if (window.WorkspaceEnvironment)
|
|
20
|
+
window.WorkspaceEnvironment.toggleBusy(nextBusy);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderActions(actions) {
|
|
24
|
+
const signature = JSON.stringify(actions);
|
|
25
|
+
if (signature === actionsSignature) return;
|
|
26
|
+
actionsSignature = signature;
|
|
27
|
+
node('actions').replaceChildren();
|
|
28
|
+
actions.forEach((action) => {
|
|
29
|
+
if (!allowedActions.includes(action.key)) return;
|
|
30
|
+
const form = document.createElement('form');
|
|
31
|
+
form.method = 'post';
|
|
32
|
+
form.action = base + '/' + action.key;
|
|
33
|
+
const button = document.createElement('button');
|
|
34
|
+
button.className =
|
|
35
|
+
'button ' + (action.primary ? 'button--primary' : 'button--subtle');
|
|
36
|
+
button.textContent = action.label;
|
|
37
|
+
form.append(button);
|
|
38
|
+
node('actions').append(form);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderStep(step) {
|
|
43
|
+
const item = document.createElement('li');
|
|
44
|
+
item.className = 'step';
|
|
45
|
+
item.dataset.stepState = step.state;
|
|
46
|
+
if (step.state === 'active') item.setAttribute('aria-current', 'step');
|
|
47
|
+
const bullet = document.createElement('span');
|
|
48
|
+
bullet.className = 'step-bullet';
|
|
49
|
+
bullet.setAttribute('aria-hidden', 'true');
|
|
50
|
+
const label = document.createElement('span');
|
|
51
|
+
label.textContent = step.label;
|
|
52
|
+
const outcome = document.createElement('span');
|
|
53
|
+
outcome.className = 'step-outcome';
|
|
54
|
+
outcome.textContent = step.state;
|
|
55
|
+
item.append(bullet, label, outcome);
|
|
56
|
+
return item;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function updateLog(data) {
|
|
60
|
+
if (data.log_source !== selectedLog) return;
|
|
61
|
+
const log = node('log');
|
|
62
|
+
const bottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 18;
|
|
63
|
+
log.textContent = data.log_tail || '';
|
|
64
|
+
if (bottom) log.scrollTop = log.scrollHeight;
|
|
65
|
+
node('updated').textContent = data.updated_at || '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function update(data) {
|
|
69
|
+
const view = data.presentation;
|
|
70
|
+
if (!view)
|
|
71
|
+
throw new Error('Restart the front door to load the updated status API.');
|
|
72
|
+
busy = view.busy;
|
|
73
|
+
toggleBusyState(busy);
|
|
74
|
+
started = Date.parse(data.prepare_started_at);
|
|
75
|
+
completed = Date.parse(data.completed_at);
|
|
76
|
+
renderStatus(view);
|
|
77
|
+
node('message').textContent = data.message || '';
|
|
78
|
+
node('progress').replaceChildren(...view.steps.map(renderStep));
|
|
79
|
+
renderActions(view.actions);
|
|
80
|
+
updateLog(data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderStatus(view) {
|
|
84
|
+
node('status').textContent = view.title;
|
|
85
|
+
node('activity').hidden = !view.busy;
|
|
86
|
+
node('preview').hidden = !view.ready;
|
|
87
|
+
node('error').textContent = view.error || '';
|
|
88
|
+
node('error-panel').hidden = !view.error;
|
|
89
|
+
if (window.WorkspaceEnvironment)
|
|
90
|
+
window.WorkspaceEnvironment.updateStatus(view.busy);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function poll() {
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch(base + '/status?log=' + selectedLog, {
|
|
98
|
+
credentials: 'same-origin',
|
|
99
|
+
signal: controller.signal
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok)
|
|
102
|
+
throw new Error('Status unavailable. Retrying automatically.');
|
|
103
|
+
update(await response.json());
|
|
104
|
+
node('connection').textContent = '';
|
|
105
|
+
} catch (error) {
|
|
106
|
+
node('connection').textContent = error.message;
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
setTimeout(poll, busy || selectedLog === 'backend' ? 2000 : 10000);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function tick() {
|
|
114
|
+
if (Number.isFinite(started)) {
|
|
115
|
+
const end = busy ? Date.now() : completed;
|
|
116
|
+
const seconds = Math.max(0, Math.floor((end - started) / 1000));
|
|
117
|
+
if (Number.isFinite(seconds))
|
|
118
|
+
node('elapsed').textContent =
|
|
119
|
+
Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
|
|
120
|
+
}
|
|
121
|
+
setTimeout(tick, 1000);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
root.addEventListener('submit', (event) => {
|
|
125
|
+
if (event.target.id !== 'environment-form') toggleBusyState(true);
|
|
126
|
+
node('connection').textContent = 'Submitting…';
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
toggleBusyState(busy);
|
|
130
|
+
tick();
|
|
131
|
+
setTimeout(poll, 1000);
|
|
132
|
+
})();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<main class="dashboard">
|
|
2
|
+
<header class="dashboard__header"><h1>GitHub access needs setup</h1><a href="/workspaces">All workspaces</a></header>
|
|
3
|
+
<section class="error-panel" role="alert">
|
|
4
|
+
<h2>Checkout couldn't authenticate</h2>
|
|
5
|
+
<p>The server needs Git credentials to fetch this repository. GitHub CLI access alone may not configure Git.</p>
|
|
6
|
+
</section>
|
|
7
|
+
<section class="status-panel" aria-labelledby="fix-heading">
|
|
8
|
+
<h2 id="fix-heading">Fix on the server</h2>
|
|
9
|
+
<ol>
|
|
10
|
+
<li>Open a terminal in the app container, as the user running Workspaces.</li>
|
|
11
|
+
<li>Check GitHub CLI authentication: <code>gh auth status</code>. If needed, authenticate with <code>gh auth login</code>.</li>
|
|
12
|
+
<li>Connect Git to those credentials:</li>
|
|
13
|
+
</ol>
|
|
14
|
+
<pre><code>gh auth setup-git --hostname github.com</code></pre>
|
|
15
|
+
<p>For SSH remotes, configure a key authorized for the repository instead. Persist the credential setup in the container configuration.</p>
|
|
16
|
+
<% if @retry_path %>
|
|
17
|
+
<form method="post" action="<%= escape(@retry_path) %>"><button class="button button--primary">Retry checkout</button></form>
|
|
18
|
+
<% else %>
|
|
19
|
+
<a class="button button--primary" href="/workspaces">Back to workspace creation</a>
|
|
20
|
+
<% end %>
|
|
21
|
+
</section>
|
|
22
|
+
</main>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
'use strict';
|
|
3
|
+
const form = document.getElementById('environment-form');
|
|
4
|
+
if (!form) return;
|
|
5
|
+
const list = form.querySelector('[data-env-add-list]');
|
|
6
|
+
const addButton = form.querySelector('[data-env-add-row]');
|
|
7
|
+
const status = document.getElementById('environment-status');
|
|
8
|
+
const namePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9
|
+
let rowIndex = 0;
|
|
10
|
+
|
|
11
|
+
function toggleBusy(nextBusy) {
|
|
12
|
+
form
|
|
13
|
+
.querySelectorAll('fieldset, [data-env-submit], [data-env-add-row]')
|
|
14
|
+
.forEach((control) => {
|
|
15
|
+
control.disabled = nextBusy;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function updateStatus(busy) {
|
|
20
|
+
if (busy)
|
|
21
|
+
status.textContent =
|
|
22
|
+
'Workspace is busy. Environment updates are temporarily disabled.';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function syncReplace(toggle) {
|
|
26
|
+
const input = toggle
|
|
27
|
+
.closest('[data-env-existing]')
|
|
28
|
+
.querySelector('[data-replace-value]');
|
|
29
|
+
input.disabled = !toggle.checked;
|
|
30
|
+
if (!toggle.checked) input.value = '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function labelledInput(id, label, type) {
|
|
34
|
+
const wrap = document.createElement('p');
|
|
35
|
+
const caption = document.createElement('label');
|
|
36
|
+
caption.className = 'env-input-label';
|
|
37
|
+
caption.htmlFor = id;
|
|
38
|
+
caption.textContent = label;
|
|
39
|
+
const input = document.createElement('input');
|
|
40
|
+
input.id = id;
|
|
41
|
+
input.type = type;
|
|
42
|
+
input.autocomplete = 'off';
|
|
43
|
+
wrap.append(caption, input);
|
|
44
|
+
return [wrap, input];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function addVariableRow() {
|
|
48
|
+
const row = document.createElement('li');
|
|
49
|
+
row.className = 'env-add-item';
|
|
50
|
+
row.dataset.envAddRow = String(rowIndex);
|
|
51
|
+
const [keyWrap, key] = labelledInput(
|
|
52
|
+
'env-add-key-' + rowIndex,
|
|
53
|
+
'Variable name',
|
|
54
|
+
'text'
|
|
55
|
+
);
|
|
56
|
+
key.spellcheck = false;
|
|
57
|
+
key.dataset.envNewName = 'true';
|
|
58
|
+
key.pattern = '[A-Za-z_][A-Za-z0-9_]*';
|
|
59
|
+
key.title =
|
|
60
|
+
'Use letters, numbers, and underscores; must not start with a number.';
|
|
61
|
+
const [valueWrap, value] = labelledInput(
|
|
62
|
+
'env-add-value-' + rowIndex,
|
|
63
|
+
'Value',
|
|
64
|
+
'password'
|
|
65
|
+
);
|
|
66
|
+
value.dataset.envNewValue = 'true';
|
|
67
|
+
rowIndex += 1;
|
|
68
|
+
const fields = document.createElement('div');
|
|
69
|
+
fields.className = 'env-add-grid';
|
|
70
|
+
fields.append(keyWrap, valueWrap);
|
|
71
|
+
const actions = document.createElement('div');
|
|
72
|
+
actions.className = 'env-row-actions';
|
|
73
|
+
const remove = document.createElement('button');
|
|
74
|
+
remove.type = 'button';
|
|
75
|
+
remove.className = 'button button--subtle button--icon';
|
|
76
|
+
remove.textContent = 'Remove';
|
|
77
|
+
remove.setAttribute('aria-label', 'Remove variable row');
|
|
78
|
+
remove.addEventListener('click', () => row.remove());
|
|
79
|
+
actions.append(remove);
|
|
80
|
+
row.append(fields, actions);
|
|
81
|
+
list.append(row);
|
|
82
|
+
key.focus();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function appendSetValue(row) {
|
|
86
|
+
const key = row.querySelector('[data-env-new-name]').value.trim();
|
|
87
|
+
const value = row.querySelector('[data-env-new-value]');
|
|
88
|
+
if (key && namePattern.test(key)) {
|
|
89
|
+
const hidden = document.createElement('input');
|
|
90
|
+
hidden.type = 'hidden';
|
|
91
|
+
hidden.dataset.generatedSet = 'true';
|
|
92
|
+
hidden.name = 'set[' + key + ']';
|
|
93
|
+
hidden.value = value.value;
|
|
94
|
+
form.append(hidden);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function prepareSubmit() {
|
|
99
|
+
form.querySelectorAll('[data-env-existing]').forEach((row) => {
|
|
100
|
+
const toggle = row.querySelector('[data-replace-toggle]');
|
|
101
|
+
if (!toggle.checked) row.querySelector('[data-replace-value]').value = '';
|
|
102
|
+
});
|
|
103
|
+
form
|
|
104
|
+
.querySelectorAll('[data-generated-set]')
|
|
105
|
+
.forEach((input) => input.remove());
|
|
106
|
+
form.querySelectorAll('li[data-env-add-row]').forEach(appendSetValue);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
form.querySelectorAll('[data-replace-toggle]').forEach((toggle) => {
|
|
110
|
+
toggle.addEventListener('change', () => syncReplace(toggle));
|
|
111
|
+
syncReplace(toggle);
|
|
112
|
+
});
|
|
113
|
+
addButton.addEventListener('click', addVariableRow);
|
|
114
|
+
form.addEventListener('submit', () => {
|
|
115
|
+
prepareSubmit();
|
|
116
|
+
status.textContent = 'Submitting environment overrides…';
|
|
117
|
+
});
|
|
118
|
+
window.WorkspaceEnvironment = { toggleBusy, updateStatus };
|
|
119
|
+
toggleBusy(document.getElementById('workspace').dataset.busy === 'true');
|
|
120
|
+
})();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<main class="dashboard">
|
|
2
|
+
<header class="dashboard__header">
|
|
3
|
+
<div><p class="eyebrow">PR preview</p><h1>PR #<%= escape(@number) %></h1></div>
|
|
4
|
+
<a href="/workspaces" class="button button--subtle">All workspaces</a>
|
|
5
|
+
</header>
|
|
6
|
+
<% unless @workspaces.empty? %>
|
|
7
|
+
<section class="status-panel" aria-labelledby="existing-heading">
|
|
8
|
+
<h2 id="existing-heading"><%= @workspaces.one? ? 'Continue in your workspace' : 'Choose a workspace' %></h2>
|
|
9
|
+
<p>Open an existing workspace to view its status, run it, or continue working.</p>
|
|
10
|
+
<% @workspaces.each do |workspace| %>
|
|
11
|
+
<article>
|
|
12
|
+
<h3><%= escape(workspace.fetch('id')) %></h3>
|
|
13
|
+
<p><%= escape(workspace['branch']) %></p>
|
|
14
|
+
<a href="/workspaces/<%= escape(workspace.fetch('id')) %>" class="button button--primary" aria-label="Open workspace <%= escape(workspace.fetch('id')) %>">Open workspace</a>
|
|
15
|
+
</article>
|
|
16
|
+
<% end %>
|
|
17
|
+
</section>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% if @workspaces.empty? %>
|
|
20
|
+
<section class="status-panel" aria-labelledby="create-heading">
|
|
21
|
+
<h2 id="create-heading">Preview this pull request</h2>
|
|
22
|
+
<p>No workspaces yet for this PR.</p>
|
|
23
|
+
<% else %>
|
|
24
|
+
<details class="status-panel">
|
|
25
|
+
<summary>Create another workspace</summary>
|
|
26
|
+
<% end %>
|
|
27
|
+
<p>Create a fresh workspace from the PR's current head and start its server. Existing workspaces and edits stay untouched.</p>
|
|
28
|
+
<form method="post" action="/pr/<%= escape(@number) %>">
|
|
29
|
+
<button class="button <%= @workspaces.empty? ? 'button--primary' : 'button--subtle' %>">Create and start workspace</button>
|
|
30
|
+
</form>
|
|
31
|
+
<p class="muted">Creation fetches the PR. Repository setup steps continue on the workspace dashboard.</p>
|
|
32
|
+
<% if @workspaces.empty? %>
|
|
33
|
+
</section>
|
|
34
|
+
<% else %>
|
|
35
|
+
</details>
|
|
36
|
+
<% end %>
|
|
37
|
+
</main>
|