@cabane/companion 0.5.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/README.md +218 -0
- package/dist/cli.js +9936 -0
- package/dist/pairing-config.js +410 -0
- package/dist/runtime.js +8928 -0
- package/dist/static/app.js +585 -0
- package/dist/static/index.html +116 -0
- package/dist/static/styles.css +537 -0
- package/package.json +54 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
// Cabane Bridge dashboard frontend. Vanilla JS, no build step.
|
|
2
|
+
// Subscribes to /api/events (SSE) for live updates and falls back to REST
|
|
3
|
+
// fetches for the initial paint + the available-workspaces list.
|
|
4
|
+
|
|
5
|
+
const $ = (id) => document.getElementById(id);
|
|
6
|
+
|
|
7
|
+
// the dashboard is read-only. No "available to connect" list, no
|
|
8
|
+
// connect/disconnect/activate/deactivate — that lifecycle lives in the cabane
|
|
9
|
+
// app now. This frontend shows status, per-workspace state, the dispatch feed,
|
|
10
|
+
// and logs, plus local-only Stop / Restart / Reload-config.
|
|
11
|
+
const state = {
|
|
12
|
+
status: null, // latest /api/status snapshot
|
|
13
|
+
dispatches: [], // newest-first wire records
|
|
14
|
+
expanded: new Set(), // dispatch ids whose detail is open
|
|
15
|
+
logsOpen: false,
|
|
16
|
+
online: true, // is the bridge process reachable (SSE up)?
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// ---- tiny fetch helpers ----
|
|
20
|
+
|
|
21
|
+
async function getJSON(path) {
|
|
22
|
+
const res = await fetch(path);
|
|
23
|
+
if (!res.ok) throw new Error((await safeError(res)) || `${res.status}`);
|
|
24
|
+
return res.json();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function postJSON(path, body) {
|
|
28
|
+
const res = await fetch(path, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'content-type': 'application/json' },
|
|
31
|
+
body: JSON.stringify(body || {}),
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) throw new Error((await safeError(res)) || `${res.status}`);
|
|
34
|
+
return res.json().catch(() => ({}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function safeError(res) {
|
|
38
|
+
try {
|
|
39
|
+
const j = await res.json();
|
|
40
|
+
return j && j.error;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- formatting ----
|
|
47
|
+
|
|
48
|
+
function timeAgo(iso) {
|
|
49
|
+
if (!iso) return 'never';
|
|
50
|
+
const secs = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
|
|
51
|
+
if (secs < 60) return `${secs}s ago`;
|
|
52
|
+
const mins = Math.round(secs / 60);
|
|
53
|
+
if (mins < 60) return `${mins}m ago`;
|
|
54
|
+
const hrs = Math.round(mins / 60);
|
|
55
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
56
|
+
return `${Math.round(hrs / 24)}d ago`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clock(iso) {
|
|
60
|
+
const d = iso ? new Date(iso) : new Date();
|
|
61
|
+
return d.toTimeString().slice(0, 8);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hostOf(url) {
|
|
65
|
+
try {
|
|
66
|
+
return new URL(url).host;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Freshness for the status dots. a connected, idle bridge is healthy,
|
|
73
|
+
// not a warning — so connected = green, full stop. red = offline / not
|
|
74
|
+
// connected to cabane. amber stays defined (in CSS) for a future genuinely-
|
|
75
|
+
// degraded signal, but we don't downgrade a quiet-but-connected bridge to it.
|
|
76
|
+
function freshness(connected) {
|
|
77
|
+
if (!state.online || !connected) return 'red';
|
|
78
|
+
return 'green';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- rendering ----
|
|
82
|
+
|
|
83
|
+
function render() {
|
|
84
|
+
renderStatus();
|
|
85
|
+
renderHarnesses();
|
|
86
|
+
renderConnected();
|
|
87
|
+
renderDispatches();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// CT586: the harnesses this device exposes to Cabane. `harnesses` is null until
|
|
91
|
+
// the first probe lands (show "checking…"); once probed, a list of three, and if
|
|
92
|
+
// NONE are exposed the loud empty state — the headline requirement. Each row shows
|
|
93
|
+
// a state badge, version when known, a one-line guide, and the friendly enable
|
|
94
|
+
// affordance for the two config-driven harnesses (Codex flag / opencode URL).
|
|
95
|
+
const HARNESS_BADGE = {
|
|
96
|
+
exposed: 'Exposed',
|
|
97
|
+
detected_not_exposed: 'Not exposed',
|
|
98
|
+
needs_attention: 'Needs attention',
|
|
99
|
+
not_detected: 'Not detected',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function renderHarnesses() {
|
|
103
|
+
const s = state.status;
|
|
104
|
+
const harnesses = s && s.harnesses;
|
|
105
|
+
const list = $('harness-list');
|
|
106
|
+
const checking = $('harness-checking');
|
|
107
|
+
const emptyBanner = $('harness-empty');
|
|
108
|
+
|
|
109
|
+
// Before the first probe (null) — or while offline with no snapshot yet — show
|
|
110
|
+
// the checking placeholder, not the scary empty state.
|
|
111
|
+
if (!harnesses) {
|
|
112
|
+
list.innerHTML = '';
|
|
113
|
+
emptyBanner.hidden = true;
|
|
114
|
+
checking.hidden = false;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
checking.hidden = true;
|
|
118
|
+
|
|
119
|
+
const anyExposed = harnesses.some((h) => h.state === 'exposed');
|
|
120
|
+
emptyBanner.hidden = anyExposed;
|
|
121
|
+
|
|
122
|
+
list.innerHTML = '';
|
|
123
|
+
for (const h of harnesses) {
|
|
124
|
+
const li = document.createElement('li');
|
|
125
|
+
li.className = 'harness-row';
|
|
126
|
+
|
|
127
|
+
const name = el('span', 'harness-name', h.label);
|
|
128
|
+
li.append(name);
|
|
129
|
+
if (h.version) li.append(el('span', 'harness-version', `v${h.version}`));
|
|
130
|
+
|
|
131
|
+
const badge = el(
|
|
132
|
+
'span',
|
|
133
|
+
`harness-badge harness-badge--${h.state}`,
|
|
134
|
+
HARNESS_BADGE[h.state] || h.state,
|
|
135
|
+
);
|
|
136
|
+
li.append(el('span', 'harness-spacer'), badge);
|
|
137
|
+
|
|
138
|
+
if (h.detail) li.append(el('p', 'harness-detail', h.detail));
|
|
139
|
+
|
|
140
|
+
// Friendly enable — never install, never login (BYO). Codex is a one-click
|
|
141
|
+
// flag flip; opencode takes a URL we validate is reachable before writing.
|
|
142
|
+
if (h.enable === 'codex') {
|
|
143
|
+
const btn = el('button', 'primary small', 'Use Codex');
|
|
144
|
+
btn.addEventListener('click', () => void enableHarness({ runtime: 'codex' }, btn));
|
|
145
|
+
const wrap = el('div', 'harness-enable');
|
|
146
|
+
wrap.append(btn);
|
|
147
|
+
li.append(wrap);
|
|
148
|
+
} else if (h.enable === 'opencode') {
|
|
149
|
+
const form = el('div', 'harness-enable');
|
|
150
|
+
const input = el('input');
|
|
151
|
+
input.type = 'text';
|
|
152
|
+
input.placeholder = 'opencode serve URL (e.g. http://127.0.0.1:4096)';
|
|
153
|
+
const btn = el('button', 'primary small', 'Add opencode');
|
|
154
|
+
const submit = () => {
|
|
155
|
+
const url = input.value.trim();
|
|
156
|
+
if (url) void enableHarness({ runtime: 'opencode', serverUrl: url }, btn);
|
|
157
|
+
};
|
|
158
|
+
btn.addEventListener('click', submit);
|
|
159
|
+
input.addEventListener('keydown', (e) => {
|
|
160
|
+
if (e.key === 'Enter') submit();
|
|
161
|
+
});
|
|
162
|
+
form.append(input, btn);
|
|
163
|
+
li.append(form);
|
|
164
|
+
}
|
|
165
|
+
list.append(li);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderStatus() {
|
|
170
|
+
const s = state.status;
|
|
171
|
+
const dot = $('status-dot');
|
|
172
|
+
const title = $('status-title');
|
|
173
|
+
if (!state.online) {
|
|
174
|
+
dot.className = 'dot dot--red';
|
|
175
|
+
title.textContent = 'Cabane Bridge — Offline';
|
|
176
|
+
$('conn-line').textContent = 'Bridge process not reachable. Re-run `cabane-companion start`.';
|
|
177
|
+
$('listen-line').textContent = '';
|
|
178
|
+
document.title = '⚠ Bridge — Offline';
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (!s) return;
|
|
182
|
+
const overall = freshness(s.connected);
|
|
183
|
+
dot.className = `dot dot--${overall}`;
|
|
184
|
+
const listening = s.connected;
|
|
185
|
+
title.textContent = listening ? 'Cabane Bridge — Listening' : 'Cabane Bridge — Disconnected';
|
|
186
|
+
document.title = listening ? '● Bridge — Listening' : '⚠ Bridge — Disconnected';
|
|
187
|
+
|
|
188
|
+
const cabaneHost = hostOf(s.cabane_url) || 'cabane';
|
|
189
|
+
if (s.connected) {
|
|
190
|
+
$('conn-line').textContent = `Connected to ${cabaneHost}`;
|
|
191
|
+
} else {
|
|
192
|
+
// this branch means the bridge process is up (the dashboard reached
|
|
193
|
+
// it) but the bridge's own event stream to cabane is down — NOT that cabane
|
|
194
|
+
// the app is unreachable. Point at the bridge connection so the copy isn't
|
|
195
|
+
// misleading. The stream auto-retries; `cabane-companion start` is the remedy
|
|
196
|
+
// if the process was never (re)started.
|
|
197
|
+
const last = s.last_event_at_overall
|
|
198
|
+
? ` — last connected ${timeAgo(s.last_event_at_overall)}`
|
|
199
|
+
: '';
|
|
200
|
+
$('conn-line').textContent =
|
|
201
|
+
`⚠ Bridge isn't connected to ${cabaneHost}${last}. Make sure \`cabane-companion start\` is running.`;
|
|
202
|
+
}
|
|
203
|
+
$('listen-line').textContent = listening
|
|
204
|
+
? `Listening — last event ${timeAgo(s.last_event_at_overall)}`
|
|
205
|
+
: 'Not connected to any workspace stream.';
|
|
206
|
+
|
|
207
|
+
$('ver-bridge').textContent = s.bridge_version || '—';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderConnected() {
|
|
211
|
+
const s = state.status;
|
|
212
|
+
const list = $('connected-list');
|
|
213
|
+
list.innerHTML = '';
|
|
214
|
+
const wss = (s && s.workspaces) || [];
|
|
215
|
+
$('connected-count').textContent = wss.length ? `(${wss.length})` : '';
|
|
216
|
+
$('connected-empty').hidden = wss.length > 0;
|
|
217
|
+
for (const ws of wss) {
|
|
218
|
+
const li = document.createElement('li');
|
|
219
|
+
li.className = 'ws-row';
|
|
220
|
+
// a terminal auth failure (rotated/revoked PAT) is a dead end the
|
|
221
|
+
// operator has to act on — render it red with a clear "re-add" line, not as
|
|
222
|
+
// the transient grey of a reconnecting stream.
|
|
223
|
+
const f = ws.auth_failed ? 'red' : freshness(ws.connected);
|
|
224
|
+
|
|
225
|
+
const dot = el('span', `dot dot--${f}`);
|
|
226
|
+
const name = el('span', 'ws-name', ws.name);
|
|
227
|
+
const meta = el(
|
|
228
|
+
'span',
|
|
229
|
+
'ws-meta',
|
|
230
|
+
ws.auth_failed
|
|
231
|
+
? 'disconnected — re-add this workspace in the cabane app'
|
|
232
|
+
: `last event ${timeAgo(ws.last_event_at)} · ${ws.dispatch_count_today} today`,
|
|
233
|
+
);
|
|
234
|
+
if (ws.auth_failed) {
|
|
235
|
+
li.classList.add('ws-row--auth-failed');
|
|
236
|
+
meta.classList.add('ws-meta--alert');
|
|
237
|
+
}
|
|
238
|
+
const spacer = el('span', 'spacer');
|
|
239
|
+
|
|
240
|
+
// read-only — assignment lives in the cabane app. The bridge pulls which
|
|
241
|
+
// agents to run; this view just observes them.
|
|
242
|
+
li.append(dot, name, meta, spacer);
|
|
243
|
+
|
|
244
|
+
// CT29: the agents this device runs in this workspace, pulled from Cabane.
|
|
245
|
+
const agents = ws.agents || [];
|
|
246
|
+
if (agents.length) {
|
|
247
|
+
const sub = el('ul', 'ws-agents');
|
|
248
|
+
for (const a of agents) {
|
|
249
|
+
const row = el('li', 'ws-agent');
|
|
250
|
+
const handle = el('span', 'ws-agent-name', `@${a.username}`);
|
|
251
|
+
const modeTag = el('span', 'ws-agent-mode', a.mode);
|
|
252
|
+
row.append(handle, modeTag);
|
|
253
|
+
if (!a.has_credential) {
|
|
254
|
+
const warn = el('span', 'ws-agent-warn', 'no credential — re-assign in cabane');
|
|
255
|
+
row.append(warn);
|
|
256
|
+
} else if (a.missing_secrets && a.missing_secrets.length) {
|
|
257
|
+
const warn = el(
|
|
258
|
+
'span',
|
|
259
|
+
'ws-agent-warn',
|
|
260
|
+
`needs secret(s) this device lacks: ${a.missing_secrets.join(', ')}`,
|
|
261
|
+
);
|
|
262
|
+
row.append(warn);
|
|
263
|
+
}
|
|
264
|
+
sub.append(row);
|
|
265
|
+
}
|
|
266
|
+
li.append(sub);
|
|
267
|
+
}
|
|
268
|
+
list.append(li);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderDispatches() {
|
|
273
|
+
const list = $('dispatch-list');
|
|
274
|
+
list.innerHTML = '';
|
|
275
|
+
$('dispatch-empty').hidden = state.dispatches.length > 0;
|
|
276
|
+
for (const d of state.dispatches) {
|
|
277
|
+
const li = document.createElement('li');
|
|
278
|
+
li.className = 'dispatch-row';
|
|
279
|
+
|
|
280
|
+
const head = el('div', 'dispatch-head');
|
|
281
|
+
head.append(
|
|
282
|
+
el('span', 'time', clock(d.timestamp)),
|
|
283
|
+
el('span', 'ws', d.workspace_slug),
|
|
284
|
+
el('span', 'preview', d.message || '(no message)'),
|
|
285
|
+
verdict(d),
|
|
286
|
+
);
|
|
287
|
+
head.addEventListener('click', () => {
|
|
288
|
+
if (state.expanded.has(d.id)) state.expanded.delete(d.id);
|
|
289
|
+
else state.expanded.add(d.id);
|
|
290
|
+
renderDispatches();
|
|
291
|
+
});
|
|
292
|
+
li.append(head);
|
|
293
|
+
|
|
294
|
+
if (state.expanded.has(d.id)) {
|
|
295
|
+
const detail = el('div', 'dispatch-detail');
|
|
296
|
+
|
|
297
|
+
// show both halves of the exchange — the triggering message and
|
|
298
|
+
// the agent's reply — plus the error if the turn failed.
|
|
299
|
+
detail.append(detailBlock('Message', d.message || '(no message body captured)'));
|
|
300
|
+
|
|
301
|
+
if (d.reply) {
|
|
302
|
+
detail.append(detailBlock('Reply', d.reply));
|
|
303
|
+
} else if (d.status === 'running') {
|
|
304
|
+
detail.append(detailBlock('Reply', 'still running…', 'muted'));
|
|
305
|
+
} else if (d.status === 'replied') {
|
|
306
|
+
detail.append(detailBlock('Reply', '(no reply text captured)', 'muted'));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (d.error) {
|
|
310
|
+
detail.append(detailBlock('Error', d.error, 'err-text'));
|
|
311
|
+
}
|
|
312
|
+
li.append(detail);
|
|
313
|
+
}
|
|
314
|
+
list.append(li);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function verdict(d) {
|
|
319
|
+
if (d.status === 'running') return el('span', 'verdict run', '… running');
|
|
320
|
+
if (d.status === 'replied') {
|
|
321
|
+
const secs = d.duration_ms ? ` (${(d.duration_ms / 1000).toFixed(1)}s)` : '';
|
|
322
|
+
return el('span', 'verdict ok', `✓ replied${secs}`);
|
|
323
|
+
}
|
|
324
|
+
return el('span', 'verdict err', '⚠ error');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---- DOM helpers ----
|
|
328
|
+
|
|
329
|
+
function el(tag, className, text) {
|
|
330
|
+
const node = document.createElement(tag);
|
|
331
|
+
if (className) node.className = className;
|
|
332
|
+
if (text !== undefined) node.textContent = text;
|
|
333
|
+
return node;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// a labeled section inside an expanded dispatch row (Message / Reply /
|
|
337
|
+
// Error). `bodyClass` adds a modifier to the body (e.g. 'muted', 'err-text').
|
|
338
|
+
function detailBlock(label, body, bodyClass) {
|
|
339
|
+
const block = el('div', 'd-block');
|
|
340
|
+
block.append(
|
|
341
|
+
el('p', 'd-label', label),
|
|
342
|
+
el('div', `d-body${bodyClass ? ` ${bodyClass}` : ''}`, body),
|
|
343
|
+
);
|
|
344
|
+
return block;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let toastTimer = null;
|
|
348
|
+
function toast(msg, isError) {
|
|
349
|
+
const t = $('toast');
|
|
350
|
+
t.textContent = msg;
|
|
351
|
+
t.className = `toast${isError ? ' error' : ''}`;
|
|
352
|
+
t.hidden = false;
|
|
353
|
+
clearTimeout(toastTimer);
|
|
354
|
+
toastTimer = setTimeout(() => {
|
|
355
|
+
t.hidden = true;
|
|
356
|
+
}, 4000);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---- actions ----
|
|
360
|
+
|
|
361
|
+
async function control(action) {
|
|
362
|
+
try {
|
|
363
|
+
await postJSON('/api/control', { action });
|
|
364
|
+
if (action === 'stop') toast('Stopping the bridge…');
|
|
365
|
+
if (action === 'restart') toast('Restarting the bridge…');
|
|
366
|
+
if (action === 'reload-config') toast('Reloaded config.');
|
|
367
|
+
} catch (err) {
|
|
368
|
+
toast(err.message || 'control failed', true);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// CT586: re-probe the harnesses now. The response carries the refreshed status;
|
|
373
|
+
// the SSE status:changed also lands, so either updates the surface.
|
|
374
|
+
async function recheckHarnesses() {
|
|
375
|
+
try {
|
|
376
|
+
const { status } = await postJSON('/api/harnesses/recheck');
|
|
377
|
+
if (status) {
|
|
378
|
+
state.status = status;
|
|
379
|
+
render();
|
|
380
|
+
}
|
|
381
|
+
} catch (err) {
|
|
382
|
+
toast(err.message || "couldn't recheck harnesses", true);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// CT586: enable/expose a config-driven harness. On success the refreshed status
|
|
387
|
+
// re-renders (the row flips to exposed). A failure — most often an unreachable
|
|
388
|
+
// opencode URL — surfaces as a toast in place; the config is left untouched.
|
|
389
|
+
async function enableHarness(body, btn) {
|
|
390
|
+
if (btn) btn.disabled = true;
|
|
391
|
+
try {
|
|
392
|
+
const { status } = await postJSON('/api/harnesses/enable', body);
|
|
393
|
+
if (status) {
|
|
394
|
+
state.status = status;
|
|
395
|
+
render();
|
|
396
|
+
}
|
|
397
|
+
toast(body.runtime === 'codex' ? 'Codex enabled.' : 'opencode added.');
|
|
398
|
+
} catch (err) {
|
|
399
|
+
if (btn) btn.disabled = false;
|
|
400
|
+
toast(err.message || "couldn't enable that harness", true);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- logs ----
|
|
405
|
+
|
|
406
|
+
let logsTimer = null;
|
|
407
|
+
async function loadLogs() {
|
|
408
|
+
try {
|
|
409
|
+
const { lines } = await getJSON('/api/logs?lines=200');
|
|
410
|
+
const view = $('logs-view');
|
|
411
|
+
view.textContent = lines.join('\n') || '(log is empty)';
|
|
412
|
+
view.scrollTop = view.scrollHeight;
|
|
413
|
+
} catch (err) {
|
|
414
|
+
$('logs-view').textContent = `couldn't read logs: ${err.message}`;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function setLogsOpen(open) {
|
|
419
|
+
state.logsOpen = open;
|
|
420
|
+
$('logs-panel').hidden = !open;
|
|
421
|
+
clearInterval(logsTimer);
|
|
422
|
+
if (open) {
|
|
423
|
+
void loadLogs();
|
|
424
|
+
logsTimer = setInterval(() => {
|
|
425
|
+
if (!document.hidden) void loadLogs();
|
|
426
|
+
}, 5000);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---- settings ----
|
|
431
|
+
|
|
432
|
+
function renderSettings() {
|
|
433
|
+
const s = state.status;
|
|
434
|
+
if (!s) return;
|
|
435
|
+
$('set-url').textContent = s.cabane_url || '—';
|
|
436
|
+
$('set-port').textContent = s.dashboard_url ? new URL(s.dashboard_url).port || '80' : '—';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Flash a green ✓ next to the control that was just saved, then clear it.
|
|
440
|
+
const savedTimers = new WeakMap();
|
|
441
|
+
function flashSaved(checkEl) {
|
|
442
|
+
if (!checkEl) return;
|
|
443
|
+
checkEl.hidden = false;
|
|
444
|
+
clearTimeout(savedTimers.get(checkEl));
|
|
445
|
+
savedTimers.set(
|
|
446
|
+
checkEl,
|
|
447
|
+
setTimeout(() => {
|
|
448
|
+
checkEl.hidden = true;
|
|
449
|
+
}, 2500),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function saveSettings(patch, checkEl) {
|
|
454
|
+
try {
|
|
455
|
+
const { status } = await postJSON('/api/settings', patch);
|
|
456
|
+
if (status) {
|
|
457
|
+
state.status = status;
|
|
458
|
+
render();
|
|
459
|
+
}
|
|
460
|
+
flashSaved(checkEl);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
toast(err.message || "couldn't save setting", true);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---- SSE wiring ----
|
|
467
|
+
|
|
468
|
+
function connectSSE() {
|
|
469
|
+
const es = new EventSource('/api/events');
|
|
470
|
+
|
|
471
|
+
es.addEventListener('status:changed', (e) => {
|
|
472
|
+
state.online = true;
|
|
473
|
+
state.status = JSON.parse(e.data);
|
|
474
|
+
render();
|
|
475
|
+
renderSettings();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const upsertDispatch = (e) => {
|
|
479
|
+
const rec = JSON.parse(e.data);
|
|
480
|
+
const idx = state.dispatches.findIndex((d) => d.id === rec.id);
|
|
481
|
+
if (idx >= 0) state.dispatches[idx] = rec;
|
|
482
|
+
else state.dispatches.unshift(rec);
|
|
483
|
+
if (state.dispatches.length > 50) state.dispatches.length = 50;
|
|
484
|
+
renderDispatches();
|
|
485
|
+
};
|
|
486
|
+
es.addEventListener('dispatch:started', upsertDispatch);
|
|
487
|
+
es.addEventListener('dispatch:completed', upsertDispatch);
|
|
488
|
+
es.addEventListener('dispatch:error', upsertDispatch);
|
|
489
|
+
|
|
490
|
+
// workspace:connected / workspace:disconnected still arrive when a local
|
|
491
|
+
// `add` + reload-config changes the runner set; the accompanying
|
|
492
|
+
// status:changed re-renders, so no extra handling is needed here.
|
|
493
|
+
|
|
494
|
+
es.onerror = () => {
|
|
495
|
+
// EventSource auto-reconnects; if the bridge is actually gone the retries
|
|
496
|
+
// keep failing and we show the offline state. readyState 2 = closed.
|
|
497
|
+
if (es.readyState === EventSource.CLOSED || es.readyState === EventSource.CONNECTING) {
|
|
498
|
+
state.online = false;
|
|
499
|
+
renderStatus();
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
es.onopen = () => {
|
|
503
|
+
if (!state.online) {
|
|
504
|
+
state.online = true;
|
|
505
|
+
void boot();
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---- boot ----
|
|
511
|
+
|
|
512
|
+
async function boot() {
|
|
513
|
+
try {
|
|
514
|
+
state.status = await getJSON('/api/status');
|
|
515
|
+
state.online = true;
|
|
516
|
+
} catch {
|
|
517
|
+
state.online = false;
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
const { dispatches } = await getJSON('/api/dispatches?limit=20');
|
|
521
|
+
state.dispatches = dispatches;
|
|
522
|
+
} catch {
|
|
523
|
+
/* leave existing */
|
|
524
|
+
}
|
|
525
|
+
render();
|
|
526
|
+
renderSettings();
|
|
527
|
+
// Seed settings controls from config-derived status.
|
|
528
|
+
const s = state.status;
|
|
529
|
+
if (s) {
|
|
530
|
+
$('set-loglevel').value = 'debug';
|
|
531
|
+
// SJ522: auto-open now defaults OFF, so the toggle seeds unchecked to match.
|
|
532
|
+
$('set-autoopen').checked = false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function wireControls() {
|
|
537
|
+
for (const btn of document.querySelectorAll('[data-toggle]')) {
|
|
538
|
+
btn.addEventListener('click', () => {
|
|
539
|
+
const id = btn.getAttribute('data-toggle');
|
|
540
|
+
const panel = $(id);
|
|
541
|
+
const open = panel.hidden;
|
|
542
|
+
panel.hidden = !open;
|
|
543
|
+
if (id === 'logs-panel') setLogsOpen(open);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
$('stop-btn').addEventListener('click', () => {
|
|
547
|
+
if (window.confirm('Stop the bridge? Replies will stop until you run it again.')) {
|
|
548
|
+
void control('stop');
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
$('restart-btn').addEventListener('click', () => void control('restart'));
|
|
552
|
+
$('recheck-btn').addEventListener('click', () => void recheckHarnesses());
|
|
553
|
+
$('set-loglevel').addEventListener('change', (e) => {
|
|
554
|
+
void saveSettings({ logLevel: e.target.value }, $('check-loglevel'));
|
|
555
|
+
void control('reload-config');
|
|
556
|
+
});
|
|
557
|
+
$('set-autoopen').addEventListener('change', (e) => {
|
|
558
|
+
void saveSettings({ autoOpen: e.target.checked }, $('check-autoopen'));
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Refresh relative timestamps + dot freshness every second without a network
|
|
563
|
+
// round-trip.
|
|
564
|
+
setInterval(() => {
|
|
565
|
+
if (state.online && state.status) {
|
|
566
|
+
renderStatus();
|
|
567
|
+
renderConnected();
|
|
568
|
+
}
|
|
569
|
+
}, 1000);
|
|
570
|
+
|
|
571
|
+
// CT586: re-probe harnesses when the window regains focus (a user tabs away to
|
|
572
|
+
// install/log into a harness, then back), throttled so rapid focus flaps don't
|
|
573
|
+
// spam the probes. The heartbeat cadence covers the background case.
|
|
574
|
+
let lastRecheck = 0;
|
|
575
|
+
window.addEventListener('focus', () => {
|
|
576
|
+
if (!state.online) return;
|
|
577
|
+
const now = Date.now();
|
|
578
|
+
if (now - lastRecheck < 5000) return;
|
|
579
|
+
lastRecheck = now;
|
|
580
|
+
void recheckHarnesses();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
wireControls();
|
|
584
|
+
void boot();
|
|
585
|
+
connectSSE();
|
|
@@ -0,0 +1,116 @@
|
|
|
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>Cabane Bridge</title>
|
|
7
|
+
<link rel="stylesheet" href="/static/styles.css" />
|
|
8
|
+
<!-- the Cabane app-icon — ink tile, ember A-frame mark. Reads on a
|
|
9
|
+
light or dark browser tab. -->
|
|
10
|
+
<link
|
|
11
|
+
rel="icon"
|
|
12
|
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='112' ry='112' fill='%23141413'/><g fill='none' stroke='%23c8552a' stroke-width='2.52' stroke-linecap='round' transform='translate(116 151) scale(11.67)'><path d='M 1.26 16.74 L 12 1.26'/><path d='M 12 1.26 L 22.74 16.74'/></g></svg>"
|
|
13
|
+
/>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<main class="shell">
|
|
17
|
+
<header class="topbar">
|
|
18
|
+
<div class="brand">
|
|
19
|
+
<svg class="logo" viewBox="0 0 24 18" fill="none" aria-hidden="true">
|
|
20
|
+
<path d="M 1.26 16.74 L 12 1.26" stroke="currentColor" stroke-width="2.52" stroke-linecap="round" />
|
|
21
|
+
<path d="M 12 1.26 L 22.74 16.74" stroke="currentColor" stroke-width="2.52" stroke-linecap="round" />
|
|
22
|
+
</svg>
|
|
23
|
+
<span id="status-dot" class="dot dot--unknown" aria-hidden="true"></span>
|
|
24
|
+
<h1 id="status-title">Cabane Bridge</h1>
|
|
25
|
+
</div>
|
|
26
|
+
<p id="conn-line" class="conn-line">Connecting…</p>
|
|
27
|
+
<p id="listen-line" class="listen-line"></p>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<!-- CT586: the harnesses this device exposes to Cabane. Primary panel — a
|
|
31
|
+
device exposing NONE is useless, so the loud empty state lives here. -->
|
|
32
|
+
<section class="panel" id="harnesses-panel">
|
|
33
|
+
<h2>
|
|
34
|
+
Harnesses
|
|
35
|
+
<button type="button" id="recheck-btn" class="link-btn" title="Re-check now">
|
|
36
|
+
Recheck
|
|
37
|
+
</button>
|
|
38
|
+
</h2>
|
|
39
|
+
<div id="harness-empty" class="harness-empty" hidden>
|
|
40
|
+
<p class="harness-empty-title">Cabane can’t run anything on this device yet.</p>
|
|
41
|
+
<p class="harness-empty-body">
|
|
42
|
+
Install and sign in to a harness — Claude Code, Codex, or opencode —
|
|
43
|
+
then it’ll appear here as ready. Until then, agents assigned to this
|
|
44
|
+
device have nothing to run on.
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
<ul id="harness-list" class="harness-list"></ul>
|
|
48
|
+
<p id="harness-checking" class="empty">Checking harnesses…</p>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<section class="panel" id="connected-panel">
|
|
52
|
+
<h2>Connected workspaces <span id="connected-count" class="count"></span></h2>
|
|
53
|
+
<ul id="connected-list" class="ws-list"></ul>
|
|
54
|
+
<p id="connected-empty" class="empty" hidden>
|
|
55
|
+
No workspaces enrolled yet. Connect a bridge in the cabane app
|
|
56
|
+
(Workspace Settings → Agents → Bridges), then run
|
|
57
|
+
<code>cabane-companion add</code> and paste the string when prompted.
|
|
58
|
+
</p>
|
|
59
|
+
</section>
|
|
60
|
+
|
|
61
|
+
<section class="panel" id="dispatches-panel">
|
|
62
|
+
<h2>Recent dispatches</h2>
|
|
63
|
+
<ul id="dispatch-list" class="dispatch-list"></ul>
|
|
64
|
+
<p id="dispatch-empty" class="empty" hidden>
|
|
65
|
+
No dispatches yet. Post a message in cabane and it'll show up here.
|
|
66
|
+
</p>
|
|
67
|
+
</section>
|
|
68
|
+
|
|
69
|
+
<footer class="actions">
|
|
70
|
+
<button type="button" data-toggle="logs-panel">Open logs</button>
|
|
71
|
+
<button type="button" data-toggle="settings-panel">Settings</button>
|
|
72
|
+
<button type="button" id="restart-btn" class="ghost">Restart</button>
|
|
73
|
+
<button type="button" id="stop-btn" class="ghost danger">Stop</button>
|
|
74
|
+
</footer>
|
|
75
|
+
|
|
76
|
+
<section class="panel collapsible" id="logs-panel" hidden>
|
|
77
|
+
<h2>Logs <span class="hint">~/.cabane/bridge.log</span></h2>
|
|
78
|
+
<pre id="logs-view" class="logs">Loading…</pre>
|
|
79
|
+
</section>
|
|
80
|
+
|
|
81
|
+
<section class="panel collapsible" id="settings-panel" hidden>
|
|
82
|
+
<h2>Settings</h2>
|
|
83
|
+
<div class="setting">
|
|
84
|
+
<label>Cabane URL</label>
|
|
85
|
+
<span id="set-url" class="readonly">—</span>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="setting">
|
|
88
|
+
<label>Dashboard port</label>
|
|
89
|
+
<span id="set-port" class="readonly">—</span>
|
|
90
|
+
<span class="hint">change in ~/.cabane/config.json (needs restart)</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="setting">
|
|
93
|
+
<label for="set-loglevel">Log level</label>
|
|
94
|
+
<select id="set-loglevel">
|
|
95
|
+
<option value="warn">warn</option>
|
|
96
|
+
<option value="info">info</option>
|
|
97
|
+
<option value="debug">debug</option>
|
|
98
|
+
</select>
|
|
99
|
+
<span id="check-loglevel" class="saved-check" hidden>✓</span>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="setting">
|
|
102
|
+
<label for="set-autoopen">Auto-open browser on start</label>
|
|
103
|
+
<input type="checkbox" id="set-autoopen" />
|
|
104
|
+
<span id="check-autoopen" class="saved-check" hidden>✓</span>
|
|
105
|
+
</div>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
<p class="version">
|
|
109
|
+
bridge <span id="ver-bridge">—</span>
|
|
110
|
+
</p>
|
|
111
|
+
</main>
|
|
112
|
+
|
|
113
|
+
<div id="toast" class="toast" hidden></div>
|
|
114
|
+
<script src="/static/app.js"></script>
|
|
115
|
+
</body>
|
|
116
|
+
</html>
|