@cabane/companion 0.6.216 → 0.6.217

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