@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.
- package/CHANGELOG.md +204 -0
- package/README.md +1 -1
- package/bin/den +8 -0
- package/docs/distributions.md +283 -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 +19 -2
- 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 +333 -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 +184 -55
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/named-target.ts +11 -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 +286 -0
- package/src/ui/mcp/connect.ts +304 -0
- package/src/ui/mcp/guards.ts +145 -0
- package/src/ui/mcp/server.ts +149 -0
- package/src/ui/mcp/tools.ts +644 -0
- package/src/ui/server/audit.ts +137 -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 +256 -61
- package/src/ui/server/lifecycle-exec.ts +188 -0
- 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 +980 -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 +790 -0
- package/src/ui/server/public/services.html +6 -1
- package/src/ui/server/readonly-routes.ts +209 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +39 -8
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/service/exec` for the lifecycle commands (up, down, restart): run
|
|
3
|
+
* through the service runner, as the CLI does, instead of executing the
|
|
4
|
+
* manifest's command string directly. That gives the web UI and MCP the CLI's
|
|
5
|
+
* semantics:
|
|
6
|
+
*
|
|
7
|
+
* - restart without a declared `restart` is down + up;
|
|
8
|
+
* - up on a service that is already healthy starts nothing ("already up");
|
|
9
|
+
* - up attaches to a running shared instance instead of starting a second;
|
|
10
|
+
* - down honours workspace ownership (refcounted).
|
|
11
|
+
*
|
|
12
|
+
* Events are the route's existing SSE shapes: line, started, service-exited,
|
|
13
|
+
* error, done; plus `ready` once a detached start is up: its health check
|
|
14
|
+
* passes (`checked: true`), or, with no health check, its launcher is still
|
|
15
|
+
* running after a settle window (`checked: false`). A launcher that keeps
|
|
16
|
+
* running in the foreground (a dev server) never sends `service-exited`, so
|
|
17
|
+
* `ready` is how a caller knows the start worked.
|
|
18
|
+
*/
|
|
19
|
+
import type { ChildProcess } from "child_process";
|
|
20
|
+
import type { ResolvedService } from "../../shared/types/index.js";
|
|
21
|
+
import type {
|
|
22
|
+
RunCommandOptions,
|
|
23
|
+
RunCommandResult,
|
|
24
|
+
} from "../../shared/environment-manager/service-runner.js";
|
|
25
|
+
import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
|
|
26
|
+
|
|
27
|
+
export const LIFECYCLE_EXEC_CMDS = ["up", "down", "restart"] as const;
|
|
28
|
+
export type LifecycleExecCmd = (typeof LIFECYCLE_EXEC_CMDS)[number];
|
|
29
|
+
|
|
30
|
+
export function isLifecycleExecCmd(c: string): c is LifecycleExecCmd {
|
|
31
|
+
return (LIFECYCLE_EXEC_CMDS as readonly string[]).includes(c);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LifecycleExecDeps {
|
|
35
|
+
run: (o: RunCommandOptions) => RunCommandResult;
|
|
36
|
+
health: (r: ResolvedService) => Promise<ServiceHealthResult>;
|
|
37
|
+
/** Health poll interval after a detached start (default 1s). */
|
|
38
|
+
pollMs?: number;
|
|
39
|
+
/** Stop polling after this long (default 120s). */
|
|
40
|
+
readyTimeoutMs?: number;
|
|
41
|
+
/** With no health check: ready after this long still running (default 5s). */
|
|
42
|
+
settleMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Restart: a green check counts only after the check went red (the old
|
|
45
|
+
* instance stopped) or this long has passed (default 5s).
|
|
46
|
+
*/
|
|
47
|
+
restartMinMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LifecycleExecOptions {
|
|
51
|
+
resolved: ResolvedService;
|
|
52
|
+
cmd: LifecycleExecCmd;
|
|
53
|
+
workspace?: string;
|
|
54
|
+
env: NodeJS.ProcessEnv;
|
|
55
|
+
emit: (e: Record<string, unknown>) => void;
|
|
56
|
+
/** Called once, after the final `done` event. */
|
|
57
|
+
end: () => void;
|
|
58
|
+
/** Registers a callback for the client going away. */
|
|
59
|
+
onClientClose: (fn: () => void) => void;
|
|
60
|
+
/** Called with the exit code when the command completes. */
|
|
61
|
+
onExit?: (exitCode: number, durationMs: number) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function streamLifecycleExec(
|
|
65
|
+
o: LifecycleExecOptions,
|
|
66
|
+
deps: LifecycleExecDeps,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const { resolved, cmd, emit } = o;
|
|
69
|
+
const start = Date.now();
|
|
70
|
+
let finished = false;
|
|
71
|
+
let clientGone = false;
|
|
72
|
+
const finish = (exitCode: number, explained = false): void => {
|
|
73
|
+
if (finished) return;
|
|
74
|
+
finished = true;
|
|
75
|
+
const duration = Date.now() - start;
|
|
76
|
+
o.onExit?.(exitCode, duration);
|
|
77
|
+
if (clientGone) return;
|
|
78
|
+
if (exitCode !== 0 && !explained)
|
|
79
|
+
emit({ type: "error", text: `Command failed (exit ${exitCode})` });
|
|
80
|
+
emit({ type: "done", exitCode, duration });
|
|
81
|
+
o.end();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let proc: ChildProcess | null = null;
|
|
85
|
+
let onServiceExitHook: (() => void) | undefined;
|
|
86
|
+
o.onClientClose(() => {
|
|
87
|
+
clientGone = true;
|
|
88
|
+
try {
|
|
89
|
+
proc?.kill();
|
|
90
|
+
} catch {
|
|
91
|
+
/* already exited */
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Up on a healthy service is a no-op: starting a second copy only fails on
|
|
96
|
+
// its port (and an agent waits out the detach window to learn that).
|
|
97
|
+
if (cmd === "up") {
|
|
98
|
+
const h = await deps.health(resolved).catch(() => null);
|
|
99
|
+
if (h?.health === "ok") {
|
|
100
|
+
emit({
|
|
101
|
+
type: "line",
|
|
102
|
+
text: `${resolved.address} is already up (healthy) — nothing to start`,
|
|
103
|
+
});
|
|
104
|
+
finish(0);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = deps.run({
|
|
110
|
+
address: resolved.address,
|
|
111
|
+
command: cmd,
|
|
112
|
+
workspace: o.workspace,
|
|
113
|
+
runtime: "daemon",
|
|
114
|
+
env: o.env,
|
|
115
|
+
onLine: (text) => {
|
|
116
|
+
if (!clientGone) emit({ type: "line", text });
|
|
117
|
+
},
|
|
118
|
+
onClose: finish,
|
|
119
|
+
onServiceExit: (exitCode) => {
|
|
120
|
+
onServiceExitHook?.();
|
|
121
|
+
if (clientGone) return;
|
|
122
|
+
emit({
|
|
123
|
+
type: "service-exited",
|
|
124
|
+
exitCode,
|
|
125
|
+
repoName: resolved.repoName,
|
|
126
|
+
subsystem: resolved.subsystem,
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
proc = result.proc;
|
|
131
|
+
|
|
132
|
+
// Backstop: the route refuses an interactive command before calling this,
|
|
133
|
+
// so the runner should never hand back a TTY-bound process here.
|
|
134
|
+
if (result.interactive) {
|
|
135
|
+
try {
|
|
136
|
+
proc.kill();
|
|
137
|
+
} catch {
|
|
138
|
+
/* already exited */
|
|
139
|
+
}
|
|
140
|
+
emit({
|
|
141
|
+
type: "error",
|
|
142
|
+
text: `"${cmd}" requires an interactive terminal (HITL). Run it from your terminal.`,
|
|
143
|
+
});
|
|
144
|
+
finish(1, true);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (result.detachable) {
|
|
148
|
+
// The runner has already said where the log is.
|
|
149
|
+
emit({ type: "started", detached: true });
|
|
150
|
+
watchReady();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function watchReady(): void {
|
|
154
|
+
const pollMs = deps.pollMs ?? 1000;
|
|
155
|
+
const timeoutMs = deps.readyTimeoutMs ?? 120_000;
|
|
156
|
+
const settleMs = deps.settleMs ?? 5000;
|
|
157
|
+
const restartMinMs = deps.restartMinMs ?? 5000;
|
|
158
|
+
const t0 = Date.now();
|
|
159
|
+
let exited = false;
|
|
160
|
+
let sawDown = false;
|
|
161
|
+
const prevExit = onServiceExitHook;
|
|
162
|
+
onServiceExitHook = () => {
|
|
163
|
+
exited = true;
|
|
164
|
+
prevExit?.();
|
|
165
|
+
};
|
|
166
|
+
const tick = async (): Promise<void> => {
|
|
167
|
+
if (finished || clientGone || exited) return;
|
|
168
|
+
const elapsed = Date.now() - t0;
|
|
169
|
+
if (elapsed > timeoutMs) return;
|
|
170
|
+
const h = await deps.health(resolved).catch(() => null);
|
|
171
|
+
if (finished || clientGone || exited) return;
|
|
172
|
+
if (h?.health === "ok") {
|
|
173
|
+
if (cmd !== "restart" || sawDown || elapsed >= restartMinMs) {
|
|
174
|
+
emit({ type: "ready", checked: true });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
} else if (h?.health === "error") {
|
|
178
|
+
sawDown = true;
|
|
179
|
+
} else if (elapsed >= settleMs) {
|
|
180
|
+
// No health check to wait for: still running after the settle window.
|
|
181
|
+
emit({ type: "ready", checked: false });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
setTimeout(() => void tick(), pollMs);
|
|
185
|
+
};
|
|
186
|
+
setTimeout(() => void tick(), pollMs);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -20,7 +20,7 @@ import * as fs from "fs";
|
|
|
20
20
|
import * as path from "path";
|
|
21
21
|
import { execFile, spawnSync } from "child_process";
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
|
|
24
24
|
import {
|
|
25
25
|
resolveService,
|
|
26
26
|
hasRunningOpctlContainers,
|
|
@@ -90,8 +90,9 @@ export function resolveHistorySource(
|
|
|
90
90
|
resolveService(`${repoName}/${svcName}`) ?? resolveService(repoName);
|
|
91
91
|
const repoPath = resolved?.repoPath;
|
|
92
92
|
|
|
93
|
-
// 1.
|
|
94
|
-
|
|
93
|
+
// 1. The service's container: its compose stack's, else opctl's
|
|
94
|
+
// conventional one (opctl_<base>_<hash>).
|
|
95
|
+
const conventional = findServiceContainer(repoName, repoPath, svcName);
|
|
95
96
|
if (conventional) return { kind: "docker", container: conventional };
|
|
96
97
|
|
|
97
98
|
// 2. Container named by the manifest log command's docker ps filter.
|
|
@@ -316,7 +316,9 @@
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
function cell(r) {
|
|
319
|
-
|
|
319
|
+
// What was checked: the URL, or a shell check's command.
|
|
320
|
+
const checked = r && (r.url ?? r.check);
|
|
321
|
+
if (!r || (!checked && r.ok == null))
|
|
320
322
|
return r?.openUrl
|
|
321
323
|
? `<span class="cell"><span class="none">—</span>${openLink(r.openUrl, r.service)}</span>`
|
|
322
324
|
: '<span class="none">—</span>';
|
|
@@ -333,7 +335,7 @@
|
|
|
333
335
|
: r.ok
|
|
334
336
|
? "up"
|
|
335
337
|
: r.error || "down";
|
|
336
|
-
const title = [r.label,
|
|
338
|
+
const title = [r.label, checked, r.error].filter(Boolean).join(" — ");
|
|
337
339
|
const link =
|
|
338
340
|
r.url && /^https?:/.test(r.url)
|
|
339
341
|
? `<a href="${esc(r.url)}" target="_blank" rel="noopener" title="${esc(title)}">${esc(what)}</a>`
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Local Dev graph's model: pure and DOM-free (like workspace-picker.js),
|
|
3
|
+
* so it is unit-testable under Jest; graph.js and panel.js own the DOM.
|
|
4
|
+
*
|
|
5
|
+
* Input is what the page already loads for a workspace:
|
|
6
|
+
* services /api/services rows health /api/health rows
|
|
7
|
+
* graph /api/graph (repo edges) apps /api/applications .applications
|
|
8
|
+
* orgs /api/orgs?repos=1 depsMap /api/workspaces/deps-check
|
|
9
|
+
*
|
|
10
|
+
* Placement (R13): each row may carry `runs` ("local" | "remote" | "none")
|
|
11
|
+
* and `target` ({ env, account?, url? }, remote only). A row without them is
|
|
12
|
+
* local with no target, so the graph works before the API sends them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Real card sizes; there is no shrink-to-fit. */
|
|
16
|
+
export const SIZE = {
|
|
17
|
+
detailed: { w: 236, h: 104 },
|
|
18
|
+
compact: { w: 176, h: 52 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const RUNS = ["local", "remote", "none"];
|
|
22
|
+
|
|
23
|
+
/** `runs`/`target` for a row, with the contract's fallbacks. */
|
|
24
|
+
export function placementOf(r) {
|
|
25
|
+
const runs = RUNS.includes(r?.runs) ? r.runs : "local";
|
|
26
|
+
const t = r?.target;
|
|
27
|
+
const target =
|
|
28
|
+
runs === "remote" && t && typeof t === "object" && typeof t.env === "string"
|
|
29
|
+
? t
|
|
30
|
+
: null;
|
|
31
|
+
return { runs, target };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PALETTE = [
|
|
35
|
+
"#58a6ff",
|
|
36
|
+
"#3fb950",
|
|
37
|
+
"#a371f7",
|
|
38
|
+
"#d29922",
|
|
39
|
+
"#f0883e",
|
|
40
|
+
"#ff7b72",
|
|
41
|
+
"#79c0ff",
|
|
42
|
+
"#56d364",
|
|
43
|
+
];
|
|
44
|
+
function colorFor(name) {
|
|
45
|
+
let h = 5381;
|
|
46
|
+
for (let i = 0; i < name.length; i++)
|
|
47
|
+
h = (Math.imul(h, 31) + name.charCodeAt(i)) | 0;
|
|
48
|
+
return PALETTE[Math.abs(h) % PALETTE.length];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build the model the graph and panel render from. */
|
|
52
|
+
export function buildModel({
|
|
53
|
+
services = [],
|
|
54
|
+
health = [],
|
|
55
|
+
graph = {},
|
|
56
|
+
apps = [],
|
|
57
|
+
orgs = null,
|
|
58
|
+
depsMap = {},
|
|
59
|
+
workspace = "",
|
|
60
|
+
} = {}) {
|
|
61
|
+
const appOfRepo = new Map();
|
|
62
|
+
// Declared applications first, then a repo that is its own application.
|
|
63
|
+
for (const a of apps || [])
|
|
64
|
+
if (a.source === "declared")
|
|
65
|
+
for (const r of a.repos || []) if (!appOfRepo.has(r)) appOfRepo.set(r, a);
|
|
66
|
+
for (const a of apps || [])
|
|
67
|
+
for (const r of a.repos || []) if (!appOfRepo.has(r)) appOfRepo.set(r, a);
|
|
68
|
+
const list = services.map((r) => {
|
|
69
|
+
const a = appOfRepo.get(r.repo);
|
|
70
|
+
return {
|
|
71
|
+
id: r.address,
|
|
72
|
+
name: r.service,
|
|
73
|
+
repo: r.repo,
|
|
74
|
+
org: orgs?.repos?.[r.repo] || "",
|
|
75
|
+
app: a ? a.title || a.name : r.repo,
|
|
76
|
+
...placementOf(r),
|
|
77
|
+
health: { ok: null },
|
|
78
|
+
open: null,
|
|
79
|
+
entry: !!r.isEntrypoint,
|
|
80
|
+
infra: !!r.infra,
|
|
81
|
+
commands: r.commands || [],
|
|
82
|
+
deps: (r.deps || []).filter((d) => d !== r.address),
|
|
83
|
+
branch: r.branch || null,
|
|
84
|
+
autostart: r.autostart !== false,
|
|
85
|
+
mode: r.mode,
|
|
86
|
+
path: r.repoPath || "",
|
|
87
|
+
logs: r.logs ?? null,
|
|
88
|
+
cloned: r.cloned !== false,
|
|
89
|
+
manifestLayers: r.manifestLayers || [],
|
|
90
|
+
owners: r.owners || [],
|
|
91
|
+
missing: depsMap[r.address] || [],
|
|
92
|
+
hasPlacement: "runs" in r,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
const byId = new Map(list.map((s) => [s.id, s]));
|
|
96
|
+
// A dep is an address, or a bare service name inside the same repo.
|
|
97
|
+
for (const s of list)
|
|
98
|
+
s.deps = s.deps
|
|
99
|
+
.map((d) =>
|
|
100
|
+
byId.has(d) ? d : byId.has(`${s.repo}/${d}`) ? `${s.repo}/${d}` : d,
|
|
101
|
+
)
|
|
102
|
+
.filter((d) => d !== s.id);
|
|
103
|
+
const byRepo = new Map();
|
|
104
|
+
for (const s of list) {
|
|
105
|
+
if (!byRepo.has(s.repo)) byRepo.set(s.repo, []);
|
|
106
|
+
byRepo.get(s.repo).push(s);
|
|
107
|
+
}
|
|
108
|
+
const orgList = new Map();
|
|
109
|
+
for (const o of orgs?.orgs || [])
|
|
110
|
+
orgList.set(o.id, {
|
|
111
|
+
id: o.id,
|
|
112
|
+
label: o.label || o.id,
|
|
113
|
+
color: o.color || colorFor(o.id),
|
|
114
|
+
});
|
|
115
|
+
const m = {
|
|
116
|
+
workspace,
|
|
117
|
+
services: list,
|
|
118
|
+
byId,
|
|
119
|
+
byRepo,
|
|
120
|
+
edges: (graph?.edges || []).filter(
|
|
121
|
+
(e) => byRepo.has(e.from) && byRepo.has(e.to) && e.from !== e.to,
|
|
122
|
+
),
|
|
123
|
+
orgs: orgList,
|
|
124
|
+
apps: apps || [],
|
|
125
|
+
mixed: false,
|
|
126
|
+
};
|
|
127
|
+
applyHealth(m, health);
|
|
128
|
+
return m;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Fold /api/health rows into the model (health, open link, placement). */
|
|
132
|
+
export function applyHealth(m, results) {
|
|
133
|
+
const byKey = new Map(
|
|
134
|
+
(results || []).map((h) => [`${h.repo}/${h.service}`, h]),
|
|
135
|
+
);
|
|
136
|
+
for (const s of m.services) {
|
|
137
|
+
// Health rows name repo + service; a single-service repo's address is the bare repo.
|
|
138
|
+
const h = byKey.get(`${s.repo}/${s.name}`) || byKey.get(s.id);
|
|
139
|
+
if (!h) continue;
|
|
140
|
+
s.health = {
|
|
141
|
+
ok: h.ok === true ? true : h.ok === false ? false : null,
|
|
142
|
+
label: h.label,
|
|
143
|
+
url: h.url ?? null,
|
|
144
|
+
check: h.check ?? null,
|
|
145
|
+
error: h.error ?? null,
|
|
146
|
+
status: h.status ?? null,
|
|
147
|
+
ms:
|
|
148
|
+
typeof h.ms === "number"
|
|
149
|
+
? h.ms
|
|
150
|
+
: typeof h.durationMs === "number"
|
|
151
|
+
? h.durationMs
|
|
152
|
+
: null,
|
|
153
|
+
};
|
|
154
|
+
s.open = h.openUrl && /^https?:/.test(h.openUrl) ? h.openUrl : s.open;
|
|
155
|
+
if (!s.hasPlacement && "runs" in h) Object.assign(s, placementOf(h));
|
|
156
|
+
}
|
|
157
|
+
m.mixed = new Set(m.services.map((s) => s.runs)).size > 1;
|
|
158
|
+
return m;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const orgOf = (m, id) =>
|
|
162
|
+
m.orgs.get(id) || {
|
|
163
|
+
id,
|
|
164
|
+
label: id || m.workspace || "Workspace",
|
|
165
|
+
color: "#8b949e",
|
|
166
|
+
};
|
|
167
|
+
export const isLocal = (s) => s.runs !== "remote";
|
|
168
|
+
export const status = (s) =>
|
|
169
|
+
s.health.ok === true ? "ok" : s.health.ok === false ? "bad" : "unk";
|
|
170
|
+
export const statusWord = (s) =>
|
|
171
|
+
s.health.ok === true ? "healthy" : s.health.ok === false ? "down" : "unknown";
|
|
172
|
+
|
|
173
|
+
/** local | remote | hybrid | none for a repo's services. */
|
|
174
|
+
export function repoPlacement(m, repo) {
|
|
175
|
+
const p = new Set((m.byRepo.get(repo) || []).map((s) => s.runs));
|
|
176
|
+
return p.size > 1 ? "hybrid" : [...p][0] || "local";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function placementText(s) {
|
|
180
|
+
if (s.runs === "remote")
|
|
181
|
+
return `☁ ${s.target?.env || "remote"}${s.target?.account ? " · " + s.target.account : ""}`;
|
|
182
|
+
if (s.runs === "none") return "no runtime";
|
|
183
|
+
return "⌂ local";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const placeGlyph = (p) =>
|
|
187
|
+
p === "remote" ? "☁" : p === "hybrid" ? "⇄" : p === "local" ? "⌂" : "";
|
|
188
|
+
|
|
189
|
+
export const remoteWhy = (s) =>
|
|
190
|
+
`runs in ${s.target?.env || "a remote env"}${s.target?.account ? " (" + s.target.account + ")" : ""}; nothing to start here`;
|
|
191
|
+
|
|
192
|
+
export const fmtMs = (ms) =>
|
|
193
|
+
ms == null ? "" : ms >= 1000 ? (ms / 1000).toFixed(1) + " s" : ms + " ms";
|
|
194
|
+
|
|
195
|
+
export function healthLine(s) {
|
|
196
|
+
const ms = s.health.ms != null ? " · " + fmtMs(s.health.ms) : "";
|
|
197
|
+
if (s.runs === "remote")
|
|
198
|
+
return (
|
|
199
|
+
(s.health.ok === false
|
|
200
|
+
? "unreachable"
|
|
201
|
+
: s.health.ok
|
|
202
|
+
? "reachable"
|
|
203
|
+
: "not checked") + ms
|
|
204
|
+
);
|
|
205
|
+
if (s.health.ok == null) return "no health check";
|
|
206
|
+
return (s.health.ok ? "healthy" : "down") + ms;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A repo's representative service: entrypoint, root-named, else first. */
|
|
210
|
+
export function entryOf(m, repo) {
|
|
211
|
+
const l = m.byRepo.get(repo) || [];
|
|
212
|
+
return l.find((s) => s.entry) || l.find((s) => s.name === repo) || l[0];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const isCollapsed = (m, collapsed, repo) =>
|
|
216
|
+
collapsed.has(repo) && (m.byRepo.get(repo)?.length || 0) > 1;
|
|
217
|
+
export const nodeFor = (m, collapsed, id) => {
|
|
218
|
+
const s = m.byId.get(id);
|
|
219
|
+
return s && isCollapsed(m, collapsed, s.repo) ? "repo:" + s.repo : id;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const KIND = { hard: "hard", soft: "soft", event: "event" };
|
|
223
|
+
|
|
224
|
+
/** Display edges: repo edges between entry services, plus intra-repo deps. */
|
|
225
|
+
export function edgeModel(m, collapsed) {
|
|
226
|
+
const out = new Map();
|
|
227
|
+
const add = (a, b, kind) => {
|
|
228
|
+
const from = nodeFor(m, collapsed, a.id),
|
|
229
|
+
to = nodeFor(m, collapsed, b.id);
|
|
230
|
+
if (from === to) return;
|
|
231
|
+
const k = from + ">" + to;
|
|
232
|
+
if (!out.has(k)) out.set(k, { from, to, kind, cross: a.runs !== b.runs });
|
|
233
|
+
};
|
|
234
|
+
for (const e of m.edges)
|
|
235
|
+
add(entryOf(m, e.from), entryOf(m, e.to), KIND[e.blocking] || "soft");
|
|
236
|
+
for (const s of m.services)
|
|
237
|
+
for (const d of s.deps) {
|
|
238
|
+
const t = m.byId.get(d);
|
|
239
|
+
if (t && t.repo === s.repo) add(s, t, "intra");
|
|
240
|
+
}
|
|
241
|
+
return [...out.values()];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The ELK graph: outer group (org or application) › repo › service. */
|
|
245
|
+
export function buildElkGraph(
|
|
246
|
+
m,
|
|
247
|
+
{
|
|
248
|
+
direction = "down",
|
|
249
|
+
group = "org",
|
|
250
|
+
density = "detailed",
|
|
251
|
+
collapsed = new Set(),
|
|
252
|
+
} = {},
|
|
253
|
+
) {
|
|
254
|
+
const sz = SIZE[density] || SIZE.detailed;
|
|
255
|
+
const outer = new Map();
|
|
256
|
+
for (const s of m.services) {
|
|
257
|
+
const key = group === "app" ? s.app : s.org || "";
|
|
258
|
+
if (!outer.has(key)) outer.set(key, new Set());
|
|
259
|
+
outer.get(key).add(s.repo);
|
|
260
|
+
}
|
|
261
|
+
const parent = {};
|
|
262
|
+
const children = [];
|
|
263
|
+
for (const [key, repos] of outer) {
|
|
264
|
+
const gid = "grp:" + key;
|
|
265
|
+
const kids = [];
|
|
266
|
+
for (const r of repos) {
|
|
267
|
+
const svcs = m.byRepo.get(r);
|
|
268
|
+
if (svcs.length === 1) {
|
|
269
|
+
kids.push({ id: svcs[0].id, width: sz.w, height: sz.h });
|
|
270
|
+
parent[svcs[0].id] = gid;
|
|
271
|
+
} else if (isCollapsed(m, collapsed, r)) {
|
|
272
|
+
kids.push({ id: "repo:" + r, width: sz.w, height: sz.h });
|
|
273
|
+
parent["repo:" + r] = gid;
|
|
274
|
+
} else {
|
|
275
|
+
const rid = "rg:" + r;
|
|
276
|
+
parent[rid] = gid;
|
|
277
|
+
kids.push({
|
|
278
|
+
id: rid,
|
|
279
|
+
layoutOptions: {
|
|
280
|
+
"elk.padding": "[top=40,left=14,bottom=14,right=14]",
|
|
281
|
+
"elk.nodeSize.constraints": "MINIMUM_SIZE",
|
|
282
|
+
"elk.nodeSize.minimum": "(" + (sz.w + 150) + ",40)",
|
|
283
|
+
},
|
|
284
|
+
children: svcs.map((s) => {
|
|
285
|
+
parent[s.id] = rid;
|
|
286
|
+
return { id: s.id, width: sz.w, height: sz.h };
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
children.push({
|
|
292
|
+
id: gid,
|
|
293
|
+
layoutOptions: { "elk.padding": "[top=46,left=18,bottom=18,right=18]" },
|
|
294
|
+
children: kids,
|
|
295
|
+
});
|
|
296
|
+
parent[gid] = "root";
|
|
297
|
+
}
|
|
298
|
+
const root = {
|
|
299
|
+
id: "root",
|
|
300
|
+
layoutOptions: {
|
|
301
|
+
"elk.algorithm": "layered",
|
|
302
|
+
"elk.direction": direction === "right" ? "RIGHT" : "DOWN",
|
|
303
|
+
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
|
304
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
305
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": "54",
|
|
306
|
+
"elk.spacing.nodeNode": "26",
|
|
307
|
+
"elk.spacing.componentComponent": "48",
|
|
308
|
+
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
|
|
309
|
+
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
|
310
|
+
"elk.padding": "[top=10,left=10,bottom=10,right=10]",
|
|
311
|
+
"elk.spacing.edgeNode": "18",
|
|
312
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": "18",
|
|
313
|
+
},
|
|
314
|
+
children,
|
|
315
|
+
edges: [],
|
|
316
|
+
};
|
|
317
|
+
// Each edge lives on its endpoints' lowest common ancestor.
|
|
318
|
+
const anc = (id) => {
|
|
319
|
+
const a = [];
|
|
320
|
+
let c = id;
|
|
321
|
+
while (c && c !== "root") {
|
|
322
|
+
c = parent[c];
|
|
323
|
+
a.push(c || "root");
|
|
324
|
+
}
|
|
325
|
+
return a;
|
|
326
|
+
};
|
|
327
|
+
const lca = (a, b) => {
|
|
328
|
+
const B = new Set(anc(b));
|
|
329
|
+
return anc(a).find((x) => B.has(x)) || "root";
|
|
330
|
+
};
|
|
331
|
+
const find = (n, id) => {
|
|
332
|
+
if (n.id === id) return n;
|
|
333
|
+
for (const c of n.children || []) {
|
|
334
|
+
const f = find(c, id);
|
|
335
|
+
if (f) return f;
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
};
|
|
339
|
+
edgeModel(m, collapsed).forEach((e, i) => {
|
|
340
|
+
const c = lca(e.from, e.to);
|
|
341
|
+
const host = c === "root" ? root : find(root, c);
|
|
342
|
+
(host.edges ||= []).push({
|
|
343
|
+
id: "e" + i,
|
|
344
|
+
sources: [e.from],
|
|
345
|
+
targets: [e.to],
|
|
346
|
+
_m: e,
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
return root;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Manifest commands that destroy state confirm inline, in Careful. */
|
|
353
|
+
export const DANGER_CMD = /reset|drop|wipe|clean|destroy|purge/;
|
|
354
|
+
export const commandTier = (cmd) =>
|
|
355
|
+
DANGER_CMD.test(cmd) ? "danger" : "commands";
|
|
356
|
+
|
|
357
|
+
export const TIERS = ["common", "commands", "tools", "danger"];
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Tiering: the primary is the first action whose primaryWhen holds (and is
|
|
361
|
+
* enabled); up to two common actions sit beside it; everything else goes in
|
|
362
|
+
* More, grouped by tier.
|
|
363
|
+
*/
|
|
364
|
+
export function resolveActions(actions, x, kind) {
|
|
365
|
+
const all = actions.filter((a) => a.for.includes(kind) && a.when(x));
|
|
366
|
+
const primary =
|
|
367
|
+
all.find((a) => a.primaryWhen?.(x) && !a.disabled?.(x)) || null;
|
|
368
|
+
const common = all
|
|
369
|
+
.filter((a) => a.tier === "common" && a !== primary)
|
|
370
|
+
.slice(0, 2);
|
|
371
|
+
const rest = all.filter((a) => a !== primary && !common.includes(a));
|
|
372
|
+
const groups = TIERS.map((t) => [t, rest.filter((a) => a.tier === t)]).filter(
|
|
373
|
+
([, l]) => l.length,
|
|
374
|
+
);
|
|
375
|
+
return { primary, common, groups, all };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Service ids (or repo:<r> node keys) adjacent to a node. */
|
|
379
|
+
export function neighbours(edges, id) {
|
|
380
|
+
const set = new Set([id]);
|
|
381
|
+
for (const e of edges) {
|
|
382
|
+
if (e.from === id) set.add(e.to);
|
|
383
|
+
if (e.to === id) set.add(e.from);
|
|
384
|
+
}
|
|
385
|
+
return set;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Depends on / Used by for a service or a repo ({ repo, services }). */
|
|
389
|
+
export function connections(m, x) {
|
|
390
|
+
if (x.services) {
|
|
391
|
+
return {
|
|
392
|
+
deps: m.edges
|
|
393
|
+
.filter((e) => e.from === x.repo)
|
|
394
|
+
.map((e) => ({ repo: e.to })),
|
|
395
|
+
users: m.edges
|
|
396
|
+
.filter((e) => e.to === x.repo)
|
|
397
|
+
.map((e) => ({ repo: e.from })),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const s = x;
|
|
401
|
+
const isEntry = entryOf(m, s.repo)?.id === s.id;
|
|
402
|
+
const intraDeps = s.deps.filter((d) => m.byId.get(d)?.repo === s.repo);
|
|
403
|
+
const usedBy = m.services
|
|
404
|
+
.filter((o) => o.repo === s.repo && o.deps.includes(s.id))
|
|
405
|
+
.map((o) => o.id);
|
|
406
|
+
const repoOut = isEntry
|
|
407
|
+
? m.edges.filter((e) => e.from === s.repo).map((e) => entryOf(m, e.to).id)
|
|
408
|
+
: [];
|
|
409
|
+
const repoIn = isEntry
|
|
410
|
+
? m.edges.filter((e) => e.to === s.repo).map((e) => entryOf(m, e.from).id)
|
|
411
|
+
: [];
|
|
412
|
+
return {
|
|
413
|
+
deps: [...new Set([...intraDeps, ...repoOut])].map((id) => ({ id })),
|
|
414
|
+
users: [...new Set([...usedBy, ...repoIn])].map((id) => ({ id })),
|
|
415
|
+
};
|
|
416
|
+
}
|