@runuai/host 0.8.5 → 0.8.7

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/src/ui/server.ts CHANGED
@@ -27,8 +27,18 @@ import { schema, type Db } from "../../lib/db";
27
27
  import { parsePreviewPortRuntimes } from "../../lib/preview-ports";
28
28
  import { getCloudState } from "../../lib/cloud-state";
29
29
  import { dockerCli } from "../../lib/docker-exec";
30
+ import {
31
+ connectEngine,
32
+ disconnectEngine,
33
+ engineCatalog,
34
+ engineStatuses,
35
+ isEngineKind,
36
+ } from "../../lib/engines";
37
+ import { ensureStandardImage } from "../../lib/standard-image";
30
38
  import {
31
39
  CloudResponse,
40
+ EngineOpResponse,
41
+ EnginesResponse,
32
42
  EventsResponse,
33
43
  StatusResponse,
34
44
  TasksResponse,
@@ -55,6 +65,12 @@ export interface UiServerOptions {
55
65
  logPath: string;
56
66
  /** Best-effort container memory by compose project; null on failure. */
57
67
  taskMemory?: (composeProject: string) => Promise<number | null>;
68
+ /**
69
+ * Re-advertise host capabilities to the cloud (ADR-021). Wired from main.ts
70
+ * (`sendCapabilities`) so an engine connect/disconnect updates the cloud's
71
+ * task picker promptly. Optional (tests omit it).
72
+ */
73
+ readvertise?: () => void;
58
74
  }
59
75
 
60
76
  export interface UiServerHandle {
@@ -121,8 +137,20 @@ async function handle(
121
137
  opts: UiServerOptions,
122
138
  ): Promise<void> {
123
139
  const path = (req.url ?? "/").split("?")[0] ?? "/";
140
+ const method = req.method ?? "GET";
124
141
  try {
125
- if (req.method !== "GET") {
142
+ // POST — engine connect/disconnect (the only writes; localhost-bound, so
143
+ // the loopback binding is the perimeter, ADR-028).
144
+ if (method === "POST") {
145
+ switch (path) {
146
+ case "/api/engines/connect":
147
+ return await handleEngineConnect(req, res, opts);
148
+ case "/api/engines/disconnect":
149
+ return await handleEngineDisconnect(req, res, opts);
150
+ }
151
+ return sendError(res, 404, `no such endpoint: ${path}`);
152
+ }
153
+ if (method !== "GET") {
126
154
  return sendError(res, 405, "method not allowed");
127
155
  }
128
156
  switch (path) {
@@ -144,6 +172,8 @@ async function handle(
144
172
  return sendJson(res, EventsResponse, eventsBody(opts));
145
173
  case "/api/users":
146
174
  return sendJson(res, UsersResponse, usersBody(opts));
175
+ case "/api/engines":
176
+ return sendJson(res, EnginesResponse, enginesBody());
147
177
  }
148
178
  if (path.startsWith("/api/")) {
149
179
  return sendError(res, 404, `no such endpoint: ${path}`);
@@ -154,6 +184,105 @@ async function handle(
154
184
  }
155
185
  }
156
186
 
187
+ // --- engines ----------------------------------------------------------------
188
+
189
+ function enginesBody(): EnginesResponse {
190
+ return { catalog: engineCatalog(), statuses: engineStatuses() };
191
+ }
192
+
193
+ /**
194
+ * POST /api/engines/connect `{kind, apiKey?, pastedToken?}`.
195
+ *
196
+ * Streams the connect as newline-delimited JSON (`application/x-ndjson`): each
197
+ * live CLI log line is `{"line":"…"}`, and a final `{"done":true,"ok":…,
198
+ * "message":"…"}` carries the result. api-key/paste connects emit no log lines
199
+ * and just the final frame. On success we re-advertise and kick a (best-effort)
200
+ * standard-image rebuild so a newly-configured engine's CLI gets installed.
201
+ */
202
+ async function handleEngineConnect(
203
+ req: IncomingMessage,
204
+ res: ServerResponse,
205
+ opts: UiServerOptions,
206
+ ): Promise<void> {
207
+ const body = await readJsonBody(req);
208
+ const kind = body?.kind;
209
+ if (!isEngineKind(kind)) {
210
+ return sendError(res, 400, "unknown or missing engine kind");
211
+ }
212
+ const apiKey = typeof body?.apiKey === "string" ? body.apiKey : undefined;
213
+ const pastedToken =
214
+ typeof body?.pastedToken === "string" ? body.pastedToken : undefined;
215
+
216
+ res.writeHead(200, {
217
+ "content-type": "application/x-ndjson; charset=utf-8",
218
+ "cache-control": "no-store",
219
+ });
220
+ const emit = (obj: unknown): void => {
221
+ res.write(`${JSON.stringify(obj)}\n`);
222
+ };
223
+
224
+ let result: { ok: boolean; message: string };
225
+ try {
226
+ result = await connectEngine(
227
+ kind,
228
+ { apiKey, pastedToken },
229
+ (line) => emit({ line }),
230
+ );
231
+ } catch (err) {
232
+ result = {
233
+ ok: false,
234
+ message: err instanceof Error ? err.message : "connect failed",
235
+ };
236
+ }
237
+ if (result.ok) {
238
+ // Re-advertise so the cloud picker offers the engine; rebuild the image so
239
+ // an optional engine's CLI (kimi/grok/cursor) is installed. Best-effort.
240
+ opts.readvertise?.();
241
+ void ensureStandardImage();
242
+ }
243
+ emit({ done: true, ok: result.ok, message: result.message });
244
+ res.end();
245
+ }
246
+
247
+ /** POST /api/engines/disconnect `{kind}` → forget the credential + re-advertise. */
248
+ async function handleEngineDisconnect(
249
+ req: IncomingMessage,
250
+ res: ServerResponse,
251
+ opts: UiServerOptions,
252
+ ): Promise<void> {
253
+ const body = await readJsonBody(req);
254
+ const kind = body?.kind;
255
+ if (!isEngineKind(kind)) {
256
+ return sendError(res, 400, "unknown or missing engine kind");
257
+ }
258
+ disconnectEngine(kind);
259
+ opts.readvertise?.();
260
+ return sendJson(res, EngineOpResponse, { ok: true });
261
+ }
262
+
263
+ /** Read a request body and parse it as a JSON object; null on empty/invalid. */
264
+ async function readJsonBody(
265
+ req: IncomingMessage,
266
+ ): Promise<Record<string, unknown> | null> {
267
+ const chunks: Buffer[] = [];
268
+ let size = 0;
269
+ for await (const chunk of req) {
270
+ const buf = chunk as Buffer;
271
+ size += buf.length;
272
+ if (size > 64 * 1024) throw new Error("request body too large");
273
+ chunks.push(buf);
274
+ }
275
+ if (chunks.length === 0) return null;
276
+ try {
277
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
278
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
279
+ ? (parsed as Record<string, unknown>)
280
+ : null;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
157
286
  // --- handlers ---------------------------------------------------------------
158
287
 
159
288
  async function tasksBody(opts: UiServerOptions): Promise<TasksResponse> {
@@ -236,6 +365,8 @@ const STATIC_FILES: Record<string, string> = {
236
365
  "/index.html": "index.html",
237
366
  "/style.css": "style.css",
238
367
  "/app.js": "app.js",
368
+ "/uai-wheel.svg": "uai-wheel.svg",
369
+ "/uai-favicon.svg": "uai-favicon.svg",
239
370
  "/uai-logo-black.svg": "uai-logo-black.svg",
240
371
  };
241
372
 
package/src/ui/types.ts CHANGED
@@ -74,5 +74,28 @@ export type UserRow = z.infer<typeof UserRow>;
74
74
  export const UsersResponse = z.object({ users: z.array(UserRow) });
75
75
  export type UsersResponse = z.infer<typeof UsersResponse>;
76
76
 
77
+ // GET /api/engines — the engine catalog + which are connected on this host.
78
+ export const EngineCatalogEntry = z.object({
79
+ kind: z.enum(["claude", "codex", "kimi", "grok", "cursor"]),
80
+ label: z.string(),
81
+ authMode: z.enum(["token-command", "login-command", "api-key"]),
82
+ notes: z.string().nullable(),
83
+ getKeyUrl: z.string().nullable(),
84
+ });
85
+ export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
86
+
87
+ export const EnginesResponse = z.object({
88
+ catalog: z.array(EngineCatalogEntry),
89
+ statuses: z.record(z.boolean()), // kind → connected
90
+ });
91
+ export type EnginesResponse = z.infer<typeof EnginesResponse>;
92
+
93
+ // POST /api/engines/disconnect — the disconnect result.
94
+ export const EngineOpResponse = z.object({
95
+ ok: z.boolean(),
96
+ message: z.string().optional(),
97
+ });
98
+ export type EngineOpResponse = z.infer<typeof EngineOpResponse>;
99
+
77
100
  export const ErrorResponse = z.object({ error: z.string() });
78
101
  export type ErrorResponse = z.infer<typeof ErrorResponse>;
package/ui/app.js CHANGED
@@ -7,6 +7,7 @@ const $ = (id) => document.getElementById(id);
7
7
  const expanded = new Set(); // task ids whose events are expanded (persist across polls)
8
8
  let latestTasks = [];
9
9
  let latestEvents = [];
10
+ let latestEngines = null; // { catalog, statuses } from /api/engines
10
11
 
11
12
  const KIND_CLASS = {
12
13
  "task.created": "created",
@@ -23,18 +24,22 @@ async function getJSON(path) {
23
24
 
24
25
  async function poll() {
25
26
  try {
26
- const [status, cloud, tasks, events] = await Promise.all([
27
+ const [status, cloud, tasks, events, engines] = await Promise.all([
27
28
  getJSON("/api/status"),
28
29
  getJSON("/api/cloud"),
29
30
  getJSON("/api/tasks"),
30
31
  getJSON("/api/events"),
32
+ // Engines are non-fatal: a failure here must not flip the badge to
33
+ // "unreachable" — keep the last known catalog.
34
+ getJSON("/api/engines").catch(() => latestEngines),
31
35
  ]);
32
36
  renderStatus(status);
33
37
  renderCloud(cloud);
34
38
  renderEvents(events.events);
35
39
  renderTasks(tasks.tasks, events.events);
40
+ renderEngines(engines);
36
41
  $("foot-note").textContent =
37
- `read-only · 127.0.0.1 · updated ${new Date().toLocaleTimeString()}`;
42
+ `127.0.0.1 · updated ${new Date().toLocaleTimeString()}`;
38
43
  } catch {
39
44
  setBadge("bad", "unreachable");
40
45
  }
@@ -216,6 +221,378 @@ function renderEvents(events) {
216
221
  }
217
222
  }
218
223
 
224
+ // --- engines ----------------------------------------------------------------
225
+
226
+ async function postJSON(path, body) {
227
+ const res = await fetch(path, {
228
+ method: "POST",
229
+ headers: { "content-type": "application/json" },
230
+ body: JSON.stringify(body),
231
+ });
232
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
233
+ return res.json();
234
+ }
235
+
236
+ function engineByKind(kind) {
237
+ return (latestEngines?.catalog || []).find((e) => e.kind === kind) || null;
238
+ }
239
+
240
+ function monogram(label) {
241
+ const m = document.createElement("span");
242
+ m.className = "engine-mono";
243
+ m.textContent = (label || "?").trim().charAt(0).toUpperCase();
244
+ return m;
245
+ }
246
+
247
+ function renderEngines(data) {
248
+ if (data) latestEngines = data;
249
+ const wrap = $("engines");
250
+ if (!wrap) return;
251
+ const catalog = latestEngines?.catalog || [];
252
+ const statuses = latestEngines?.statuses || {};
253
+ const connected = catalog.filter((e) => statuses[e.kind]);
254
+
255
+ $("engines-count").textContent = connected.length ? `(${connected.length})` : "";
256
+ $("engines-empty").hidden = connected.length > 0;
257
+ wrap.querySelectorAll(".engine-card").forEach((n) => n.remove());
258
+ for (const e of connected) wrap.append(engineCard(e));
259
+ }
260
+
261
+ function engineCard(e) {
262
+ const card = document.createElement("div");
263
+ card.className = "engine-card";
264
+
265
+ const head = document.createElement("div");
266
+ head.className = "engine-head";
267
+ head.append(monogram(e.label));
268
+ const meta = document.createElement("div");
269
+ meta.className = "engine-meta";
270
+ const name = document.createElement("div");
271
+ name.className = "engine-name";
272
+ name.textContent = e.label;
273
+ const sub = document.createElement("div");
274
+ sub.className = "engine-sub ok";
275
+ sub.textContent = "Connected";
276
+ meta.append(name, sub);
277
+ head.append(meta);
278
+ card.append(head);
279
+
280
+ const btn = document.createElement("button");
281
+ btn.className = "link-btn danger";
282
+ btn.type = "button";
283
+ btn.textContent = "Disconnect";
284
+ btn.addEventListener("click", async () => {
285
+ btn.disabled = true;
286
+ btn.textContent = "Disconnecting…";
287
+ try {
288
+ await postJSON("/api/engines/disconnect", { kind: e.kind });
289
+ } catch {
290
+ /* poll re-syncs truth */
291
+ }
292
+ await poll();
293
+ });
294
+ card.append(btn);
295
+ return card;
296
+ }
297
+
298
+ // --- add-engine modal -------------------------------------------------------
299
+
300
+ function openModal() {
301
+ $("engine-modal").hidden = false;
302
+ }
303
+ function closeModal() {
304
+ $("engine-modal").hidden = true;
305
+ $("engine-modal-body").replaceChildren();
306
+ $("engine-modal-title").textContent = "Add engine";
307
+ }
308
+
309
+ function openAddEngine() {
310
+ $("engine-modal-title").textContent = "Add engine";
311
+ const body = $("engine-modal-body");
312
+ body.replaceChildren();
313
+
314
+ const catalog = latestEngines?.catalog || [];
315
+ const statuses = latestEngines?.statuses || {};
316
+ const available = catalog.filter((e) => !statuses[e.kind]);
317
+
318
+ if (available.length === 0) {
319
+ const p = document.createElement("p");
320
+ p.className = "empty";
321
+ p.textContent = "All engines are connected.";
322
+ body.append(p);
323
+ openModal();
324
+ return;
325
+ }
326
+
327
+ const list = document.createElement("div");
328
+ list.className = "engine-picker";
329
+ for (const e of available) {
330
+ const row = document.createElement("button");
331
+ row.className = "engine-pick";
332
+ row.type = "button";
333
+ row.append(monogram(e.label));
334
+ const meta = document.createElement("div");
335
+ meta.className = "engine-meta";
336
+ const name = document.createElement("div");
337
+ name.className = "engine-name";
338
+ name.textContent = e.label;
339
+ if (e.notes) {
340
+ const sub = document.createElement("div");
341
+ sub.className = "engine-sub";
342
+ sub.textContent = e.notes;
343
+ meta.append(name, sub);
344
+ } else {
345
+ meta.append(name);
346
+ }
347
+ row.append(meta);
348
+ const chev = document.createElement("span");
349
+ chev.className = "chev";
350
+ chev.textContent = "›";
351
+ row.append(chev);
352
+ row.addEventListener("click", () => setupEngine(e));
353
+ list.append(row);
354
+ }
355
+ body.append(list);
356
+ openModal();
357
+ }
358
+
359
+ function setupEngine(e) {
360
+ $("engine-modal-title").textContent = `Connect ${e.label}`;
361
+ const body = $("engine-modal-body");
362
+ body.replaceChildren();
363
+
364
+ if (e.authMode === "api-key") {
365
+ body.append(apiKeyForm(e));
366
+ } else {
367
+ body.append(commandForm(e));
368
+ }
369
+
370
+ const back = document.createElement("button");
371
+ back.className = "link-btn back";
372
+ back.type = "button";
373
+ back.textContent = "‹ Back";
374
+ back.addEventListener("click", openAddEngine);
375
+ body.append(back);
376
+ }
377
+
378
+ /** api-key mode (Cursor): a masked field + Save + "get a key" link. */
379
+ function apiKeyForm(e) {
380
+ const form = document.createElement("div");
381
+ form.className = "engine-setup";
382
+
383
+ if (e.notes) {
384
+ const note = document.createElement("p");
385
+ note.className = "setup-note";
386
+ note.textContent = e.notes;
387
+ form.append(note);
388
+ }
389
+
390
+ const field = document.createElement("div");
391
+ field.className = "field";
392
+ const input = document.createElement("input");
393
+ input.type = "password";
394
+ input.className = "text-input";
395
+ input.placeholder = `${e.label} API key`;
396
+ input.autocomplete = "off";
397
+ input.spellcheck = false;
398
+ field.append(input);
399
+ form.append(field);
400
+
401
+ const status = document.createElement("div");
402
+ status.className = "setup-status";
403
+ form.append(status);
404
+
405
+ const actions = document.createElement("div");
406
+ actions.className = "setup-actions";
407
+ const save = document.createElement("button");
408
+ save.className = "btn";
409
+ save.type = "button";
410
+ save.textContent = "Save";
411
+ save.addEventListener("click", async () => {
412
+ const apiKey = input.value.trim();
413
+ if (!apiKey) {
414
+ status.className = "setup-status err";
415
+ status.textContent = "Paste a key first.";
416
+ return;
417
+ }
418
+ save.disabled = true;
419
+ save.textContent = "Saving…";
420
+ const result = await runConnect({ kind: e.kind, apiKey });
421
+ if (result.ok) {
422
+ await poll();
423
+ closeModal();
424
+ } else {
425
+ save.disabled = false;
426
+ save.textContent = "Save";
427
+ status.className = "setup-status err";
428
+ status.textContent = result.message || "Couldn't save the key.";
429
+ }
430
+ });
431
+ actions.append(save);
432
+ if (e.getKeyUrl) {
433
+ const link = document.createElement("a");
434
+ link.className = "link-btn";
435
+ link.href = e.getKeyUrl;
436
+ link.target = "_blank";
437
+ link.rel = "noreferrer";
438
+ link.textContent = "Get a key";
439
+ actions.append(link);
440
+ }
441
+ form.append(actions);
442
+ return form;
443
+ }
444
+
445
+ /** token/login mode: a Connect button + a live log; Claude adds a paste link. */
446
+ function commandForm(e) {
447
+ const form = document.createElement("div");
448
+ form.className = "engine-setup";
449
+
450
+ if (e.notes) {
451
+ const note = document.createElement("p");
452
+ note.className = "setup-note";
453
+ note.textContent = e.notes;
454
+ form.append(note);
455
+ }
456
+
457
+ const status = document.createElement("div");
458
+ status.className = "setup-status";
459
+ const log = document.createElement("pre");
460
+ log.className = "log";
461
+ log.hidden = true;
462
+
463
+ const actions = document.createElement("div");
464
+ actions.className = "setup-actions";
465
+ const connect = document.createElement("button");
466
+ connect.className = "btn";
467
+ connect.type = "button";
468
+ connect.textContent = "Connect";
469
+ connect.addEventListener("click", async () => {
470
+ connect.disabled = true;
471
+ connect.textContent = "Connecting…";
472
+ status.className = "setup-status";
473
+ status.textContent =
474
+ e.authMode === "token-command"
475
+ ? "Your browser will open to authorize…"
476
+ : "Your browser will open to sign in…";
477
+ log.hidden = false;
478
+ log.textContent = "";
479
+ const result = await runConnect({ kind: e.kind }, (line) => {
480
+ log.textContent += (log.textContent ? "\n" : "") + line;
481
+ log.scrollTop = log.scrollHeight;
482
+ });
483
+ if (result.ok) {
484
+ await poll();
485
+ closeModal();
486
+ } else {
487
+ connect.disabled = false;
488
+ connect.textContent = "Try again";
489
+ status.className = "setup-status err";
490
+ status.textContent = result.message || "Connect failed.";
491
+ }
492
+ });
493
+ actions.append(connect);
494
+ form.append(actions, status, log);
495
+
496
+ // Claude: a manual paste fallback for when the browser flow isn't possible.
497
+ if (e.authMode === "token-command") {
498
+ const pasteToggle = document.createElement("button");
499
+ pasteToggle.className = "link-btn";
500
+ pasteToggle.type = "button";
501
+ pasteToggle.textContent = "Paste a token instead";
502
+ const pasteWrap = document.createElement("div");
503
+ pasteWrap.className = "engine-setup";
504
+ pasteWrap.hidden = true;
505
+ const pInput = document.createElement("input");
506
+ pInput.type = "password";
507
+ pInput.className = "text-input";
508
+ pInput.placeholder = "sk-ant-oat…";
509
+ pInput.autocomplete = "off";
510
+ pInput.spellcheck = false;
511
+ const pField = document.createElement("div");
512
+ pField.className = "field";
513
+ pField.append(pInput);
514
+ const pStatus = document.createElement("div");
515
+ pStatus.className = "setup-status";
516
+ const pSave = document.createElement("button");
517
+ pSave.className = "btn";
518
+ pSave.type = "button";
519
+ pSave.textContent = "Save token";
520
+ pSave.addEventListener("click", async () => {
521
+ const pastedToken = pInput.value.trim();
522
+ if (!pastedToken) {
523
+ pStatus.className = "setup-status err";
524
+ pStatus.textContent = "Paste a token first.";
525
+ return;
526
+ }
527
+ pSave.disabled = true;
528
+ pSave.textContent = "Saving…";
529
+ const result = await runConnect({ kind: e.kind, pastedToken });
530
+ if (result.ok) {
531
+ await poll();
532
+ closeModal();
533
+ } else {
534
+ pSave.disabled = false;
535
+ pSave.textContent = "Save token";
536
+ pStatus.className = "setup-status err";
537
+ pStatus.textContent = result.message || "Couldn't save the token.";
538
+ }
539
+ });
540
+ const pActions = document.createElement("div");
541
+ pActions.className = "setup-actions";
542
+ pActions.append(pSave);
543
+ pasteWrap.append(pField, pActions, pStatus);
544
+ pasteToggle.addEventListener("click", () => {
545
+ pasteWrap.hidden = !pasteWrap.hidden;
546
+ });
547
+ form.append(pasteToggle, pasteWrap);
548
+ }
549
+ return form;
550
+ }
551
+
552
+ /**
553
+ * POST /api/engines/connect and consume the NDJSON stream: `{line}` frames feed
554
+ * onLine; the final `{done,ok,message}` frame is the result.
555
+ */
556
+ async function runConnect(body, onLine) {
557
+ let res;
558
+ try {
559
+ res = await fetch("/api/engines/connect", {
560
+ method: "POST",
561
+ headers: { "content-type": "application/json" },
562
+ body: JSON.stringify(body),
563
+ });
564
+ } catch (err) {
565
+ return { ok: false, message: String(err) };
566
+ }
567
+ if (!res.ok || !res.body) {
568
+ return { ok: false, message: `connect failed (${res.status})` };
569
+ }
570
+ const reader = res.body.getReader();
571
+ const dec = new TextDecoder();
572
+ let buf = "";
573
+ let result = null;
574
+ for (;;) {
575
+ const { value, done } = await reader.read();
576
+ if (done) break;
577
+ buf += dec.decode(value, { stream: true });
578
+ let nl;
579
+ while ((nl = buf.indexOf("\n")) >= 0) {
580
+ const raw = buf.slice(0, nl);
581
+ buf = buf.slice(nl + 1);
582
+ if (!raw.trim()) continue;
583
+ let msg;
584
+ try {
585
+ msg = JSON.parse(raw);
586
+ } catch {
587
+ continue;
588
+ }
589
+ if (typeof msg.line === "string" && onLine) onLine(msg.line);
590
+ if (msg.done) result = { ok: !!msg.ok, message: msg.message || "" };
591
+ }
592
+ }
593
+ return result || { ok: false, message: "Connection ended unexpectedly." };
594
+ }
595
+
219
596
  // --- formatting -------------------------------------------------------------
220
597
 
221
598
  function fmtDuration(ms) {
@@ -260,5 +637,15 @@ $("badge").addEventListener("click", () => {
260
637
  $("badge").setAttribute("aria-expanded", String(show));
261
638
  });
262
639
 
640
+ $("add-engine").addEventListener("click", openAddEngine);
641
+ $("engine-modal-close").addEventListener("click", closeModal);
642
+ $("engine-modal").addEventListener("click", (e) => {
643
+ // Click the backdrop (not the dialog) to dismiss.
644
+ if (e.target === $("engine-modal")) closeModal();
645
+ });
646
+ document.addEventListener("keydown", (e) => {
647
+ if (e.key === "Escape" && !$("engine-modal").hidden) closeModal();
648
+ });
649
+
263
650
  poll();
264
651
  setInterval(poll, 2000);
package/ui/index.html CHANGED
@@ -5,14 +5,14 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Uai host</title>
7
7
  <!-- Local monitor UI (ADR-028). Served from 127.0.0.1 by the host service. -->
8
- <link rel="icon" type="image/svg+xml" href="/uai-logo-black.svg" />
8
+ <link rel="icon" type="image/svg+xml" href="/uai-favicon.svg" />
9
9
  <link rel="stylesheet" href="/style.css" />
10
10
  </head>
11
11
  <body>
12
12
  <main>
13
13
  <header class="topbar">
14
14
  <div class="brand">
15
- <img class="logo" src="/uai-logo-black.svg" alt="Uai" />
15
+ <img class="logo" src="/uai-wheel.svg" alt="Uai" />
16
16
  <div class="brand-text">
17
17
  <div class="host-name" id="host-name">…</div>
18
18
  <div class="host-sub" id="host-sub">host monitor</div>
@@ -32,6 +32,18 @@
32
32
  <dl class="kv" id="service-kv"></dl>
33
33
  </section>
34
34
 
35
+ <section class="panel">
36
+ <h2>
37
+ Engines <span class="count" id="engines-count"></span>
38
+ <button class="link-btn add-engine" id="add-engine" type="button">
39
+ + Add engine
40
+ </button>
41
+ </h2>
42
+ <div id="engines" class="engines">
43
+ <p class="empty" id="engines-empty">No engines connected.</p>
44
+ </div>
45
+ </section>
46
+
35
47
  <section class="panel">
36
48
  <h2>Active tasks <span class="count" id="tasks-count"></span></h2>
37
49
  <div id="tasks" class="tasks">
@@ -47,9 +59,32 @@
47
59
  </section>
48
60
 
49
61
  <footer class="foot">
50
- <span id="foot-note">read-only · 127.0.0.1 · polls every 2s</span>
62
+ <span id="foot-note">127.0.0.1 · polls every 2s</span>
51
63
  </footer>
52
64
  </main>
65
+
66
+ <div class="modal-backdrop" id="engine-modal" hidden>
67
+ <div
68
+ class="modal"
69
+ role="dialog"
70
+ aria-modal="true"
71
+ aria-labelledby="engine-modal-title"
72
+ >
73
+ <div class="modal-head">
74
+ <h3 id="engine-modal-title">Add engine</h3>
75
+ <button
76
+ class="modal-close"
77
+ id="engine-modal-close"
78
+ type="button"
79
+ aria-label="Close"
80
+ >
81
+ ×
82
+ </button>
83
+ </div>
84
+ <div class="modal-body" id="engine-modal-body"></div>
85
+ </div>
86
+ </div>
87
+
53
88
  <script src="/app.js"></script>
54
89
  </body>
55
90
  </html>