workspaces 0.1.10 → 0.1.12
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 +18 -9
- data/README.md +7 -3
- data/lib/workspaces/config.rb +3 -0
- data/lib/workspaces/coordinator.rb +54 -8
- data/lib/workspaces/dashboard/app.js +184 -48
- data/lib/workspaces/dashboard/styles.css +130 -52
- data/lib/workspaces/dashboard/template.html.erb +57 -45
- data/lib/workspaces/dashboard_view.rb +46 -4
- data/lib/workspaces/duration_format.rb +36 -0
- data/lib/workspaces/paths.rb +11 -0
- data/lib/workspaces/presentation.rb +110 -17
- data/lib/workspaces/step_log.rb +55 -0
- data/lib/workspaces/tee_log.rb +7 -1
- data/lib/workspaces/version.rb +1 -1
- data/lib/workspaces/workflow.rb +13 -3
- metadata +4 -2
|
@@ -6,8 +6,14 @@ 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. Setup steps are
|
|
14
|
+
# not a section of their own: each is an accordion with its own log inside `log`.
|
|
15
|
+
SECTIONS = %w(lead actions environment technical log details danger).freeze
|
|
16
|
+
TECHNICAL = %w(technical actions log details danger).freeze
|
|
11
17
|
|
|
12
18
|
# Workspace lifecycle state machine. Each state declares only what is usable and
|
|
13
19
|
# meaningful there; everything else is hidden rather than shown disabled.
|
|
@@ -20,19 +26,60 @@ module Workspaces
|
|
|
20
26
|
#
|
|
21
27
|
# `environment_submit` labels the single environment-form button, which saves the
|
|
22
28
|
# settings and (re)starts the workspace, so a brand new workspace needs only that.
|
|
29
|
+
#
|
|
30
|
+
# Per state: `headline` and `summary` are plain language; `open_environment` and
|
|
31
|
+
# `open_technical` say which disclosures start expanded. A failure always opens the
|
|
32
|
+
# technical section so the cause is visible without hunting for it.
|
|
23
33
|
STATES = {
|
|
24
34
|
'idle' => {
|
|
25
|
-
sections: %w(environment),
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
sections: %w(lead environment),
|
|
36
|
+
headline: "Let's get your workspace running",
|
|
37
|
+
summary: 'Pick the version of the site you want to preview, then start. Setup usually takes a few minutes.',
|
|
38
|
+
environment_submit: 'Start workspace', environment_title: 'Choose what to preview',
|
|
39
|
+
open_environment: true, open_technical: false
|
|
40
|
+
},
|
|
41
|
+
'busy' => {
|
|
42
|
+
sections: %w(lead technical log),
|
|
43
|
+
headline: 'Setting things up…',
|
|
44
|
+
summary: "This usually takes a few minutes. We'll take you to the preview when it's ready.",
|
|
45
|
+
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
46
|
+
open_environment: false, open_technical: true
|
|
47
|
+
},
|
|
48
|
+
'ready' => {
|
|
49
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
50
|
+
headline: 'Your workspace is ready',
|
|
51
|
+
summary: 'Everything is set up and running. Open it to see your changes.',
|
|
52
|
+
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
53
|
+
open_environment: false, open_technical: false
|
|
54
|
+
},
|
|
55
|
+
'stopped' => {
|
|
56
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
57
|
+
headline: 'Your workspace is paused',
|
|
58
|
+
summary: 'Start it again to pick up where you left off.',
|
|
59
|
+
environment_submit: 'Save & start', environment_title: 'Change settings',
|
|
60
|
+
open_environment: false, open_technical: false
|
|
61
|
+
},
|
|
62
|
+
'error' => {
|
|
63
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
64
|
+
headline: "Something didn't go to plan",
|
|
65
|
+
summary: 'Setup stopped before it finished. You can try again, or change settings first.',
|
|
66
|
+
environment_submit: 'Save & retry', environment_title: 'Change settings',
|
|
67
|
+
open_environment: false, open_technical: true
|
|
29
68
|
},
|
|
30
|
-
'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
69
|
+
'interrupted' => {
|
|
70
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
71
|
+
headline: 'Setup was interrupted',
|
|
72
|
+
summary: 'It stopped before it finished. Try again to pick up from the start.',
|
|
73
|
+
environment_submit: 'Save & retry', environment_title: 'Change settings',
|
|
74
|
+
open_environment: false, open_technical: true
|
|
75
|
+
},
|
|
76
|
+
'unknown' => {
|
|
77
|
+
sections: %w(lead technical log details),
|
|
78
|
+
headline: 'Workspace status unknown',
|
|
79
|
+
summary: 'Open the technical details to see what the workspace is doing.',
|
|
80
|
+
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
81
|
+
open_environment: false, open_technical: true
|
|
82
|
+
}
|
|
36
83
|
}.freeze
|
|
37
84
|
|
|
38
85
|
def initialize(snapshot)
|
|
@@ -42,9 +89,13 @@ module Workspaces
|
|
|
42
89
|
def to_h
|
|
43
90
|
{ 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
|
|
44
91
|
'actions' => actions, 'ready' => status == 'ready' && !busy?,
|
|
45
|
-
'state' => state, 'sections' => sections,
|
|
46
|
-
'
|
|
47
|
-
'
|
|
92
|
+
'state' => state, 'sections' => sections,
|
|
93
|
+
'headline' => definition[:headline], 'summary' => definition[:summary],
|
|
94
|
+
'subtitle' => subtitle, 'lead_action' => lead_action, 'total' => total,
|
|
95
|
+
'current_preset' => current_preset,
|
|
96
|
+
'environment_title' => definition[:environment_title],
|
|
97
|
+
'environment_submit' => definition[:environment_submit],
|
|
98
|
+
'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
|
|
48
99
|
end
|
|
49
100
|
|
|
50
101
|
private
|
|
@@ -68,11 +119,53 @@ module Workspaces
|
|
|
68
119
|
end
|
|
69
120
|
|
|
70
121
|
def sections
|
|
71
|
-
visible = definition[:sections]
|
|
72
|
-
visible.delete('progress') if steps.empty? || %w(idle stopped).include?(state)
|
|
122
|
+
visible = definition[:sections]
|
|
73
123
|
SECTIONS.to_h { |name| [name, visible.include?(name)] }
|
|
74
124
|
end
|
|
75
125
|
|
|
126
|
+
# The single most useful thing to do next, or nil when the user should just wait.
|
|
127
|
+
# `open` links straight to the preview; the others are lifecycle form posts.
|
|
128
|
+
def lead_action
|
|
129
|
+
case state
|
|
130
|
+
when 'ready' then { 'kind' => 'open', 'label' => 'Open workspace' }
|
|
131
|
+
when 'stopped' then { 'kind' => 'post', 'key' => 'start', 'label' => 'Start workspace' }
|
|
132
|
+
when 'error', 'interrupted' then retry_action.merge('kind' => 'post')
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The preset whose values exactly equal the workspace's saved editable settings, or nil when
|
|
137
|
+
# there are no presets or the settings match none (for example after a hand edit). Presets
|
|
138
|
+
# arrive already completed with the recipe defaults, so equality is a plain comparison.
|
|
139
|
+
def current_preset
|
|
140
|
+
presets = @snapshot['environment_presets']
|
|
141
|
+
current = @snapshot['editable_environment']
|
|
142
|
+
return nil unless presets.is_a?(Hash) && current.is_a?(Hash) && !current.empty?
|
|
143
|
+
|
|
144
|
+
normalized = stringify(current)
|
|
145
|
+
presets.find { |_name, values| values.is_a?(Hash) && stringify(values) == normalized }&.first&.to_s
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def stringify(values)
|
|
149
|
+
values.to_h { |name, value| [name.to_s, value.to_s] }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# How long the last run took and how it ended, or nil while one is in progress or when no
|
|
153
|
+
# total was recorded (interrupted runs, and workspaces set up before totals were persisted).
|
|
154
|
+
def total
|
|
155
|
+
seconds = @snapshot['total_seconds']
|
|
156
|
+
return nil if busy? || !seconds.is_a?(Numeric)
|
|
157
|
+
|
|
158
|
+
failed = status == 'error' || steps.any? { |step| step['state'] == 'failed' }
|
|
159
|
+
{ 'state' => failed ? 'failed' : 'complete', 'seconds' => seconds }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# What is happening right now, in the quiet line under the headline.
|
|
163
|
+
def subtitle
|
|
164
|
+
return nil unless state == 'busy'
|
|
165
|
+
|
|
166
|
+
@snapshot['message']
|
|
167
|
+
end
|
|
168
|
+
|
|
76
169
|
def title
|
|
77
170
|
return 'Workspace busy' if @snapshot['active'] && !busy_status?
|
|
78
171
|
return @snapshot['message'] || 'Running setup' if status == 'preparing'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Workspaces
|
|
4
|
+
# Splits a run's output by recipe step.
|
|
5
|
+
#
|
|
6
|
+
# Wraps the combined log (a TeeLog, or anything with puts/print). Everything still goes to
|
|
7
|
+
# the combined log exactly as before; while a step is current, the same text is also written
|
|
8
|
+
# to that step's own file so the dashboard can show each step under its own heading.
|
|
9
|
+
#
|
|
10
|
+
# Text arriving through this object has already been scrubbed by the wrapped log, so we hand
|
|
11
|
+
# it the raw text and copy what it actually wrote, rather than scrubbing twice.
|
|
12
|
+
class StepLog
|
|
13
|
+
def initialize(log, workspace_id)
|
|
14
|
+
@log = log
|
|
15
|
+
@workspace_id = workspace_id
|
|
16
|
+
@file = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Begins a step's file, truncating any output from an earlier run of the same step.
|
|
20
|
+
def begin_step(step_id)
|
|
21
|
+
finish_step
|
|
22
|
+
@file = File.open(Config.step_log_path(@workspace_id, step_id), 'w')
|
|
23
|
+
@file.sync = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def finish_step
|
|
27
|
+
@file&.close
|
|
28
|
+
@file = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def puts(message = '')
|
|
32
|
+
text = scrubbed(message)
|
|
33
|
+
@log.puts(message)
|
|
34
|
+
@file&.puts(text)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def print(message)
|
|
38
|
+
text = scrubbed(message)
|
|
39
|
+
@log.print(message)
|
|
40
|
+
@file&.print(text)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Clears every step file; called when a run begins so a restart never shows stale steps.
|
|
44
|
+
def self.reset(workspace_id)
|
|
45
|
+
dir = Config.step_logs_dir(workspace_id, create: false)
|
|
46
|
+
Dir.glob(dir.join('*.log')).each { |path| File.delete(path) } if dir.directory?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def scrubbed(message)
|
|
52
|
+
@log.respond_to?(:scrub_text) ? @log.scrub_text(message) : message.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/workspaces/tee_log.rb
CHANGED
|
@@ -26,10 +26,16 @@ module Workspaces
|
|
|
26
26
|
$stdout.print(prefixed(scrubbed))
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# The filtering applied to everything written. Public so StepLog can copy exactly what the
|
|
30
|
+
# combined log received, instead of reimplementing the rules.
|
|
31
|
+
def scrub_text(message)
|
|
32
|
+
@overrides.scrub(message)
|
|
33
|
+
end
|
|
34
|
+
|
|
29
35
|
private
|
|
30
36
|
|
|
31
37
|
def scrub(message)
|
|
32
|
-
|
|
38
|
+
scrub_text(message)
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def prefixed(message)
|
data/lib/workspaces/version.rb
CHANGED
data/lib/workspaces/workflow.rb
CHANGED
|
@@ -3,12 +3,15 @@ require_relative 'recipe'
|
|
|
3
3
|
require_relative 'command_runner'
|
|
4
4
|
require_relative 'backend'
|
|
5
5
|
require_relative 'environment_overrides'
|
|
6
|
+
require_relative 'step_log'
|
|
7
|
+
require_relative 'duration_format'
|
|
6
8
|
|
|
7
9
|
module Workspaces
|
|
8
10
|
class Workflow
|
|
9
11
|
def initialize(workspace, log, state: StateStore.new(Config.state_file))
|
|
10
12
|
@workspace = workspace
|
|
11
|
-
@
|
|
13
|
+
@combined_log = log
|
|
14
|
+
@log = StepLog.new(log, workspace.id)
|
|
12
15
|
@state = state
|
|
13
16
|
@backend = Backend.new(workspace.id)
|
|
14
17
|
end
|
|
@@ -18,6 +21,7 @@ module Workspaces
|
|
|
18
21
|
overrides = EnvironmentOverrides.new(@workspace.id).load
|
|
19
22
|
steps = recipe.for_operation(restart: restart)
|
|
20
23
|
publish_steps(steps)
|
|
24
|
+
StepLog.reset(@workspace.id)
|
|
21
25
|
@backend.stop!
|
|
22
26
|
context = build_context(@backend.reserve!)
|
|
23
27
|
runner = CommandRunner.new(@workspace.path, @log)
|
|
@@ -27,6 +31,8 @@ module Workspaces
|
|
|
27
31
|
rescue StandardError
|
|
28
32
|
@backend.stop!
|
|
29
33
|
raise
|
|
34
|
+
ensure
|
|
35
|
+
@log.finish_step
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
private
|
|
@@ -50,6 +56,7 @@ module Workspaces
|
|
|
50
56
|
def execute(step, recipe, context, runner, overrides)
|
|
51
57
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
58
|
progress(step, 'active')
|
|
59
|
+
@log.begin_step(step.fetch('id'))
|
|
53
60
|
@log.puts "==> #{step.fetch('name')}"
|
|
54
61
|
command = recipe.command(step, context)
|
|
55
62
|
env = recipe.env(step, context, overrides: overrides)
|
|
@@ -60,7 +67,10 @@ module Workspaces
|
|
|
60
67
|
@backend.ensure_alive! if @server_started
|
|
61
68
|
end
|
|
62
69
|
@server_started = true if step['background']
|
|
63
|
-
rescue StandardError
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
# The reason a step failed is not command output, so the step file would otherwise end
|
|
72
|
+
# without it. Record it where the step's own accordion can show it.
|
|
73
|
+
@log.puts "ERROR: #{e.message}"
|
|
64
74
|
finish_step(step, 'failed', started_at)
|
|
65
75
|
raise
|
|
66
76
|
else
|
|
@@ -70,7 +80,7 @@ module Workspaces
|
|
|
70
80
|
def finish_step(step, status, started_at)
|
|
71
81
|
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).round(3)
|
|
72
82
|
progress(step, status, duration_seconds: duration)
|
|
73
|
-
@log.puts format('==> %s %s (
|
|
83
|
+
@log.puts format('==> %s %s (%s)', step.fetch('name'), status, DurationFormat.call(duration))
|
|
74
84
|
end
|
|
75
85
|
|
|
76
86
|
def progress(step, status, duration_seconds: nil)
|
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.12
|
|
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-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -83,6 +83,7 @@ files:
|
|
|
83
83
|
- lib/workspaces/dashboard/styles.css
|
|
84
84
|
- lib/workspaces/dashboard/template.html.erb
|
|
85
85
|
- lib/workspaces/dashboard_view.rb
|
|
86
|
+
- lib/workspaces/duration_format.rb
|
|
86
87
|
- lib/workspaces/environment_overrides.rb
|
|
87
88
|
- lib/workspaces/front_door.rb
|
|
88
89
|
- lib/workspaces/paths.rb
|
|
@@ -92,6 +93,7 @@ files:
|
|
|
92
93
|
- lib/workspaces/recipe.rb
|
|
93
94
|
- lib/workspaces/registry.rb
|
|
94
95
|
- lib/workspaces/state_store.rb
|
|
96
|
+
- lib/workspaces/step_log.rb
|
|
95
97
|
- lib/workspaces/tee_log.rb
|
|
96
98
|
- lib/workspaces/tls.rb
|
|
97
99
|
- lib/workspaces/version.rb
|