@monster-orange/den 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +1 -1
  3. package/bin/den +7 -0
  4. package/docs/distributions.md +255 -12
  5. package/package.json +4 -2
  6. package/src/pillars/journeys/sandbox.ts +2 -5
  7. package/src/pillars/security/discover.ts +17 -1
  8. package/src/pillars/security/run.ts +31 -3
  9. package/src/shared/agent-actor.ts +77 -0
  10. package/src/shared/den-config.ts +122 -0
  11. package/src/shared/edge/status.ts +74 -0
  12. package/src/shared/environment-manager/health.ts +54 -8
  13. package/src/shared/environment-manager/load-applications.ts +31 -1
  14. package/src/shared/environment-manager/log-tail.ts +5 -4
  15. package/src/shared/environment-manager/opctl.ts +2 -1
  16. package/src/shared/environment-manager/service-container.ts +47 -0
  17. package/src/shared/environment-manager/service-runner.ts +16 -1
  18. package/src/shared/environment-manager/sync-targets.ts +66 -0
  19. package/src/shared/environment-manager/up-plan.ts +25 -1
  20. package/src/shared/manifest/merge-override.ts +75 -0
  21. package/src/shared/manifest/placement.ts +321 -0
  22. package/src/shared/manifest/read-manifest.ts +156 -38
  23. package/src/shared/manifest/schema.ts +14 -0
  24. package/src/shared/manifest/validate-layers.ts +229 -0
  25. package/src/shared/manifest/validate-manifest.ts +66 -6
  26. package/src/shared/paths.ts +12 -0
  27. package/src/shared/redact.ts +107 -0
  28. package/src/shared/stacks/manifest-commands.ts +30 -0
  29. package/src/shared/stacks/status.ts +99 -0
  30. package/src/shared/types/index.ts +23 -0
  31. package/src/ui/cli/applications-command.ts +34 -2
  32. package/src/ui/cli/edge-command.ts +9 -19
  33. package/src/ui/cli/graph-command.ts +50 -4
  34. package/src/ui/cli/index.ts +131 -45
  35. package/src/ui/cli/mcp-main.ts +13 -0
  36. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  37. package/src/ui/cli/stack-command.ts +13 -40
  38. package/src/ui/cli/test-runner.ts +8 -1
  39. package/src/ui/mcp/api.ts +284 -0
  40. package/src/ui/mcp/guards.ts +141 -0
  41. package/src/ui/mcp/server.ts +147 -0
  42. package/src/ui/mcp/tools.ts +565 -0
  43. package/src/ui/server/audit.ts +71 -0
  44. package/src/ui/server/changelog-parser.ts +11 -0
  45. package/src/ui/server/graph-prefs.ts +89 -0
  46. package/src/ui/server/health-row.ts +15 -0
  47. package/src/ui/server/index.ts +175 -61
  48. package/src/ui/server/log-history.ts +4 -3
  49. package/src/ui/server/public/environments.html +4 -2
  50. package/src/ui/server/public/graph-model.js +416 -0
  51. package/src/ui/server/public/graph.css +976 -0
  52. package/src/ui/server/public/graph.js +986 -0
  53. package/src/ui/server/public/header.js +7 -5
  54. package/src/ui/server/public/local-dev.html +130 -2191
  55. package/src/ui/server/public/logs.html +11 -4
  56. package/src/ui/server/public/panel.js +787 -0
  57. package/src/ui/server/public/services.html +6 -1
  58. package/src/ui/server/readonly-routes.ts +200 -0
  59. package/src/ui/server/ui-assets.ts +73 -0
  60. package/src/ui/server/workspace-routes.ts +39 -8
