@monster-orange/den 0.1.0 → 0.2.1

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