@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
|
@@ -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
|
+
}
|