unfold-deploy 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +150 -2
- package/bin/unfold.js +14 -0
- package/demos/claude-success.json +72 -0
- package/demos/codex-checks-failing.json +62 -0
- package/demos/gcloud-deploy-failure.json +616 -0
- package/demos/gcloud-success.json +608 -0
- package/demos/vercel-build-failure.json +331 -0
- package/demos/vercel-interrupted.json +271 -0
- package/demos/vercel-success.json +343 -0
- package/dist/cli.js +121 -0
- package/dist/commands/demo.js +75 -0
- package/dist/commands/observe.js +137 -0
- package/dist/commands/replay.js +76 -0
- package/dist/commands/run.js +24 -0
- package/dist/commands/show.js +44 -0
- package/dist/core/errors.js +3 -0
- package/dist/core/fix-prompt.js +85 -0
- package/dist/core/flow.js +211 -0
- package/dist/core/launch.js +196 -0
- package/dist/core/model.js +3 -0
- package/dist/core/redact.js +79 -0
- package/dist/core/server.js +109 -0
- package/dist/core/session.js +136 -0
- package/dist/core/store.js +52 -0
- package/dist/core/terminal.js +109 -0
- package/dist/providers/agents/claude.js +205 -0
- package/dist/providers/agents/codex.js +152 -0
- package/dist/providers/agents/shared.js +119 -0
- package/dist/providers/gcloud/index.js +39 -0
- package/dist/providers/gcloud/parser.js +149 -0
- package/dist/providers/gcloud/stages.js +55 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/provider.js +1 -0
- package/dist/providers/vercel/index.js +56 -0
- package/dist/providers/vercel/parser.js +239 -0
- package/dist/providers/vercel/stages.js +54 -0
- package/dist/tui/ansi.js +71 -0
- package/dist/tui/explore.js +220 -0
- package/dist/tui/panel.js +118 -0
- package/dist/tui/render.js +140 -0
- package/dist/tui/restore.js +29 -0
- package/dist/ui/app.js +332 -0
- package/dist/ui/index.html +60 -0
- package/dist/ui/styles.css +220 -0
- package/package.json +64 -4
- package/scripts/postinstall.mjs +13 -0
package/dist/ui/app.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
function h(tag, attrs = {}, ...children) {
|
|
2
|
+
const el = document.createElement(tag);
|
|
3
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
4
|
+
if (typeof v === 'function')
|
|
5
|
+
el.addEventListener(k.slice(2), v);
|
|
6
|
+
else if (v === true)
|
|
7
|
+
el.setAttribute(k, '');
|
|
8
|
+
else if (v !== false)
|
|
9
|
+
el.setAttribute(k, v);
|
|
10
|
+
}
|
|
11
|
+
for (const c of children)
|
|
12
|
+
if (c !== null && c !== undefined && c !== false)
|
|
13
|
+
el.append(c);
|
|
14
|
+
return el;
|
|
15
|
+
}
|
|
16
|
+
function fill(el, ...children) {
|
|
17
|
+
el.replaceChildren(...children.filter((c) => c !== null && c !== undefined && c !== false));
|
|
18
|
+
}
|
|
19
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
20
|
+
function icon(path) {
|
|
21
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
22
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
23
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
24
|
+
const p = document.createElementNS(SVG_NS, 'path');
|
|
25
|
+
p.setAttribute('d', path);
|
|
26
|
+
svg.append(p);
|
|
27
|
+
return svg;
|
|
28
|
+
}
|
|
29
|
+
const ICONS = {
|
|
30
|
+
check: 'M5 12.5l4.5 4.5L19 7.5',
|
|
31
|
+
x: 'M6 6l12 12M18 6L6 18',
|
|
32
|
+
eye: 'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zm10 3a3 3 0 100-6 3 3 0 000 6z',
|
|
33
|
+
book: 'M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2zm0 0v16M8 7h7',
|
|
34
|
+
ext: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5',
|
|
35
|
+
};
|
|
36
|
+
const STATUS_LABEL = {
|
|
37
|
+
pending: 'Waiting',
|
|
38
|
+
active: 'In progress',
|
|
39
|
+
confirmed: 'Confirmed',
|
|
40
|
+
failed: 'Failed',
|
|
41
|
+
unverified: 'Unverified',
|
|
42
|
+
};
|
|
43
|
+
const $ = (id) => document.getElementById(id);
|
|
44
|
+
let state;
|
|
45
|
+
const logs = new Map();
|
|
46
|
+
const logEls = new Map();
|
|
47
|
+
let selected; // stage id chosen by the user; undefined = follow the run
|
|
48
|
+
let logsOpen = false;
|
|
49
|
+
let fixAgent = 'claude';
|
|
50
|
+
let fixPromptOpen = false;
|
|
51
|
+
// ---------- rendering ----------
|
|
52
|
+
function rel(at) {
|
|
53
|
+
if (!state)
|
|
54
|
+
return '';
|
|
55
|
+
const s = Math.max(0, (at - state.startedAt) / 1000);
|
|
56
|
+
return s < 60 ? `+${s.toFixed(1)}s` : `+${Math.floor(s / 60)}m${Math.round(s % 60)}s`;
|
|
57
|
+
}
|
|
58
|
+
function autoStage(s) {
|
|
59
|
+
if (s.failedStage)
|
|
60
|
+
return s.failedStage;
|
|
61
|
+
const active = [...s.stages].reverse().find((st) => st.status === 'active');
|
|
62
|
+
if (active)
|
|
63
|
+
return active.id;
|
|
64
|
+
if (s.outcome === 'success')
|
|
65
|
+
return s.stages.at(-1).id;
|
|
66
|
+
if (s.outcome === 'interrupted') {
|
|
67
|
+
// Show where the run stopped: the first stage after the last confirmed one.
|
|
68
|
+
const i = s.stages.findIndex((st) => st.id === s.lastConfirmed);
|
|
69
|
+
return (s.stages[i + 1] ?? s.stages[0]).id;
|
|
70
|
+
}
|
|
71
|
+
return s.lastConfirmed ?? s.stages[0].id;
|
|
72
|
+
}
|
|
73
|
+
function stageIcon(st) {
|
|
74
|
+
const el = h('span', { class: `icon ${st.status}` });
|
|
75
|
+
if (st.status === 'confirmed')
|
|
76
|
+
el.append(icon(ICONS.check));
|
|
77
|
+
else if (st.status === 'failed')
|
|
78
|
+
el.append(icon(ICONS.x));
|
|
79
|
+
else if (st.status === 'unverified')
|
|
80
|
+
el.append('?');
|
|
81
|
+
return el;
|
|
82
|
+
}
|
|
83
|
+
function renderHeader(s) {
|
|
84
|
+
$('cmd').textContent = s.command.join(' ');
|
|
85
|
+
$('cmd').title = `${s.command.join(' ')}\nin ${s.cwd}`;
|
|
86
|
+
const mode = $('mode');
|
|
87
|
+
mode.textContent = s.mode === 'demo' ? 'Demo replay' : s.mode === 'replay' ? 'Saved run' : 'Live';
|
|
88
|
+
mode.className = `badge ${s.mode}`;
|
|
89
|
+
const status = $('status');
|
|
90
|
+
status.className = `pill ${s.outcome}`;
|
|
91
|
+
status.textContent = { running: 'Running', success: s.stages.at(-1)?.status === 'confirmed' ? (s.notes?.success ?? 'Done') : 'Exited 0', failure: 'Failed', interrupted: 'Stopped' }[s.outcome];
|
|
92
|
+
$('saved').replaceChildren(...(s.savedTo ? ['Saved to ', h('code', {}, s.savedTo)] : s.mode === 'demo' ? ['Demo data: recorded output, nothing is sent anywhere.'] : []));
|
|
93
|
+
document.title = `${status.textContent} · Unfold`;
|
|
94
|
+
}
|
|
95
|
+
function renderElapsed() {
|
|
96
|
+
if (!state)
|
|
97
|
+
return;
|
|
98
|
+
const end = state.endedAt ?? Date.now();
|
|
99
|
+
const s = Math.max(0, (end - state.startedAt) / 1000);
|
|
100
|
+
$('elapsed').textContent = s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
|
|
101
|
+
}
|
|
102
|
+
function fact(s, key) {
|
|
103
|
+
return s.facts.find((f) => f.key === key);
|
|
104
|
+
}
|
|
105
|
+
function primaryError(s) {
|
|
106
|
+
return s.errors.find((e) => e.severity === 'fatal') ?? s.errors.at(-1);
|
|
107
|
+
}
|
|
108
|
+
function causes(s, stage) {
|
|
109
|
+
const fatal = primaryError(s);
|
|
110
|
+
return s.errors
|
|
111
|
+
.filter((e) => e.severity === 'cause' && (!stage || e.stage === stage) && e.message !== fatal?.message)
|
|
112
|
+
.sort((a, b) => (b.priority ?? 1) - (a.priority ?? 1) || a.seq - b.seq)
|
|
113
|
+
.slice(0, 6);
|
|
114
|
+
}
|
|
115
|
+
function extLink(href, label) {
|
|
116
|
+
const a = h('a', { class: 'btn', href, target: '_blank', rel: 'noreferrer noopener' }, label);
|
|
117
|
+
a.append(icon(ICONS.ext));
|
|
118
|
+
a.querySelector('svg').setAttribute('style', 'width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:2');
|
|
119
|
+
return a;
|
|
120
|
+
}
|
|
121
|
+
function renderBanner(s) {
|
|
122
|
+
const el = $('banner');
|
|
123
|
+
const title = (id) => s.stages.find((st) => st.id === id)?.title ?? 'none';
|
|
124
|
+
const inspect = fact(s, 'inspectUrl') ?? fact(s, 'logsUrl');
|
|
125
|
+
if (s.outcome === 'running') {
|
|
126
|
+
el.hidden = true;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
el.hidden = false;
|
|
130
|
+
el.className = `banner ${s.outcome}`;
|
|
131
|
+
if (s.outcome === 'failure') {
|
|
132
|
+
const err = primaryError(s);
|
|
133
|
+
const cause = causes(s)[0];
|
|
134
|
+
fill(el, h('h2', {}, `Failed during ${title(s.failedStage)}`), h('p', {}, `Last confirmed stage: ${title(s.lastConfirmed)}. Exit code ${s.exitCode ?? s.exitSignal ?? 'unknown'}.`), err ? h('div', { class: 'err' }, err.message) : h('p', {}, 'The command failed without printing a recognizable error. Check the logs.'), cause ? h('p', {}, 'Most relevant output: ', h('code', {}, cause.message)) : null, h('div', { class: 'actions' }, h('button', { class: 'btn primary', onclick: () => jumpTo((cause ?? err)?.seq) }, 'Jump to the error in the logs'), h('button', { class: 'btn', onclick: () => select(s.failedStage) }, `Open the ${title(s.failedStage)} stage`), inspect && inspect.href ? extLink(inspect.href, `Open ${inspect.label}`) : null), s.fix ? fixPanel(s) : null);
|
|
135
|
+
}
|
|
136
|
+
else if (s.outcome === 'success') {
|
|
137
|
+
const url = fact(s, 'alias') ?? fact(s, 'deploymentUrl');
|
|
138
|
+
const live = s.stages.at(-1)?.status === 'confirmed';
|
|
139
|
+
fill(el, h('h2', {}, live ? (s.notes?.successHeadline ?? 'Finished') : 'Command finished, but its output never confirmed the last stage'), live ? null : h('p', {}, "The command exited with code 0, but its output never confirmed the deployment went live."), url?.href ? h('a', { class: 'live-url', href: url.href, target: '_blank', rel: 'noreferrer noopener' }, url.value) : h('p', {}, 'The command succeeded.'), fact(s, 'protection') ? h('p', {}, 'Deployment Protection is on, so visitors may need to sign in to Vercel.') : null, h('div', { class: 'actions' }, url?.value ? h('button', { class: 'btn', onclick: (e) => copy(url.value, e.currentTarget) }, 'Copy URL') : null, inspect?.href ? extLink(inspect.href, `Open ${inspect.label}`) : null));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
fill(el, h('h2', {}, 'Stopped with Ctrl+C'), h('p', {}, `Last confirmed stage: ${title(s.lastConfirmed)}. ${s.stages.find((st) => st.status === 'unverified' && st.note)?.note ?? ''}`), h('div', { class: 'actions' }, inspect?.href ? extLink(inspect.href, `Open ${inspect.label}`) : null, h('button', { class: 'btn', onclick: () => openLogs(true) }, 'View logs')));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** A ready-made prompt so a coding agent can pick up the failure. */
|
|
146
|
+
function fixPanel(s) {
|
|
147
|
+
const fix = s.fix;
|
|
148
|
+
const name = fixAgent === 'claude' ? 'Claude Code' : 'Codex';
|
|
149
|
+
const command = fix.commands?.[fixAgent];
|
|
150
|
+
const seg = (agent, label) => h('button', { class: 'seg-btn', 'aria-pressed': String(fixAgent === agent), onclick: () => ((fixAgent = agent), render()) }, label);
|
|
151
|
+
return h('div', { class: 'fix' }, h('div', { class: 'fix-head' }, h('strong', {}, 'Fix it with an AI agent'), h('span', { class: 'seg', role: 'group', 'aria-label': 'Agent' }, seg('claude', 'Claude Code'), seg('codex', 'Codex'))), h('p', {}, 'Unfold wrote a prompt from this run: the error, the likely cause, a redacted log excerpt and hints about this stage. ', command ? `Run this in your project folder to start ${name} with it:` : `Copy it into ${name}.`), command ? h('div', { class: 'cmdline' }, h('code', {}, command), h('button', { class: 'btn', onclick: (e) => copy(command, e.currentTarget, 'Copy command') }, 'Copy command')) : null, h('div', { class: 'actions' }, h('button', { class: 'btn', onclick: (e) => copy(fix.prompt, e.currentTarget, 'Copy prompt') }, 'Copy prompt'), h('button', { class: 'btn', 'aria-expanded': String(fixPromptOpen), onclick: () => ((fixPromptOpen = !fixPromptOpen), render()) }, fixPromptOpen ? 'Hide prompt' : 'Show prompt')), fixPromptOpen ? h('pre', { class: 'prompt' }, fix.prompt) : null, h('p', { class: 'fine' }, 'Tip: ', h('code', {}, `unfold vercel --fix ${fixAgent}`), ` starts ${name} automatically when a deploy fails.`));
|
|
152
|
+
}
|
|
153
|
+
function renderFlow(s) {
|
|
154
|
+
const nav = $('flow');
|
|
155
|
+
nav.style.setProperty('--n', String(s.stages.length));
|
|
156
|
+
const current = selected ?? autoStage(s);
|
|
157
|
+
nav.replaceChildren(...s.stages.map((st, i) => h('button', {
|
|
158
|
+
class: `stage ${st.status}${i > 0 && s.stages[i - 1].status === 'confirmed' ? ' prev-confirmed' : ''}`,
|
|
159
|
+
role: 'tab',
|
|
160
|
+
id: `tab-${st.id}`,
|
|
161
|
+
'aria-selected': String(st.id === current),
|
|
162
|
+
'aria-controls': 'detail',
|
|
163
|
+
tabindex: st.id === current ? '0' : '-1',
|
|
164
|
+
onclick: () => select(st.id),
|
|
165
|
+
}, h('span', { class: 'stage-head' }, stageIcon(st), h('span', { class: 'stage-title' }, st.title), h('span', { class: 'stage-num' }, `${i + 1}/${s.stages.length}`)), h('span', { class: 'stage-status' }, STATUS_LABEL[st.status]), h('span', { class: 'stage-note' }, st.note ?? st.summary))));
|
|
166
|
+
}
|
|
167
|
+
// Build log lines carry an ISO timestamp; the relative time column already says when.
|
|
168
|
+
const stripStamp = (text) => text.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*/, '');
|
|
169
|
+
function evidenceItem(seq, at, note, rawText) {
|
|
170
|
+
const text = stripStamp(rawText);
|
|
171
|
+
return h('li', {}, h('span', { class: 'ev-time' }, rel(at)), h('span', { class: 'ev-note' }, note), h('span', { class: 'ev-line' }, h('code', { title: text }, text), h('button', { class: 'jump', onclick: () => jumpTo(seq) }, `line ${seq} →`)));
|
|
172
|
+
}
|
|
173
|
+
function renderDetail(s) {
|
|
174
|
+
const id = selected ?? autoStage(s);
|
|
175
|
+
const st = s.stages.find((x) => x.id === id) ?? s.stages[0];
|
|
176
|
+
const facts = s.facts.filter((f) => f.stage === st.id);
|
|
177
|
+
const stageCauses = st.status === 'failed' ? causes(s, st.id) : [];
|
|
178
|
+
const fatal = st.status === 'failed' ? s.errors.find((e) => e.severity === 'fatal' && e.stage === st.id) : undefined;
|
|
179
|
+
const statusText = st.status === 'pending' ? 'Not started yet. Nothing about this stage has appeared in the output.' : st.note ?? STATUS_LABEL[st.status];
|
|
180
|
+
const observed = h('div', { class: 'card observed' }, h('div', { class: 'kicker observed' }, icon(ICONS.eye), "Observed in this run"), h('h3', {}, stageIcon(st), st.title, h('span', { class: 'stage-status' }, STATUS_LABEL[st.status])), h('div', { class: `status-line ${st.status}` }, statusText), fatal ? h('div', { class: 'causes' }, h('div', { class: 'sub' }, 'Error'), h('span', { class: 'ev-line' }, h('code', { title: fatal.message }, fatal.message), h('button', { class: 'jump', onclick: () => jumpTo(fatal.seq) }, `line ${fatal.seq} →`))) : null, stageCauses.length
|
|
181
|
+
? h('div', { class: 'causes' }, h('div', { class: 'sub' }, 'Lines that look like the cause'), ...stageCauses.map((c) => h('span', { class: 'ev-line' }, h('code', { title: c.message }, c.message), h('button', { class: 'jump', onclick: () => jumpTo(c.seq) }, `line ${c.seq} →`))))
|
|
182
|
+
: null, facts.length
|
|
183
|
+
? h('dl', { class: 'facts' }, ...facts.flatMap((f) => [h('dt', {}, f.label), h('dd', {}, f.href ? h('a', { href: f.href, target: '_blank', rel: 'noreferrer noopener' }, f.value) : f.value)]))
|
|
184
|
+
: null, st.evidence.length
|
|
185
|
+
? h('div', { class: 'sub' }, 'Evidence from the output')
|
|
186
|
+
: null, st.evidence.length
|
|
187
|
+
? h('ul', { class: 'evidence' }, ...st.evidence.map((e) => evidenceItem(e.seq, e.at, e.note, logs.get(e.seq)?.text ?? e.text)))
|
|
188
|
+
: h('p', { class: 'empty' }, 'No output from this run relates to this stage.'));
|
|
189
|
+
const explain = h('div', { class: 'card explain' }, h('div', { class: 'kicker' }, icon(ICONS.book), 'How this stage works · general, not from this run'), h('p', { class: 'summary' }, st.summary), ...st.explanation.map((p) => h('p', {}, p)), h('p', { class: 'confirm' }, h('b', {}, 'What would confirm it: '), st.confirmedBy), st.docsUrl ? h('a', { href: st.docsUrl, target: '_blank', rel: 'noreferrer noopener' }, s.notes?.docsLabel ?? 'Docs') : null);
|
|
190
|
+
$('detail').setAttribute('aria-labelledby', `tab-${st.id}`);
|
|
191
|
+
$('detail').replaceChildren(observed, explain);
|
|
192
|
+
}
|
|
193
|
+
function render() {
|
|
194
|
+
if (!state)
|
|
195
|
+
return;
|
|
196
|
+
renderHeader(state);
|
|
197
|
+
renderBanner(state);
|
|
198
|
+
renderFlow(state);
|
|
199
|
+
renderDetail(state);
|
|
200
|
+
renderElapsed();
|
|
201
|
+
markLogLines();
|
|
202
|
+
}
|
|
203
|
+
// ---------- logs ----------
|
|
204
|
+
function evidenceSeqs() {
|
|
205
|
+
const ev = new Set();
|
|
206
|
+
const err = new Set();
|
|
207
|
+
state?.stages.forEach((st) => st.evidence.forEach((e) => ev.add(e.seq)));
|
|
208
|
+
state?.errors.forEach((e) => err.add(e.seq));
|
|
209
|
+
return { ev, err };
|
|
210
|
+
}
|
|
211
|
+
function markLogLines() {
|
|
212
|
+
const { ev, err } = evidenceSeqs();
|
|
213
|
+
for (const [seq, li] of logEls) {
|
|
214
|
+
li.classList.toggle('ev', ev.has(seq) && !err.has(seq));
|
|
215
|
+
li.classList.toggle('err', err.has(seq));
|
|
216
|
+
}
|
|
217
|
+
applyFilter();
|
|
218
|
+
}
|
|
219
|
+
function upsertLogs(lines) {
|
|
220
|
+
const list = $('log-lines');
|
|
221
|
+
const atBottom = list.scrollTop + list.clientHeight >= list.scrollHeight - 30;
|
|
222
|
+
for (const line of lines) {
|
|
223
|
+
logs.set(line.seq, line);
|
|
224
|
+
let li = logEls.get(line.seq);
|
|
225
|
+
if (!li) {
|
|
226
|
+
li = h('li', { id: `L${line.seq}` }, h('span', { class: 'ln' }, String(line.seq)), h('span', { class: 'lt' }), h('span', { class: 'tx' }));
|
|
227
|
+
logEls.set(line.seq, li);
|
|
228
|
+
list.append(li);
|
|
229
|
+
}
|
|
230
|
+
li.querySelector('.lt').textContent = rel(line.at);
|
|
231
|
+
li.querySelector('.tx').textContent = line.text;
|
|
232
|
+
}
|
|
233
|
+
$('log-count').textContent = `${logs.size} lines`;
|
|
234
|
+
if ($('follow').checked && atBottom)
|
|
235
|
+
list.scrollTop = list.scrollHeight;
|
|
236
|
+
}
|
|
237
|
+
function applyFilter() {
|
|
238
|
+
const q = $('log-filter').value.toLowerCase();
|
|
239
|
+
const errorsOnly = $('errors-only').checked;
|
|
240
|
+
for (const [seq, li] of logEls) {
|
|
241
|
+
const text = logs.get(seq)?.text.toLowerCase() ?? '';
|
|
242
|
+
li.hidden = (q !== '' && !text.includes(q)) || (errorsOnly && !li.classList.contains('err'));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function openLogs(open) {
|
|
246
|
+
logsOpen = open;
|
|
247
|
+
$('log-lines').hidden = !open;
|
|
248
|
+
$('logs-toggle').setAttribute('aria-expanded', String(open));
|
|
249
|
+
}
|
|
250
|
+
function jumpTo(seq) {
|
|
251
|
+
openLogs(true);
|
|
252
|
+
$('log-filter').value = '';
|
|
253
|
+
$('errors-only').checked = false;
|
|
254
|
+
$('follow').checked = false;
|
|
255
|
+
applyFilter();
|
|
256
|
+
const li = seq !== undefined ? logEls.get(seq) : undefined;
|
|
257
|
+
$('logs').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
258
|
+
if (!li)
|
|
259
|
+
return;
|
|
260
|
+
const list = $('log-lines');
|
|
261
|
+
list.scrollTop = li.offsetTop - list.offsetTop - list.clientHeight / 3;
|
|
262
|
+
li.classList.remove('flash');
|
|
263
|
+
void li.offsetWidth;
|
|
264
|
+
li.classList.add('flash');
|
|
265
|
+
}
|
|
266
|
+
// ---------- interaction ----------
|
|
267
|
+
function select(id) {
|
|
268
|
+
selected = id;
|
|
269
|
+
render();
|
|
270
|
+
if (id)
|
|
271
|
+
document.getElementById(`tab-${id}`)?.focus();
|
|
272
|
+
}
|
|
273
|
+
async function copy(text, btn, label = 'Copy URL') {
|
|
274
|
+
try {
|
|
275
|
+
await navigator.clipboard.writeText(text);
|
|
276
|
+
btn.textContent = 'Copied';
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
btn.textContent = 'Copy failed';
|
|
280
|
+
}
|
|
281
|
+
setTimeout(() => (btn.textContent = label), 1500);
|
|
282
|
+
}
|
|
283
|
+
$('flow').addEventListener('keydown', (e) => {
|
|
284
|
+
if (!state || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key))
|
|
285
|
+
return;
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
const ids = state.stages.map((s) => s.id);
|
|
288
|
+
const i = ids.indexOf(selected ?? autoStage(state));
|
|
289
|
+
const next = e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? Math.max(0, i - 1) : Math.min(ids.length - 1, i + 1);
|
|
290
|
+
select(ids[next]);
|
|
291
|
+
});
|
|
292
|
+
$('logs-toggle').addEventListener('click', () => openLogs(!logsOpen));
|
|
293
|
+
$('log-filter').addEventListener('input', applyFilter);
|
|
294
|
+
$('errors-only').addEventListener('change', applyFilter);
|
|
295
|
+
document.addEventListener('keydown', (e) => {
|
|
296
|
+
if (e.key === 'Escape' && selected) {
|
|
297
|
+
selected = undefined; // go back to following the run
|
|
298
|
+
render();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
// ---------- connection ----------
|
|
302
|
+
function reset() {
|
|
303
|
+
logs.clear();
|
|
304
|
+
logEls.clear();
|
|
305
|
+
$('log-lines').replaceChildren();
|
|
306
|
+
selected = undefined;
|
|
307
|
+
}
|
|
308
|
+
const es = new EventSource('/api/events');
|
|
309
|
+
es.addEventListener('snapshot', (e) => {
|
|
310
|
+
const data = JSON.parse(e.data);
|
|
311
|
+
reset();
|
|
312
|
+
state = data.state;
|
|
313
|
+
upsertLogs(data.logs);
|
|
314
|
+
if (state.outcome === 'failure')
|
|
315
|
+
openLogs(true);
|
|
316
|
+
render();
|
|
317
|
+
});
|
|
318
|
+
es.addEventListener('reset', () => reset());
|
|
319
|
+
es.addEventListener('logs', (e) => upsertLogs(JSON.parse(e.data)));
|
|
320
|
+
es.addEventListener('state', (e) => {
|
|
321
|
+
const prev = state?.outcome;
|
|
322
|
+
state = JSON.parse(e.data);
|
|
323
|
+
if (prev === 'running' && state.outcome === 'failure')
|
|
324
|
+
openLogs(true);
|
|
325
|
+
render();
|
|
326
|
+
});
|
|
327
|
+
es.addEventListener('error', () => {
|
|
328
|
+
if (state && state.outcome !== 'running')
|
|
329
|
+
es.close(); // the run is over and Unfold exited; keep the last view
|
|
330
|
+
});
|
|
331
|
+
setInterval(() => state?.outcome === 'running' && renderElapsed(), 250);
|
|
332
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Unfold</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="top">
|
|
11
|
+
<div class="brand">
|
|
12
|
+
<svg class="mark" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h6l2 3h8M4 12h10l2 3h4M4 18h14" /></svg>
|
|
13
|
+
<span class="name">Unfold</span>
|
|
14
|
+
<span class="tagline">Watch what happens after you hit Enter.</span>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="run-meta">
|
|
17
|
+
<span id="mode" class="badge"></span>
|
|
18
|
+
<code id="cmd" class="cmd"></code>
|
|
19
|
+
<span id="status" class="pill"></span>
|
|
20
|
+
<span id="elapsed" class="elapsed"></span>
|
|
21
|
+
</div>
|
|
22
|
+
</header>
|
|
23
|
+
|
|
24
|
+
<main>
|
|
25
|
+
<section id="banner" class="banner" hidden></section>
|
|
26
|
+
|
|
27
|
+
<section class="flow-wrap" aria-label="Deploy flow">
|
|
28
|
+
<nav id="flow" class="flow" role="tablist"></nav>
|
|
29
|
+
<div class="legend">
|
|
30
|
+
<span><i class="dot confirmed"></i>Confirmed by this run's output</span>
|
|
31
|
+
<span><i class="dot active"></i>In progress</span>
|
|
32
|
+
<span><i class="dot failed"></i>Failed</span>
|
|
33
|
+
<span><i class="dot unverified"></i>Unverified: output didn't confirm it</span>
|
|
34
|
+
</div>
|
|
35
|
+
</section>
|
|
36
|
+
|
|
37
|
+
<section id="detail" class="detail" role="tabpanel"></section>
|
|
38
|
+
|
|
39
|
+
<section id="logs" class="logs">
|
|
40
|
+
<div class="logs-bar">
|
|
41
|
+
<button id="logs-toggle" class="logs-toggle" aria-expanded="false">
|
|
42
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
|
|
43
|
+
Logs <span id="log-count" class="count"></span>
|
|
44
|
+
</button>
|
|
45
|
+
<input id="log-filter" type="search" placeholder="Filter lines" aria-label="Filter log lines" />
|
|
46
|
+
<label class="check"><input id="errors-only" type="checkbox" /> Errors only</label>
|
|
47
|
+
<label class="check"><input id="follow" type="checkbox" checked /> Follow</label>
|
|
48
|
+
</div>
|
|
49
|
+
<ol id="log-lines" class="log-lines" hidden></ol>
|
|
50
|
+
</section>
|
|
51
|
+
</main>
|
|
52
|
+
|
|
53
|
+
<footer class="foot">
|
|
54
|
+
<span>Captured locally. Secrets are redacted before anything is shown or saved. Environment variables are never recorded.</span>
|
|
55
|
+
<span id="saved"></span>
|
|
56
|
+
</footer>
|
|
57
|
+
|
|
58
|
+
<script type="module" src="/app.js"></script>
|
|
59
|
+
</body>
|
|
60
|
+
</html>
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #fafafa;
|
|
3
|
+
--surface: #ffffff;
|
|
4
|
+
--surface-2: #f4f4f5;
|
|
5
|
+
--border: #e4e4e7;
|
|
6
|
+
--text: #18181b;
|
|
7
|
+
--muted: #71717a;
|
|
8
|
+
--faint: #a1a1aa;
|
|
9
|
+
--accent: #2563eb;
|
|
10
|
+
--accent-soft: #dbeafe;
|
|
11
|
+
--ok: #16a34a;
|
|
12
|
+
--ok-soft: #dcfce7;
|
|
13
|
+
--bad: #dc2626;
|
|
14
|
+
--bad-soft: #fee2e2;
|
|
15
|
+
--warn: #b45309;
|
|
16
|
+
--warn-soft: #fef3c7;
|
|
17
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
18
|
+
--sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
19
|
+
--radius: 10px;
|
|
20
|
+
color-scheme: light;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@media (prefers-color-scheme: dark) {
|
|
24
|
+
:root {
|
|
25
|
+
--bg: #0b0b0d;
|
|
26
|
+
--surface: #141417;
|
|
27
|
+
--surface-2: #1c1c20;
|
|
28
|
+
--border: #2a2a30;
|
|
29
|
+
--text: #f4f4f5;
|
|
30
|
+
--muted: #a1a1aa;
|
|
31
|
+
--faint: #71717a;
|
|
32
|
+
--accent: #60a5fa;
|
|
33
|
+
--accent-soft: #172554;
|
|
34
|
+
--ok: #4ade80;
|
|
35
|
+
--ok-soft: #052e16;
|
|
36
|
+
--bad: #f87171;
|
|
37
|
+
--bad-soft: #3b0a0a;
|
|
38
|
+
--warn: #fbbf24;
|
|
39
|
+
--warn-soft: #3a2a05;
|
|
40
|
+
color-scheme: dark;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
* { box-sizing: border-box; }
|
|
45
|
+
html, body { margin: 0; }
|
|
46
|
+
body {
|
|
47
|
+
background: var(--bg);
|
|
48
|
+
color: var(--text);
|
|
49
|
+
font: 14px/1.5 var(--sans);
|
|
50
|
+
min-height: 100vh;
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
}
|
|
54
|
+
a { color: var(--accent); }
|
|
55
|
+
code, .mono { font-family: var(--mono); font-size: 12.5px; }
|
|
56
|
+
button { font: inherit; color: inherit; }
|
|
57
|
+
[hidden] { display: none !important; }
|
|
58
|
+
|
|
59
|
+
/* Header */
|
|
60
|
+
.top {
|
|
61
|
+
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
|
62
|
+
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--surface);
|
|
63
|
+
}
|
|
64
|
+
.brand { display: flex; align-items: baseline; gap: 10px; }
|
|
65
|
+
.mark { width: 18px; height: 18px; align-self: center; fill: none; stroke: var(--text); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
66
|
+
.name { font-weight: 650; letter-spacing: -0.01em; }
|
|
67
|
+
.tagline { color: var(--muted); font-size: 13px; }
|
|
68
|
+
.run-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-width: 0; }
|
|
69
|
+
.cmd { background: var(--surface-2); border: 1px solid var(--border); padding: 3px 8px; border-radius: 6px; max-width: 46ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
70
|
+
.cmd::before { content: '$ '; color: var(--faint); }
|
|
71
|
+
.badge { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; }
|
|
72
|
+
.badge.demo { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
|
|
73
|
+
.pill { font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 999px; background: var(--surface-2); }
|
|
74
|
+
.pill.running { background: var(--accent-soft); color: var(--accent); }
|
|
75
|
+
.pill.success { background: var(--ok-soft); color: var(--ok); }
|
|
76
|
+
.pill.failure { background: var(--bad-soft); color: var(--bad); }
|
|
77
|
+
.pill.interrupted { background: var(--warn-soft); color: var(--warn); }
|
|
78
|
+
.elapsed { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
|
|
79
|
+
|
|
80
|
+
main { width: 100%; max-width: 1120px; margin: 0 auto; padding: 24px; display: flex; flex-direction: column; gap: 20px; flex: 1; }
|
|
81
|
+
|
|
82
|
+
/* Banner */
|
|
83
|
+
.banner { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; background: var(--surface); display: grid; gap: 8px; }
|
|
84
|
+
.banner h2 { margin: 0; font-size: 16px; }
|
|
85
|
+
.banner p { margin: 0; color: var(--muted); }
|
|
86
|
+
.banner.failure { border-color: color-mix(in srgb, var(--bad) 45%, var(--border)); background: color-mix(in srgb, var(--bad-soft) 55%, var(--surface)); }
|
|
87
|
+
.banner.success { border-color: color-mix(in srgb, var(--ok) 40%, var(--border)); background: color-mix(in srgb, var(--ok-soft) 50%, var(--surface)); }
|
|
88
|
+
.banner.interrupted { border-color: color-mix(in srgb, var(--warn) 45%, var(--border)); background: color-mix(in srgb, var(--warn-soft) 55%, var(--surface)); }
|
|
89
|
+
.banner .err { font-family: var(--mono); font-size: 12.5px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; white-space: pre-wrap; word-break: break-word; }
|
|
90
|
+
.banner .live-url { font-family: var(--mono); font-size: 14px; word-break: break-all; }
|
|
91
|
+
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
|
|
92
|
+
.btn { border: 1px solid var(--border); background: var(--surface); padding: 5px 12px; border-radius: 7px; cursor: pointer; font-size: 13px; text-decoration: none; color: var(--text); display: inline-flex; align-items: center; gap: 6px; }
|
|
93
|
+
.btn:hover { border-color: var(--faint); }
|
|
94
|
+
.btn.primary { background: var(--text); color: var(--bg); border-color: var(--text); }
|
|
95
|
+
|
|
96
|
+
/* Fix with an agent */
|
|
97
|
+
.fix { margin-top: 6px; border-top: 1px solid color-mix(in srgb, var(--bad) 25%, var(--border)); padding-top: 12px; display: grid; gap: 8px; }
|
|
98
|
+
.fix-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
99
|
+
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); }
|
|
100
|
+
.seg-btn { border: 0; background: none; padding: 4px 12px; font-size: 13px; cursor: pointer; color: var(--muted); }
|
|
101
|
+
.seg-btn[aria-pressed='true'] { background: var(--text); color: var(--bg); }
|
|
102
|
+
.cmdline { display: flex; gap: 8px; align-items: center; min-width: 0; }
|
|
103
|
+
.cmdline code { flex: 1; min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; overflow-x: auto; white-space: nowrap; }
|
|
104
|
+
.prompt { margin: 0; max-height: 320px; overflow: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; }
|
|
105
|
+
.fine { font-size: 12.5px; }
|
|
106
|
+
.fine code { font-size: 12px; }
|
|
107
|
+
|
|
108
|
+
/* Flow */
|
|
109
|
+
.flow-wrap { display: grid; gap: 10px; }
|
|
110
|
+
.flow { display: grid; grid-template-columns: repeat(var(--n, 4), 1fr); gap: 0; }
|
|
111
|
+
.stage {
|
|
112
|
+
position: relative; text-align: left; border: 1px solid var(--border); background: var(--surface);
|
|
113
|
+
border-radius: var(--radius); padding: 14px 14px 12px; cursor: pointer; margin: 0 10px; min-width: 0;
|
|
114
|
+
display: grid; gap: 6px; transition: border-color .15s, box-shadow .15s, transform .15s;
|
|
115
|
+
}
|
|
116
|
+
.stage:first-child { margin-left: 0; }
|
|
117
|
+
.stage:last-child { margin-right: 0; }
|
|
118
|
+
.stage:hover { border-color: var(--faint); }
|
|
119
|
+
.stage:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
120
|
+
.stage[aria-selected='true'] { border-color: var(--text); box-shadow: 0 1px 0 var(--text), 0 6px 20px -12px rgb(0 0 0 / .35); }
|
|
121
|
+
.stage + .stage::before {
|
|
122
|
+
content: ''; position: absolute; left: -21px; top: 27px; width: 20px; height: 2px; background: var(--border);
|
|
123
|
+
}
|
|
124
|
+
.stage.prev-confirmed::before { background: var(--ok); }
|
|
125
|
+
.stage-head { display: flex; align-items: center; gap: 10px; }
|
|
126
|
+
.stage-num { color: var(--faint); font-size: 12px; font-variant-numeric: tabular-nums; margin-left: auto; }
|
|
127
|
+
.stage-title { font-weight: 600; }
|
|
128
|
+
.stage-note { color: var(--muted); font-size: 12.5px; line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; min-height: 2.8em; }
|
|
129
|
+
.stage-status { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
130
|
+
.stage.confirmed .stage-status { color: var(--ok); }
|
|
131
|
+
.stage.failed .stage-status { color: var(--bad); }
|
|
132
|
+
.stage.active .stage-status { color: var(--accent); }
|
|
133
|
+
.stage.unverified .stage-status { color: var(--warn); }
|
|
134
|
+
.stage.failed { border-color: color-mix(in srgb, var(--bad) 50%, var(--border)); }
|
|
135
|
+
.stage.unverified { border-style: dashed; }
|
|
136
|
+
|
|
137
|
+
.icon { width: 22px; height: 22px; flex: none; border-radius: 50%; display: grid; place-items: center; border: 2px solid var(--border); }
|
|
138
|
+
.icon svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
|
139
|
+
.icon.confirmed { background: var(--ok); border-color: var(--ok); color: var(--surface); }
|
|
140
|
+
.icon.failed { background: var(--bad); border-color: var(--bad); color: var(--surface); }
|
|
141
|
+
.icon.active { border-color: var(--accent); border-top-color: transparent; animation: spin 0.9s linear infinite; }
|
|
142
|
+
.icon.unverified { border-style: dashed; border-color: var(--warn); color: var(--warn); font-size: 12px; font-weight: 700; }
|
|
143
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
144
|
+
@media (prefers-reduced-motion: reduce) { .icon.active { animation: none; border-top-color: var(--accent); } }
|
|
145
|
+
|
|
146
|
+
.legend { display: flex; gap: 16px; flex-wrap: wrap; color: var(--muted); font-size: 12px; }
|
|
147
|
+
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|
148
|
+
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; border: 2px solid var(--border); }
|
|
149
|
+
.dot.confirmed { background: var(--ok); border-color: var(--ok); }
|
|
150
|
+
.dot.active { border-color: var(--accent); }
|
|
151
|
+
.dot.failed { background: var(--bad); border-color: var(--bad); }
|
|
152
|
+
.dot.unverified { border-style: dashed; border-color: var(--warn); }
|
|
153
|
+
|
|
154
|
+
/* Detail */
|
|
155
|
+
.detail { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
|
156
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; display: grid; gap: 12px; min-width: 0; }
|
|
157
|
+
.card.explain { background: var(--surface-2); border-style: dashed; }
|
|
158
|
+
.kicker { display: flex; align-items: center; gap: 8px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 600; color: var(--muted); }
|
|
159
|
+
.kicker svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
160
|
+
.kicker.observed { color: var(--text); }
|
|
161
|
+
.card h3 { margin: 0; font-size: 17px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
162
|
+
.card p { margin: 0; }
|
|
163
|
+
.explain p { color: var(--muted); }
|
|
164
|
+
.explain .summary { color: var(--text); font-weight: 500; }
|
|
165
|
+
.confirm { font-size: 13px; border-top: 1px solid var(--border); padding-top: 10px; color: var(--muted); }
|
|
166
|
+
.confirm b { color: var(--text); font-weight: 600; }
|
|
167
|
+
.status-line { padding: 10px 12px; border-radius: 8px; background: var(--surface-2); font-size: 13.5px; }
|
|
168
|
+
.status-line.failed { background: var(--bad-soft); color: var(--bad); }
|
|
169
|
+
.status-line.unverified { background: var(--warn-soft); color: var(--warn); }
|
|
170
|
+
.status-line.confirmed { background: var(--ok-soft); color: var(--ok); }
|
|
171
|
+
.status-line.active { background: var(--accent-soft); color: var(--accent); }
|
|
172
|
+
.facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; margin: 0; font-size: 13px; }
|
|
173
|
+
.facts dt { color: var(--muted); }
|
|
174
|
+
.facts dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
|
175
|
+
.evidence { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
|
176
|
+
.evidence li { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 4px 10px; align-items: start; }
|
|
177
|
+
.ev-time { color: var(--faint); font-size: 12px; font-variant-numeric: tabular-nums; padding-top: 1px; }
|
|
178
|
+
.ev-note { font-size: 13px; }
|
|
179
|
+
.ev-line { grid-column: 2; display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
|
180
|
+
.ev-line code { background: var(--surface-2); border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
|
|
181
|
+
.jump { border: 0; background: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 0; white-space: nowrap; }
|
|
182
|
+
.jump:hover { text-decoration: underline; }
|
|
183
|
+
.empty { color: var(--muted); font-size: 13px; }
|
|
184
|
+
.causes { display: grid; gap: 6px; }
|
|
185
|
+
.causes .ev-line { grid-column: auto; }
|
|
186
|
+
.sub { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 4px; }
|
|
187
|
+
|
|
188
|
+
/* Logs */
|
|
189
|
+
.logs { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
190
|
+
.logs-bar { display: flex; align-items: center; gap: 12px; padding: 8px 12px; flex-wrap: wrap; }
|
|
191
|
+
.logs-toggle { display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; font-weight: 600; cursor: pointer; padding: 4px 0; }
|
|
192
|
+
.logs-toggle svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2.5; transition: transform .15s; }
|
|
193
|
+
.logs-toggle[aria-expanded='true'] svg { transform: rotate(90deg); }
|
|
194
|
+
.count { color: var(--muted); font-weight: 400; }
|
|
195
|
+
#log-filter { margin-left: auto; border: 1px solid var(--border); background: var(--surface-2); color: var(--text); border-radius: 6px; padding: 4px 8px; font: inherit; font-size: 13px; width: 200px; max-width: 100%; }
|
|
196
|
+
.check { display: inline-flex; gap: 5px; align-items: center; color: var(--muted); font-size: 13px; }
|
|
197
|
+
.log-lines { list-style: none; margin: 0; padding: 6px 0; max-height: 420px; overflow: auto; border-top: 1px solid var(--border); background: var(--surface-2); font-family: var(--mono); font-size: 12px; line-height: 1.6; }
|
|
198
|
+
.log-lines li { display: grid; grid-template-columns: 48px 60px minmax(0, 1fr); padding: 0 12px; white-space: pre-wrap; word-break: break-word; }
|
|
199
|
+
.log-lines li.ev { box-shadow: inset 2px 0 0 var(--ok); }
|
|
200
|
+
.log-lines li.err { color: var(--bad); box-shadow: inset 2px 0 0 var(--bad); }
|
|
201
|
+
.log-lines li.flash { animation: flash 1.6s ease-out; }
|
|
202
|
+
@keyframes flash { from { background: color-mix(in srgb, var(--accent) 30%, transparent); } to { background: transparent; } }
|
|
203
|
+
.ln { color: var(--faint); text-align: right; padding-right: 12px; user-select: none; }
|
|
204
|
+
.lt { color: var(--faint); user-select: none; }
|
|
205
|
+
|
|
206
|
+
.foot { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 12px 24px; color: var(--faint); font-size: 12px; border-top: 1px solid var(--border); }
|
|
207
|
+
.foot code { font-size: 11.5px; }
|
|
208
|
+
|
|
209
|
+
.connecting { color: var(--muted); padding: 40px; text-align: center; }
|
|
210
|
+
|
|
211
|
+
@media (max-width: 820px) {
|
|
212
|
+
main { padding: 16px; }
|
|
213
|
+
.top { padding: 12px 16px; }
|
|
214
|
+
.flow { grid-template-columns: 1fr; gap: 10px; }
|
|
215
|
+
.stage, .stage:first-child, .stage:last-child { margin: 0; }
|
|
216
|
+
.stage + .stage::before { left: 25px; top: -11px; width: 2px; height: 10px; }
|
|
217
|
+
.detail { grid-template-columns: 1fr; }
|
|
218
|
+
.log-lines li { grid-template-columns: 36px minmax(0, 1fr); }
|
|
219
|
+
.lt { display: none; }
|
|
220
|
+
}
|