@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.
- package/CHANGELOG.md +137 -0
- package/README.md +1 -1
- package/bin/den +7 -0
- package/docs/distributions.md +255 -12
- package/package.json +4 -2
- package/src/pillars/journeys/sandbox.ts +2 -5
- package/src/pillars/security/discover.ts +17 -1
- package/src/pillars/security/run.ts +31 -3
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/den-config.ts +122 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/environment-manager/health.ts +54 -8
- package/src/shared/environment-manager/load-applications.ts +31 -1
- package/src/shared/environment-manager/log-tail.ts +5 -4
- package/src/shared/environment-manager/opctl.ts +2 -1
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-runner.ts +16 -1
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +25 -1
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/placement.ts +321 -0
- package/src/shared/manifest/read-manifest.ts +156 -38
- package/src/shared/manifest/schema.ts +14 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +66 -6
- package/src/shared/paths.ts +12 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/stacks/manifest-commands.ts +30 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/types/index.ts +23 -0
- package/src/ui/cli/applications-command.ts +34 -2
- package/src/ui/cli/edge-command.ts +9 -19
- package/src/ui/cli/graph-command.ts +50 -4
- package/src/ui/cli/index.ts +131 -45
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/stack-command.ts +13 -40
- package/src/ui/cli/test-runner.ts +8 -1
- package/src/ui/mcp/api.ts +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -0
- package/src/ui/server/changelog-parser.ts +11 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/index.ts +175 -61
- package/src/ui/server/log-history.ts +4 -3
- package/src/ui/server/public/environments.html +4 -2
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +976 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +7 -5
- package/src/ui/server/public/local-dev.html +130 -2191
- package/src/ui/server/public/logs.html +11 -4
- package/src/ui/server/public/panel.js +787 -0
- package/src/ui/server/public/services.html +6 -1
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/ui-assets.ts +73 -0
- 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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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
|
+
}
|