@runuai/host 0.8.4 → 0.8.6
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/images/standard/Dockerfile +9 -0
- package/lib/agents/cursor.ts +324 -0
- package/lib/agents/factory.ts +1 -0
- package/lib/engines.ts +486 -0
- package/lib/standard-image.ts +11 -2
- package/package.json +1 -1
- package/src/main.ts +3 -0
- package/src/ui/server.ts +130 -1
- package/src/ui/types.ts +23 -0
- package/ui/app.js +389 -2
- package/ui/index.html +36 -1
- package/ui/style.css +262 -0
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
|
-
`
|
|
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
|
@@ -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">
|
|
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>
|