@@ -0,0 +1,787 @@
1
+ /**
2
+ * The graph's details panel. Everything in it comes from three registries,
3
+ * so a new panel (tests, security, releases, a distribution's own) is one
4
+ * entry in each:
5
+ *
6
+ * ACTIONS { id, label, for: ["service"|"repo"], when(x), tier: "common"|"commands"|"tools"|"danger",
7
+ * primaryWhen?(x), disabled?(x) → reason string, confirm?: true, run(x) }
8
+ * TABS { id, label, for, when?(x), order }
9
+ * SECTIONS { id, tab, title?, for, when?(x), order, render(x) → HTMLElement|string }
10
+ *
11
+ * Kinds: "service" (x = a model service) and "repo" (x = { repo, services }).
12
+ * Tiering (graph-model.js resolveActions): one primary, up to two common
13
+ * actions beside it, the rest in More grouped as Manifest commands / Tools /
14
+ * Careful; Careful actions confirm inline on a second click.
15
+ *
16
+ * graph.js binds the context (model, hooks, view helpers) with bindPanel().
17
+ */
18
+ import {
19
+ commandTier,
20
+ connections,
21
+ entryOf,
22
+ fmtMs,
23
+ healthLine,
24
+ isLocal,
25
+ orgOf,
26
+ placeGlyph,
27
+ placementText,
28
+ remoteWhy,
29
+ repoPlacement,
30
+ resolveActions,
31
+ status,
32
+ statusWord,
33
+ } from "/graph-model.js";
34
+
35
+ /** ctx: { model(), st, hooks, toast(msg), selectTab(id), focusOn(sel), refresh() } */
36
+ let C = null;
37
+ export function bindPanel(ctx) {
38
+ C = ctx;
39
+ }
40
+
41
+ export const esc = (s) =>
42
+ String(s ?? "").replace(
43
+ /[&<>"']/g,
44
+ (c) =>
45
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
46
+ c
47
+ ],
48
+ );
49
+
50
+ const has = (s, c) => s.commands.includes(c);
51
+ /** Runs here: local, has a runtime, and is on disk. */
52
+ const runsHere = (s) => isLocal(s) && s.runs !== "none" && s.cloned;
53
+ const repoEntry = (r) => entryOf(C.model(), r.repo);
54
+ const anyHere = (r) => r.services.some(runsHere);
55
+ const LIFECYCLE = ["up", "down", "restart"];
56
+
57
+ export const ACTIONS = [
58
+ // ── service ──
59
+ {
60
+ id: "start",
61
+ label: "Start",
62
+ for: ["service"],
63
+ tier: "common",
64
+ when: (s) => runsHere(s) && has(s, "up") && s.health.ok !== true,
65
+ primaryWhen: (s) => s.health.ok === false,
66
+ run: (s) => C.hooks.runCmd(s, "up"),
67
+ },
68
+ {
69
+ id: "start-remote",
70
+ label: "Start",
71
+ for: ["service"],
72
+ tier: "common",
73
+ when: (s) => !isLocal(s),
74
+ disabled: remoteWhy,
75
+ run: () => {},
76
+ },
77
+ {
78
+ id: "open",
79
+ label: "Open ↗",
80
+ for: ["service"],
81
+ tier: "common",
82
+ when: (s) => !!(s.open || s.target?.url),
83
+ primaryWhen: (s) => s.health.ok !== false || !isLocal(s),
84
+ run: (s) => window.open(s.open || s.target.url, "_blank", "noopener"),
85
+ },
86
+ {
87
+ id: "logs",
88
+ label: "Logs",
89
+ for: ["service"],
90
+ tier: "common",
91
+ when: runsHere,
92
+ primaryWhen: () => true,
93
+ run: () => C.selectTab("logs"),
94
+ },
95
+ {
96
+ id: "restart",
97
+ label: "Restart",
98
+ for: ["service"],
99
+ tier: "common",
100
+ when: (s) => runsHere(s) && has(s, "restart"),
101
+ run: (s) => C.hooks.runCmd(s, "restart"),
102
+ },
103
+ {
104
+ id: "up-deps",
105
+ label: "Start with dependencies",
106
+ for: ["service"],
107
+ tier: "common",
108
+ when: (s) => runsHere(s) && has(s, "up"),
109
+ run: (s) => C.hooks.upWithDeps(s.repo),
110
+ },
111
+ // ── repo (system-level: runs against the repo's entrypoint, as the CLI does) ──
112
+ {
113
+ id: "repo-start",
114
+ label: "Start",
115
+ for: ["repo"],
116
+ tier: "common",
117
+ when: (r) => runsHere(repoEntry(r)) && has(repoEntry(r), "up"),
118
+ primaryWhen: (r) =>
119
+ r.services.some((s) => runsHere(s) && s.health.ok === false),
120
+ run: (r) => C.hooks.runCmd(repoEntry(r), "up"),
121
+ },
122
+ {
123
+ id: "repo-logs",
124
+ label: "Logs",
125
+ for: ["repo"],
126
+ tier: "common",
127
+ when: anyHere,
128
+ primaryWhen: () => true,
129
+ run: (r) => C.focusOn({ id: repoEntry(r).id, tab: "logs" }),
130
+ },
131
+ {
132
+ id: "repo-restart",
133
+ label: "Restart",
134
+ for: ["repo"],
135
+ tier: "common",
136
+ when: (r) => runsHere(repoEntry(r)) && has(repoEntry(r), "restart"),
137
+ run: (r) => C.hooks.runCmd(repoEntry(r), "restart"),
138
+ },
139
+ {
140
+ id: "repo-up-deps",
141
+ label: "Start with dependencies",
142
+ for: ["repo"],
143
+ tier: "common",
144
+ when: anyHere,
145
+ run: (r) => C.hooks.upWithDeps(r.repo),
146
+ },
147
+ // ── tools ──
148
+ {
149
+ id: "terminal",
150
+ label: "Terminal in repo",
151
+ for: ["service", "repo"],
152
+ tier: "tools",
153
+ when: (x) => {
154
+ const s = x.services ? x.services[0] : x;
155
+ return isLocal(s) && s.cloned && !!s.path;
156
+ },
157
+ run: (x) => C.hooks.openTerminal((x.services ? x.services[0] : x).path),
158
+ },
159
+ {
160
+ id: "editor",
161
+ label: "Open in editor",
162
+ for: ["service", "repo"],
163
+ tier: "tools",
164
+ when: (x) => {
165
+ const s = x.services ? x.services[0] : x;
166
+ return isLocal(s) && s.cloned && !!s.path;
167
+ },
168
+ run: (x) => C.hooks.openEditor((x.services ? x.services[0] : x).path),
169
+ },
170
+ {
171
+ id: "logs-page",
172
+ label: "Open in Logs",
173
+ for: ["service"],
174
+ tier: "tools",
175
+ when: runsHere,
176
+ run: (s) => C.hooks.openLogsPage(s),
177
+ },
178
+ {
179
+ id: "output",
180
+ label: "Show output pane",
181
+ for: ["service", "repo"],
182
+ tier: "tools",
183
+ when: (x) => (x.services ? anyHere(x) : runsHere(x)),
184
+ run: (x) => C.hooks.showOutput(x.services ? repoEntry(x) : x),
185
+ },
186
+ {
187
+ id: "recheck",
188
+ label: "Re-check health",
189
+ for: ["service", "repo"],
190
+ tier: "tools",
191
+ when: () => true,
192
+ run: (x) => C.recheck(x.services ? x.services : [x]),
193
+ },
194
+ {
195
+ id: "autostart",
196
+ label: "Auto-start",
197
+ for: ["service", "repo"],
198
+ tier: "tools",
199
+ when: (x) => isLocal(x.services ? x.services[0] : x),
200
+ labelFor: (x) =>
201
+ (x.services ? x.services[0] : x).autostart
202
+ ? "Auto-start: on (turn off)"
203
+ : "Auto-start: off (turn on)",
204
+ run: (x) => {
205
+ const s = x.services ? x.services[0] : x;
206
+ C.hooks.toggleAutostart(s.repo, !s.autostart);
207
+ },
208
+ },
209
+ {
210
+ id: "mode",
211
+ label: "Switch mode",
212
+ for: ["service", "repo"],
213
+ tier: "tools",
214
+ when: (x) => isLocal(x.services ? x.services[0] : x),
215
+ labelFor: (x) =>
216
+ (x.services ? x.services[0] : x).mode === "managed"
217
+ ? "Use my local checkout"
218
+ : "Use a managed clone",
219
+ run: (x) => {
220
+ const s = x.services ? x.services[0] : x;
221
+ C.hooks.switchMode(
222
+ s.repo,
223
+ s.mode === "managed" ? "dev-managed" : "managed",
224
+ );
225
+ },
226
+ },
227
+ {
228
+ id: "sync",
229
+ label: "Sync",
230
+ for: ["service", "repo"],
231
+ tier: "tools",
232
+ when: (x) => {
233
+ const s = x.services ? x.services[0] : x;
234
+ return isLocal(s) && s.mode === "managed";
235
+ },
236
+ labelFor: (x) =>
237
+ (x.services ? x.services : [x]).some((s) => !s.cloned)
238
+ ? "Clone"
239
+ : "Sync (down, pull, setup)",
240
+ run: (x) => C.hooks.sync(x.repo),
241
+ },
242
+ {
243
+ id: "copy",
244
+ label: "Copy address",
245
+ for: ["service"],
246
+ tier: "tools",
247
+ when: () => true,
248
+ run: (s) => {
249
+ navigator.clipboard?.writeText(s.id).then(
250
+ () => C.toast("Copied " + s.id),
251
+ () => {},
252
+ );
253
+ },
254
+ },
255
+ // ── careful ──
256
+ {
257
+ id: "stop",
258
+ label: "Stop",
259
+ for: ["service"],
260
+ tier: "danger",
261
+ confirm: true,
262
+ when: (s) => runsHere(s) && has(s, "down"),
263
+ run: (s) => C.hooks.runCmd(s, "down"),
264
+ },
265
+ {
266
+ id: "down-deps",
267
+ label: "Stop with dependencies",
268
+ for: ["service"],
269
+ tier: "danger",
270
+ confirm: true,
271
+ when: (s) => runsHere(s) && has(s, "down"),
272
+ run: (s) => C.hooks.downWithDeps(s.repo),
273
+ },
274
+ {
275
+ id: "repo-stop",
276
+ label: "Stop",
277
+ for: ["repo"],
278
+ tier: "danger",
279
+ confirm: true,
280
+ when: (r) => runsHere(repoEntry(r)) && has(repoEntry(r), "down"),
281
+ run: (r) => C.hooks.runCmd(repoEntry(r), "down"),
282
+ },
283
+ {
284
+ id: "repo-down-deps",
285
+ label: "Stop with dependencies",
286
+ for: ["repo"],
287
+ tier: "danger",
288
+ confirm: true,
289
+ when: anyHere,
290
+ run: (r) => C.hooks.downWithDeps(r.repo),
291
+ },
292
+ ];
293
+
294
+ /** The manifest's own commands (setup, seed, reset-db, …) become actions too. */
295
+ function commandActions(s) {
296
+ if (!runsHere(s)) return [];
297
+ return s.commands
298
+ .filter((c) => !LIFECYCLE.includes(c))
299
+ .map((c) => ({
300
+ id: "cmd:" + c,
301
+ label: "Run " + c,
302
+ for: ["service"],
303
+ tier: commandTier(c),
304
+ confirm: commandTier(c) === "danger",
305
+ when: () => true,
306
+ run: (x) => C.hooks.runCmd(x, c),
307
+ }));
308
+ }
309
+
310
+ export function actionsFor(x) {
311
+ const kind = x.services ? "repo" : "service";
312
+ return resolveActions(
313
+ [...ACTIONS, ...(kind === "service" ? commandActions(x) : [])],
314
+ x,
315
+ kind,
316
+ );
317
+ }
318
+
319
+ export const TABS = [
320
+ { id: "services", label: "Services", for: ["repo"], order: 0 },
321
+ { id: "overview", label: "Overview", for: ["service", "repo"], order: 1 },
322
+ { id: "logs", label: "Logs", for: ["service"], when: runsHere, order: 2 },
323
+ { id: "config", label: "Config", for: ["service", "repo"], order: 3 },
324
+ { id: "activity", label: "Activity", for: ["service", "repo"], order: 4 },
325
+ ];
326
+
327
+ const kv = (rows) =>
328
+ `<dl class="kv">${rows
329
+ .filter(Boolean)
330
+ .map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`)
331
+ .join("")}</dl>`;
332
+ const none = (t) => `<span class="dg-none">${esc(t)}</span>`;
333
+ const checkText = (s) =>
334
+ s.health.url
335
+ ? `<code>GET ${esc(s.health.url)}</code>`
336
+ : s.health.check
337
+ ? `<code>${esc(s.health.check)}</code>`
338
+ : none("none declared");
339
+ function chip(id) {
340
+ const s = C.model().byId.get(id);
341
+ if (!s) return `<span class="dg-chip">${esc(id)}</span>`;
342
+ return `<button class="dg-chip" data-goto="${esc(id)}"><span class="dg-dot ${status(s)}"></span>${esc(id)}${s.runs === "remote" ? " ☁" : ""}</button>`;
343
+ }
344
+ const refChip = (r) =>
345
+ r.id
346
+ ? chip(r.id)
347
+ : `<button class="dg-chip" data-goto-repo="${esc(r.repo)}">${esc(r.repo)} ${placeGlyph(repoPlacement(C.model(), r.repo))}</button>`;
348
+ function logsText(l) {
349
+ if (!l) return null;
350
+ if (typeof l === "string") return l;
351
+ if (l.type === "shell") return l.cmd;
352
+ if (l.type === "cloudwatch")
353
+ return `cloudwatch ${l.logGroup}${l.logStream ? " / " + l.logStream : ""}`;
354
+ return l.type;
355
+ }
356
+ const hhmm = (ts) => {
357
+ const d = new Date(ts);
358
+ return isNaN(d) ? "" : d.toTimeString().slice(0, 5);
359
+ };
360
+
361
+ export const SECTIONS = [
362
+ // Overview: is it OK, where is it, what does it touch
363
+ {
364
+ id: "remote-note",
365
+ tab: "overview",
366
+ for: ["service"],
367
+ order: 0,
368
+ when: (s) => !isLocal(s),
369
+ render: (s) =>
370
+ `<p class="dg-callout">☁ Runs in <b>${esc(s.target?.env || "a remote env")}</b>${s.target?.account ? " · " + esc(s.target.account) : ""}. Start, stop and logs happen there, not on this machine.</p>`,
371
+ },
372
+ {
373
+ id: "health",
374
+ tab: "overview",
375
+ title: "Health",
376
+ for: ["service"],
377
+ order: 10,
378
+ render: (s) =>
379
+ kv([
380
+ [
381
+ "Status",
382
+ `<span class="dg-pill ${status(s)}">${esc(statusWord(s))}</span> ${s.health.ms != null ? `<span class="dg-muted">${fmtMs(s.health.ms)}</span>` : ""}${s.health.error ? ` <span class="dg-muted">${esc(s.health.error)}</span>` : ""}`,
383
+ ],
384
+ ["Check", checkText(s)],
385
+ (s.open || s.target?.url) && [
386
+ "Opens",
387
+ `<a href="${esc(s.open || s.target.url)}" target="_blank" rel="noopener">${esc(s.open || s.target.url)}</a>`,
388
+ ],
389
+ ]),
390
+ },
391
+ {
392
+ id: "health-roll",
393
+ tab: "overview",
394
+ title: "Health",
395
+ for: ["repo"],
396
+ order: 10,
397
+ render: (r) => {
398
+ const ok = r.services.filter((s) => s.health.ok).length;
399
+ const p = repoPlacement(C.model(), r.repo);
400
+ return kv([
401
+ [
402
+ "Services",
403
+ `<span class="dg-pill ${ok === r.services.length ? "ok" : "bad"}">${ok}/${r.services.length} healthy</span>`,
404
+ ],
405
+ [
406
+ "Placement",
407
+ esc(
408
+ {
409
+ local: "⌂ all local",
410
+ remote: "☁ all remote",
411
+ none: "no runtime",
412
+ hybrid:
413
+ "⇄ hybrid: " +
414
+ r.services.map((s) => `${s.name} ${s.runs}`).join(", "),
415
+ }[p],
416
+ ),
417
+ ],
418
+ ]);
419
+ },
420
+ },
421
+ {
422
+ id: "connections",
423
+ tab: "overview",
424
+ title: "Connections",
425
+ for: ["service", "repo"],
426
+ order: 20,
427
+ render: (x) => {
428
+ const c = connections(C.model(), x);
429
+ return `<div class="dg-conn"><div><h4>Depends on</h4>${c.deps.length ? `<div class="dg-chips">${c.deps.map(refChip).join("")}</div>` : none("nothing")}</div><div><h4>Used by</h4>${c.users.length ? `<div class="dg-chips">${c.users.map(refChip).join("")}</div>` : none("nothing in this workspace")}</div></div>`;
430
+ },
431
+ },
432
+ {
433
+ id: "missing",
434
+ tab: "overview",
435
+ title: "Missing dependencies",
436
+ for: ["service", "repo"],
437
+ order: 25,
438
+ when: (x) => (x.services ? x.services : [x]).some((s) => s.missing.length),
439
+ render: (x) => {
440
+ const deps = [
441
+ ...new Set((x.services ? x.services : [x]).flatMap((s) => s.missing)),
442
+ ];
443
+ return `<p class="dg-muted" style="margin:0 0 8px;font-size:13px">Declared but not in this workspace.</p><ul class="cmds">${deps.map((d) => `<li><code>${esc(d)}</code><button class="dg-btn sm" data-add-dep="${esc(d)}">Add</button></li>`).join("")}</ul>`;
444
+ },
445
+ },
446
+ {
447
+ id: "about",
448
+ tab: "overview",
449
+ title: "About",
450
+ for: ["service"],
451
+ order: 30,
452
+ render: (s) =>
453
+ kv([
454
+ [
455
+ "Role",
456
+ [s.entry && "entry point", s.infra && "infrastructure"]
457
+ .filter(Boolean)
458
+ .join(", ") || "service",
459
+ ],
460
+ ["Application", esc(s.app)],
461
+ ["Branch", s.branch ? `⎇ ${esc(s.branch)}` : none("unknown")],
462
+ [
463
+ "Starts",
464
+ s.autostart ? "with the workspace" : "on demand (auto-start off)",
465
+ ],
466
+ !s.cloned && ["On disk", "not cloned yet: Sync clones it"],
467
+ ]),
468
+ },
469
+ {
470
+ id: "about-repo",
471
+ tab: "overview",
472
+ title: "About",
473
+ for: ["repo"],
474
+ order: 30,
475
+ render: (r) =>
476
+ kv([
477
+ ["Application", esc(r.services[0].app)],
478
+ [
479
+ "Branch",
480
+ r.services[0].branch
481
+ ? `⎇ ${esc(r.services[0].branch)}`
482
+ : none("unknown"),
483
+ ],
484
+ ["Mode", esc(r.services[0].mode)],
485
+ [
486
+ "Starts",
487
+ r.services[0].autostart
488
+ ? "with the workspace"
489
+ : "on demand (auto-start off)",
490
+ ],
491
+ ]),
492
+ },
493
+ // Services (repo): each row with its own primary action
494
+ {
495
+ id: "svc-list",
496
+ tab: "services",
497
+ for: ["repo"],
498
+ order: 0,
499
+ render: (r) =>
500
+ `<table class="svc"><tbody>${r.services
501
+ .map((s) => {
502
+ const a = actionsFor(s);
503
+ return `<tr><td><span class="dg-dot ${status(s)}"></span></td><td><button class="nm" data-goto="${esc(s.id)}">${esc(s.name)}</button>${s.entry ? ' <span class="dg-tag entry">entry</span>' : ""}${!isLocal(s) ? ' <span class="dg-badge remote">☁ ' + esc(s.target?.env || "remote") + "</span>" : ""}<div class="h">${esc(healthLine(s))}</div></td><td class="a">${a.primary ? actBtn(a.primary, s, "sm") : ""}</td></tr>`;
504
+ })
505
+ .join("")}</tbody></table>`,
506
+ },
507
+ // Logs: the live tail inline
508
+ {
509
+ id: "logs",
510
+ tab: "logs",
511
+ for: ["service"],
512
+ order: 0,
513
+ render: (s) => logTail(s),
514
+ },
515
+ // Config
516
+ {
517
+ id: "commands",
518
+ tab: "config",
519
+ title: "Commands",
520
+ for: ["service"],
521
+ order: 10,
522
+ render: (s) =>
523
+ s.commands.length
524
+ ? `<ul class="cmds">${s.commands.map((c) => `<li><code>${esc(c)}</code>${runsHere(s) ? `<button class="dg-btn sm" data-run="${esc(c)}" data-key="${esc(s.id)}" ${commandTier(c) === "danger" || c === "down" ? "data-confirm" : ""}>Run</button>` : ""}</li>`).join("")}</ul>`
525
+ : none(isLocal(s) ? "none declared" : "none here: it runs remotely"),
526
+ },
527
+ {
528
+ id: "source",
529
+ tab: "config",
530
+ title: "Where it comes from",
531
+ for: ["service", "repo"],
532
+ order: 20,
533
+ render: (x) => {
534
+ const s = x.services ? x.services[0] : x;
535
+ return kv([
536
+ ["Repo", s.path ? `<code>${esc(s.path)}</code>` : none("unknown")],
537
+ [
538
+ "Manifest",
539
+ s.manifestLayers.length
540
+ ? `<span class="dg-layers">${s.manifestLayers.map((l) => `<span>${esc(l)}</span>`).join("")}</span>`
541
+ : none("none found"),
542
+ ],
543
+ ["Mode", esc(s.mode)],
544
+ !x.services && [
545
+ "Logs from",
546
+ logsText(s.logs)
547
+ ? `<code>${esc(logsText(s.logs))}</code>`
548
+ : isLocal(s)
549
+ ? none("the service's own log file")
550
+ : none("the remote env"),
551
+ ],
552
+ !x.services && ["Health check", checkText(s)],
553
+ ]);
554
+ },
555
+ },
556
+ // Activity: the audit trail (R14), yours and agents'
557
+ {
558
+ id: "activity",
559
+ tab: "activity",
560
+ for: ["service", "repo"],
561
+ order: 0,
562
+ render: (x) => activityList(x),
563
+ },
564
+ ];
565
+
566
+ function actBtn(a, x, cls = "") {
567
+ const why = a.disabled?.(x);
568
+ const label = a.labelFor ? a.labelFor(x) : a.label;
569
+ return `<button class="dg-btn ${cls}" data-action="${esc(a.id)}" data-key="${esc(x.services ? "repo:" + x.repo : x.id)}" ${a.confirm ? "data-confirm" : ""} ${why ? `disabled title="${esc(why)}"` : ""}>${esc(label)}</button>`;
570
+ }
571
+ export { actBtn };
572
+
573
+ const GROUP_LABEL = {
574
+ common: "More actions",
575
+ commands: "Manifest commands",
576
+ tools: "Tools",
577
+ danger: "Careful",
578
+ };
579
+ function actionBar(x) {
580
+ const { primary, common, groups } = actionsFor(x);
581
+ const key = esc(x.services ? "repo:" + x.repo : x.id);
582
+ const menu = groups.length
583
+ ? `<div class="dg-more"><button class="dg-btn" data-more aria-haspopup="menu" aria-expanded="false">More ▾</button><div class="dg-menu" role="menu" hidden>${groups
584
+ .map(
585
+ ([t, l]) =>
586
+ `<div class="dg-mg ${t}" role="group" aria-label="${GROUP_LABEL[t]}"><div class="dg-ml">${GROUP_LABEL[t]}</div>${l
587
+ .map((a) => {
588
+ const why = a.disabled?.(x);
589
+ return `<button role="menuitem" data-action="${esc(a.id)}" data-key="${key}" ${a.confirm ? "data-confirm" : ""} ${why ? `disabled title="${esc(why)}"` : ""}>${esc(a.labelFor ? a.labelFor(x) : a.label)}</button>`;
590
+ })
591
+ .join("")}</div>`,
592
+ )
593
+ .join("")}</div></div>`
594
+ : "";
595
+ return `<div class="dg-actions">${primary ? actBtn(primary, x, "primary") : ""}${common.map((a) => actBtn(a, x)).join("")}${menu}</div>`;
596
+ }
597
+
598
+ /** The entity behind a selection: { kind, x }. */
599
+ export function entity(sel) {
600
+ const m = C.model();
601
+ if (sel.repo)
602
+ return m.byRepo.has(sel.repo)
603
+ ? {
604
+ kind: "repo",
605
+ x: { repo: sel.repo, services: m.byRepo.get(sel.repo) },
606
+ }
607
+ : null;
608
+ const s = m.byId.get(sel.id);
609
+ return s ? { kind: "service", x: s } : null;
610
+ }
611
+
612
+ // Live sections (log tail, activity fetch) register a disposer here; a
613
+ // re-render or close disposes them, so one panel holds at most one stream.
614
+ let disposers = [];
615
+ export function disposePanel() {
616
+ for (const d of disposers) d();
617
+ disposers = [];
618
+ }
619
+
620
+ /** Fill `el` with the panel for `sel`. Returns false if the selection is gone. */
621
+ export function renderPanel(el, sel) {
622
+ disposePanel();
623
+ const e = entity(sel);
624
+ if (!e) return false;
625
+ const { kind, x } = e;
626
+ const st = C.st;
627
+ const m = C.model();
628
+ const s0 = kind === "repo" ? x.services[0] : x;
629
+ const org = orgOf(m, s0.org);
630
+ const tabs = TABS.filter(
631
+ (t) => t.for.includes(kind) && (!t.when || t.when(x)),
632
+ ).sort((a, b) => a.order - b.order);
633
+ if (!tabs.find((t) => t.id === st.tab)) st.tab = tabs[0].id;
634
+ const ok =
635
+ kind === "repo" ? x.services.every((s) => s.health.ok) : x.health.ok;
636
+ const word =
637
+ kind === "repo"
638
+ ? `${x.services.filter((s) => s.health.ok).length}/${x.services.length} healthy`
639
+ : statusWord(x);
640
+ const place = kind === "repo" ? repoPlacement(m, x.repo) : x.runs;
641
+ const placeLabel =
642
+ kind === "repo"
643
+ ? {
644
+ local: "⌂ local",
645
+ remote: "☁ remote",
646
+ hybrid: "⇄ hybrid",
647
+ none: "no runtime",
648
+ }[place]
649
+ : placementText(x);
650
+ const pillCls = ok
651
+ ? "ok"
652
+ : kind === "repo"
653
+ ? x.services.some((s) => s.health.ok === false)
654
+ ? "bad"
655
+ : ""
656
+ : ok === false
657
+ ? "bad"
658
+ : "";
659
+ el.innerHTML = `${st.popped ? "" : '<div class="dg-resize" data-resize title="Drag to resize"></div>'}<div class="dg-dh">
660
+ <nav class="dg-crumbs" aria-label="Location"><span style="color:${esc(org.color)}">${esc(org.label)}</span> › <span>${esc(s0.app)}</span>${kind === "service" && m.byRepo.get(x.repo).length > 1 ? ` › <button data-goto-repo-panel="${esc(x.repo)}">${esc(x.repo)}</button>` : ""}</nav>
661
+ <div class="t"><h2>${esc(kind === "repo" ? x.repo : x.name)}</h2><span class="dg-pill ${pillCls}">${esc(word)}</span>${place !== "local" || m.mixed ? `<span class="dg-badge ${place}">${esc(placeLabel)}</span>` : ""}
662
+ <span class="dg-hbtns"><button class="dg-ib" data-popout title="${st.popped ? "Dock as side panel" : "Expand to pop-up"}" aria-label="${st.popped ? "Dock as side panel" : "Expand to pop-up"}">${st.popped ? "⇲" : "⤢"}</button><button class="dg-ib" data-close aria-label="Close details">×</button></span></div>
663
+ <div class="sub">${esc(kind === "repo" ? `${x.services.length} services · ${s0.path}` : x.id)}</div>
664
+ ${actionBar(x)}
665
+ <div class="dg-tabs" role="tablist">${tabs.map((t) => `<button role="tab" aria-selected="${t.id === st.tab}" data-tab="${t.id}">${esc(t.label)}</button>`).join("")}</div>
666
+ </div><div class="dg-pbody" role="tabpanel"></div>`;
667
+ const body = el.querySelector(".dg-pbody");
668
+ for (const c of SECTIONS.filter(
669
+ (c) => c.tab === st.tab && c.for.includes(kind) && (!c.when || c.when(x)),
670
+ ).sort((a, b) => a.order - b.order)) {
671
+ const sec = document.createElement("section");
672
+ sec.className = "dg-sec";
673
+ sec.dataset.section = c.id;
674
+ if (c.title)
675
+ sec.insertAdjacentHTML("beforeend", `<h3>${esc(c.title)}</h3>`);
676
+ const out = c.render(x);
677
+ if (typeof out === "string") sec.insertAdjacentHTML("beforeend", out);
678
+ else if (out) sec.appendChild(out);
679
+ body.appendChild(sec);
680
+ }
681
+ return true;
682
+ }
683
+
684
+ /** Run an action by id for a data-key ("repo:<r>" or a service id). */
685
+ export function runAction(id, key) {
686
+ const e = entity(
687
+ key.startsWith("repo:") ? { repo: key.slice(5) } : { id: key },
688
+ );
689
+ if (!e) return;
690
+ const a = actionsFor(e.x).all.find((y) => y.id === id);
691
+ if (a && !a.disabled?.(e.x)) a.run(e.x);
692
+ }
693
+
694
+ const TAIL_MAX = 500;
695
+ function logTail(s) {
696
+ const wrap = document.createElement("div");
697
+ wrap.innerHTML = `<div class="dg-logbar"><button class="dg-btn sm" data-log="pause" aria-pressed="false">Pause</button><button class="dg-btn sm" data-log="wrap" aria-pressed="false">Wrap</button><input type="search" placeholder="Filter lines" aria-label="Filter log lines"><button class="dg-btn sm" data-log="page">Open in Logs ↗</button></div><pre class="dg-log" aria-live="off"></pre>`;
698
+ const pre = wrap.querySelector("pre");
699
+ const filter = wrap.querySelector("input");
700
+ const lines = [];
701
+ let paused = false,
702
+ queued = false,
703
+ open = true;
704
+ const draw = () => {
705
+ queued = false;
706
+ const q = filter.value.trim().toLowerCase();
707
+ const shown = q ? lines.filter((l) => l.toLowerCase().includes(q)) : lines;
708
+ const atEnd = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8;
709
+ pre.textContent = shown.length
710
+ ? shown.join("\n")
711
+ : open
712
+ ? "Waiting for log lines…"
713
+ : "The log stream ended.";
714
+ if (atEnd) pre.scrollTop = pre.scrollHeight;
715
+ };
716
+ const schedule = () => {
717
+ if (!queued && !paused) {
718
+ queued = true;
719
+ requestAnimationFrame(draw);
720
+ }
721
+ };
722
+ draw();
723
+ const es = new EventSource(
724
+ `/api/service/taillog?${new URLSearchParams({ repo: s.repo, service: s.name })}`,
725
+ );
726
+ es.onmessage = (ev) => {
727
+ let msg;
728
+ try {
729
+ msg = JSON.parse(ev.data);
730
+ } catch {
731
+ return;
732
+ }
733
+ if (msg.type !== "line") return;
734
+ lines.push(msg.text);
735
+ if (lines.length > TAIL_MAX) lines.splice(0, lines.length - TAIL_MAX);
736
+ schedule();
737
+ };
738
+ es.onerror = () => {
739
+ es.close();
740
+ open = false;
741
+ schedule();
742
+ };
743
+ disposers.push(() => es.close());
744
+ filter.addEventListener("input", () => {
745
+ queued = false;
746
+ draw();
747
+ });
748
+ wrap.addEventListener("click", (e) => {
749
+ const b = e.target.closest("[data-log]");
750
+ if (!b) return;
751
+ e.stopPropagation();
752
+ if (b.dataset.log === "pause") {
753
+ paused = !paused;
754
+ b.setAttribute("aria-pressed", String(paused));
755
+ b.textContent = paused ? "Resume" : "Pause";
756
+ if (!paused) draw();
757
+ }
758
+ if (b.dataset.log === "wrap") {
759
+ const on = pre.classList.toggle("wrap");
760
+ b.setAttribute("aria-pressed", String(on));
761
+ }
762
+ if (b.dataset.log === "page") C.hooks.openLogsPage(s);
763
+ });
764
+ return wrap;
765
+ }
766
+
767
+ function activityList(x) {
768
+ const el = document.createElement("div");
769
+ el.innerHTML = none("Loading…");
770
+ const q = x.services
771
+ ? `repo=${encodeURIComponent(x.repo)}`
772
+ : `address=${encodeURIComponent(x.repo + "/" + x.name)}`;
773
+ let live = true;
774
+ disposers.push(() => (live = false));
775
+ fetch(`/api/activity?${q}`)
776
+ .then((r) => (r.ok ? r.json() : { entries: [] }))
777
+ .catch(() => ({ entries: [] }))
778
+ .then(({ entries = [] }) => {
779
+ if (!live) return;
780
+ el.innerHTML = entries.length
781
+ ? `<ol class="act">${entries.map((a) => `<li><time datetime="${esc(a.ts)}">${esc(hhmm(a.ts))}</time><span class="dg-who ${a.actor === "agent" ? "agent" : ""}">${esc(a.actor === "agent" ? "[agent]" + (a.client ? " " + a.client : "") : "you")}</span><span>${esc(a.verb)} ${esc(a.detail)}</span></li>`).join("")}</ol>`
782
+ : none(
783
+ "No actions yet. Starts, restarts and syncs, yours and agents', show here.",
784
+ );
785
+ });
786
+ return el;
787
+ }