@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,986 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Local Dev graph: an ELK layered layout (elkjs, vendored and served by
|
|
3
|
+
* Den at /vendor/elk.bundled.js) of HTML cards over an SVG edge layer, with
|
|
4
|
+
* org/app › repo › service frames, pan and zoom, focus, find, and the
|
|
5
|
+
* details panel (panel.js).
|
|
6
|
+
*
|
|
7
|
+
* mountGraph(host, data, hooks) draw into `host` (#dep-graph-wrap)
|
|
8
|
+
* graphHealth(results) fold a /api/health update in place
|
|
9
|
+
*
|
|
10
|
+
* `data` is what local-dev.html loads for the viewed workspace (see
|
|
11
|
+
* graph-model.js buildModel). `hooks` are the page's existing actions
|
|
12
|
+
* (runCmd, upWithDeps, openEditor, …) so the graph reuses the same calls.
|
|
13
|
+
*
|
|
14
|
+
* View state (selection, collapsed repos, pan/zoom) lives at module level so
|
|
15
|
+
* a re-render after an action keeps it. Layout preferences are remembered in
|
|
16
|
+
* the user config through /api/settings and mirrored to localStorage.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
applyHealth,
|
|
20
|
+
buildElkGraph,
|
|
21
|
+
buildModel,
|
|
22
|
+
edgeModel,
|
|
23
|
+
entryOf,
|
|
24
|
+
healthLine,
|
|
25
|
+
isCollapsed,
|
|
26
|
+
neighbours,
|
|
27
|
+
nodeFor,
|
|
28
|
+
orgOf,
|
|
29
|
+
placementText,
|
|
30
|
+
repoPlacement,
|
|
31
|
+
status,
|
|
32
|
+
statusWord,
|
|
33
|
+
} from "/graph-model.js";
|
|
34
|
+
import {
|
|
35
|
+
actBtn,
|
|
36
|
+
actionsFor,
|
|
37
|
+
bindPanel,
|
|
38
|
+
disposePanel,
|
|
39
|
+
esc,
|
|
40
|
+
renderPanel,
|
|
41
|
+
runAction,
|
|
42
|
+
} from "/panel.js";
|
|
43
|
+
|
|
44
|
+
const LS = "__DEN_CLI__-graph:";
|
|
45
|
+
const store = {
|
|
46
|
+
get(k, d) {
|
|
47
|
+
try {
|
|
48
|
+
const v = localStorage.getItem(LS + k);
|
|
49
|
+
return v == null ? d : JSON.parse(v);
|
|
50
|
+
} catch {
|
|
51
|
+
return d;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
set(k, v) {
|
|
55
|
+
try {
|
|
56
|
+
localStorage.setItem(LS + k, JSON.stringify(v));
|
|
57
|
+
} catch {
|
|
58
|
+
/* private mode */
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const DEFAULTS = {
|
|
63
|
+
direction: "down",
|
|
64
|
+
group: "org",
|
|
65
|
+
density: "detailed",
|
|
66
|
+
panelWidth: 440,
|
|
67
|
+
};
|
|
68
|
+
const saved = store.get("prefs", DEFAULTS);
|
|
69
|
+
|
|
70
|
+
const st = {
|
|
71
|
+
dir: saved.direction || "down",
|
|
72
|
+
group: saved.group || "org",
|
|
73
|
+
density: saved.density || "detailed",
|
|
74
|
+
panelW: saved.panelWidth || 440,
|
|
75
|
+
popped: false,
|
|
76
|
+
tab: null,
|
|
77
|
+
collapsed: new Set(store.get("collapsed", [])),
|
|
78
|
+
sel: null,
|
|
79
|
+
q: "",
|
|
80
|
+
z: 1,
|
|
81
|
+
tx: 24,
|
|
82
|
+
ty: 24,
|
|
83
|
+
fitted: false,
|
|
84
|
+
};
|
|
85
|
+
let M = null;
|
|
86
|
+
let hooks = {};
|
|
87
|
+
let host = null;
|
|
88
|
+
let layoutAbs = {};
|
|
89
|
+
let edges = [];
|
|
90
|
+
let elk = null;
|
|
91
|
+
let renderSeq = 0;
|
|
92
|
+
let prefsLoaded = false;
|
|
93
|
+
|
|
94
|
+
const $ = (s, el = host) => el?.querySelector(s);
|
|
95
|
+
const zoomFloor = 0.35;
|
|
96
|
+
|
|
97
|
+
bindPanel({
|
|
98
|
+
model: () => M,
|
|
99
|
+
st,
|
|
100
|
+
get hooks() {
|
|
101
|
+
return hooks;
|
|
102
|
+
},
|
|
103
|
+
toast,
|
|
104
|
+
selectTab: (id) => {
|
|
105
|
+
st.tab = id;
|
|
106
|
+
if (st.sel) openDetail(st.sel, true);
|
|
107
|
+
},
|
|
108
|
+
focusOn: (sel) => focusOn(sel),
|
|
109
|
+
recheck,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── prefs ────────────────────────────────────────────────────────────────
|
|
113
|
+
function prefs() {
|
|
114
|
+
return {
|
|
115
|
+
direction: st.dir,
|
|
116
|
+
group: st.group,
|
|
117
|
+
density: st.density,
|
|
118
|
+
panelWidth: st.panelW,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function savePrefs() {
|
|
122
|
+
store.set("prefs", prefs());
|
|
123
|
+
fetch("/api/settings", {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "Content-Type": "application/json" },
|
|
126
|
+
body: JSON.stringify({ graph: prefs() }),
|
|
127
|
+
}).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
function loadServerPrefs() {
|
|
130
|
+
if (prefsLoaded) return;
|
|
131
|
+
prefsLoaded = true;
|
|
132
|
+
fetch("/api/settings")
|
|
133
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
134
|
+
.then((d) => {
|
|
135
|
+
const g = d?.graph;
|
|
136
|
+
if (!g) return;
|
|
137
|
+
const before = JSON.stringify(prefs());
|
|
138
|
+
st.dir = g.direction;
|
|
139
|
+
st.group = g.group;
|
|
140
|
+
st.density = g.density;
|
|
141
|
+
st.panelW = g.panelWidth;
|
|
142
|
+
store.set("prefs", prefs());
|
|
143
|
+
if (JSON.stringify(prefs()) !== before && host?.isConnected) {
|
|
144
|
+
syncControls();
|
|
145
|
+
render().then(() => fit(0.6));
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
.catch(() => {});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── ELK (vendored; loaded once, on first draw) ───────────────────────────
|
|
152
|
+
let elkLoading = null;
|
|
153
|
+
function loadElk() {
|
|
154
|
+
if (window.ELK) return Promise.resolve();
|
|
155
|
+
elkLoading ||= new Promise((resolve, reject) => {
|
|
156
|
+
const s = document.createElement("script");
|
|
157
|
+
s.src = "/vendor/elk.bundled.js";
|
|
158
|
+
s.onload = () => resolve();
|
|
159
|
+
s.onerror = () => {
|
|
160
|
+
elkLoading = null;
|
|
161
|
+
reject(new Error("layout library did not load"));
|
|
162
|
+
};
|
|
163
|
+
document.head.appendChild(s);
|
|
164
|
+
});
|
|
165
|
+
return elkLoading;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── mount ────────────────────────────────────────────────────────────────
|
|
169
|
+
export function mountGraph(el, data, pageHooks) {
|
|
170
|
+
hooks = pageHooks || {};
|
|
171
|
+
host = el;
|
|
172
|
+
M = buildModel(data);
|
|
173
|
+
// Drop view state for things that left the workspace.
|
|
174
|
+
for (const r of [...st.collapsed])
|
|
175
|
+
if (!M.byRepo.has(r)) st.collapsed.delete(r);
|
|
176
|
+
if (
|
|
177
|
+
st.sel &&
|
|
178
|
+
!(st.sel.repo ? M.byRepo.has(st.sel.repo) : M.byId.has(st.sel.id))
|
|
179
|
+
)
|
|
180
|
+
st.sel = null;
|
|
181
|
+
el.innerHTML = `<div class="dg">
|
|
182
|
+
<div class="dg-bar" role="toolbar" aria-label="Graph view options">
|
|
183
|
+
<div class="dg-ctl"><span>Layout</span><div class="dg-seg" data-seg="dir"><button data-v="down">Top-down</button><button data-v="right">Left-right</button></div></div>
|
|
184
|
+
<div class="dg-ctl"><span>Group</span><div class="dg-seg" data-seg="group"><button data-v="org">Org</button><button data-v="app">Application</button></div></div>
|
|
185
|
+
<div class="dg-ctl"><span>Cards</span><div class="dg-seg" data-seg="density"><button data-v="detailed">Detailed</button><button data-v="compact">Compact</button></div></div>
|
|
186
|
+
<div class="dg-ctl"><div class="dg-seg" data-expand><button data-v="all">Expand all</button><button data-v="none">Collapse all</button></div></div>
|
|
187
|
+
<div class="dg-spacer"></div>
|
|
188
|
+
<label class="dg-ctl"><span>Find</span><input type="search" data-find placeholder="service, repo…" autocomplete="off" value="${esc(st.q)}"></label>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="dg-stage">
|
|
191
|
+
<div class="dg-vp" tabindex="0" aria-label="Service graph. Drag to pan, scroll to move, Ctrl or Cmd + scroll to zoom.">
|
|
192
|
+
<div class="dg-world"><svg aria-hidden="true"></svg></div>
|
|
193
|
+
<div class="dg-legend"></div>
|
|
194
|
+
<div class="dg-zoom" role="group" aria-label="Zoom"><button data-z="out" aria-label="Zoom out">−</button><button data-z="100" aria-label="Reset zoom to 100%">100%</button><button data-z="in" aria-label="Zoom in">+</button><button data-z="fit">Fit</button></div>
|
|
195
|
+
</div>
|
|
196
|
+
<aside class="dg-panel" hidden aria-label="Details"></aside>
|
|
197
|
+
</div>
|
|
198
|
+
</div>`;
|
|
199
|
+
wire();
|
|
200
|
+
syncControls();
|
|
201
|
+
loadServerPrefs();
|
|
202
|
+
render().then(() => {
|
|
203
|
+
if (!st.fitted) {
|
|
204
|
+
fit(0.6);
|
|
205
|
+
st.fitted = true;
|
|
206
|
+
} else applyView();
|
|
207
|
+
if (st.sel) openDetail(st.sel, true);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A /api/health update: refresh dots, health lines and the open panel in place. */
|
|
212
|
+
export function graphHealth(results) {
|
|
213
|
+
if (!M || !host?.isConnected) return;
|
|
214
|
+
applyHealth(M, results);
|
|
215
|
+
for (const c of host.querySelectorAll(".dg-card")) {
|
|
216
|
+
if (c.dataset.id && M.byId.has(c.dataset.id))
|
|
217
|
+
c.innerHTML = cardInner(M.byId.get(c.dataset.id));
|
|
218
|
+
else if (c.dataset.repo && M.byRepo.has(c.dataset.repo))
|
|
219
|
+
c.innerHTML = repoCardInner(c.dataset.repo);
|
|
220
|
+
}
|
|
221
|
+
for (const g of host.querySelectorAll(".dg-grp.dg-org"))
|
|
222
|
+
g.querySelector(".roll").textContent = rollup(groupServices(g.dataset.key));
|
|
223
|
+
legend();
|
|
224
|
+
applyFocus();
|
|
225
|
+
// The Logs tab holds a live stream; leave it alone.
|
|
226
|
+
if (st.sel && st.tab !== "logs" && !openMenus().length)
|
|
227
|
+
openDetail(st.sel, true);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── render ───────────────────────────────────────────────────────────────
|
|
231
|
+
async function render() {
|
|
232
|
+
const seq = ++renderSeq;
|
|
233
|
+
const world = $(".dg-world");
|
|
234
|
+
try {
|
|
235
|
+
await loadElk();
|
|
236
|
+
} catch {
|
|
237
|
+
world
|
|
238
|
+
.querySelectorAll(".dg-grp,.dg-card,.dg-msg")
|
|
239
|
+
.forEach((n) => n.remove());
|
|
240
|
+
world.insertAdjacentHTML(
|
|
241
|
+
"beforeend",
|
|
242
|
+
'<div class="dg-msg">The graph layout library did not load. Reload the page; if it persists, reinstall __DEN_CLI__.</div>',
|
|
243
|
+
);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
elk ||= new window.ELK();
|
|
247
|
+
const root = buildElkGraph(M, {
|
|
248
|
+
direction: st.dir,
|
|
249
|
+
group: st.group,
|
|
250
|
+
density: st.density,
|
|
251
|
+
collapsed: st.collapsed,
|
|
252
|
+
});
|
|
253
|
+
const meta = new Map();
|
|
254
|
+
(function walk(n) {
|
|
255
|
+
for (const e of n.edges || []) meta.set(e.id, e._m);
|
|
256
|
+
(n.children || []).forEach(walk);
|
|
257
|
+
})(root);
|
|
258
|
+
const res = await elk.layout(root);
|
|
259
|
+
if (seq !== renderSeq || !world.isConnected) return; // a newer render won
|
|
260
|
+
world.querySelectorAll(".dg-grp,.dg-card,.dg-msg").forEach((n) => n.remove());
|
|
261
|
+
edges = edgeModel(M, st.collapsed);
|
|
262
|
+
layoutAbs = {};
|
|
263
|
+
const frag = document.createDocumentFragment();
|
|
264
|
+
const paths = [];
|
|
265
|
+
let maxX = 0,
|
|
266
|
+
maxY = 0;
|
|
267
|
+
(function walk(n, ox, oy) {
|
|
268
|
+
const x = ox + (n.x || 0),
|
|
269
|
+
y = oy + (n.y || 0);
|
|
270
|
+
if (n.id !== "root") {
|
|
271
|
+
layoutAbs[n.id] = { x, y, w: n.width, h: n.height };
|
|
272
|
+
maxX = Math.max(maxX, x + n.width);
|
|
273
|
+
maxY = Math.max(maxY, y + n.height);
|
|
274
|
+
}
|
|
275
|
+
if (n.id.startsWith("grp:"))
|
|
276
|
+
frag.appendChild(groupEl(n.id.slice(4), x, y, n.width, n.height));
|
|
277
|
+
else if (n.id.startsWith("rg:"))
|
|
278
|
+
frag.appendChild(repoGroupEl(n.id.slice(3), x, y, n.width, n.height));
|
|
279
|
+
else if (n.id.startsWith("repo:"))
|
|
280
|
+
frag.appendChild(repoCard(n.id.slice(5), x, y, n.width, n.height));
|
|
281
|
+
else if (M.byId.has(n.id))
|
|
282
|
+
frag.appendChild(card(M.byId.get(n.id), x, y, n.width, n.height));
|
|
283
|
+
for (const e of n.edges || []) {
|
|
284
|
+
const m = meta.get(e.id);
|
|
285
|
+
// ELK gives edge coordinates relative to the edge's container.
|
|
286
|
+
const cont =
|
|
287
|
+
e.container && e.container !== n.id && layoutAbs[e.container]
|
|
288
|
+
? layoutAbs[e.container]
|
|
289
|
+
: { x: n.id === "root" ? 0 : x, y: n.id === "root" ? 0 : y };
|
|
290
|
+
for (const sct of e.sections || []) {
|
|
291
|
+
const pts = [
|
|
292
|
+
sct.startPoint,
|
|
293
|
+
...(sct.bendPoints || []),
|
|
294
|
+
sct.endPoint,
|
|
295
|
+
].map((p) => [p.x + cont.x, p.y + cont.y]);
|
|
296
|
+
paths.push({ d: roundPath(pts), m });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const c of n.children || []) walk(c, x, y);
|
|
300
|
+
})(res, 0, 0);
|
|
301
|
+
world.appendChild(frag);
|
|
302
|
+
const svg = $(".dg-world > svg");
|
|
303
|
+
svg.setAttribute("width", maxX + 20);
|
|
304
|
+
svg.setAttribute("height", maxY + 20);
|
|
305
|
+
const MK = {
|
|
306
|
+
intra: "#8b949e",
|
|
307
|
+
hard: "#58a6ff",
|
|
308
|
+
soft: "#8b949e",
|
|
309
|
+
event: "#bc8cff",
|
|
310
|
+
cross: "#39c5cf",
|
|
311
|
+
};
|
|
312
|
+
svg.innerHTML =
|
|
313
|
+
`<defs>${Object.entries(MK)
|
|
314
|
+
.map(
|
|
315
|
+
([k, c]) =>
|
|
316
|
+
`<marker id="dg-ah-${k}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="${c}"/></marker>`,
|
|
317
|
+
)
|
|
318
|
+
.join("")}</defs>` +
|
|
319
|
+
paths
|
|
320
|
+
.map(({ d, m }) => {
|
|
321
|
+
const k = m.cross ? "cross" : m.kind;
|
|
322
|
+
return `<path class="dg-e ${k}" data-f="${esc(m.from)}" data-t="${esc(m.to)}" d="${d}" marker-end="url(#dg-ah-${k})"/>`;
|
|
323
|
+
})
|
|
324
|
+
.join("");
|
|
325
|
+
world.style.width = maxX + 20 + "px";
|
|
326
|
+
world.style.height = maxY + 20 + "px";
|
|
327
|
+
legend();
|
|
328
|
+
applyFocus();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function roundPath(pts) {
|
|
332
|
+
if (pts.length < 3) return "M" + pts.map((p) => p.join(",")).join(" L");
|
|
333
|
+
let d = `M${pts[0][0]},${pts[0][1]}`;
|
|
334
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
335
|
+
const [px, py] = pts[i - 1],
|
|
336
|
+
[x, y] = pts[i],
|
|
337
|
+
[nx, ny] = pts[i + 1];
|
|
338
|
+
const r = Math.min(
|
|
339
|
+
8,
|
|
340
|
+
Math.hypot(x - px, y - py) / 2,
|
|
341
|
+
Math.hypot(nx - x, ny - y) / 2,
|
|
342
|
+
);
|
|
343
|
+
const a = [x - Math.sign(x - px) * r, y - Math.sign(y - py) * r],
|
|
344
|
+
b = [x + Math.sign(nx - x) * r, y + Math.sign(ny - y) * r];
|
|
345
|
+
d += ` L${a[0]},${a[1]} Q${x},${y} ${b[0]},${b[1]}`;
|
|
346
|
+
}
|
|
347
|
+
const l = pts[pts.length - 1];
|
|
348
|
+
return d + ` L${l[0]},${l[1]}`;
|
|
349
|
+
}
|
|
350
|
+
function place(el, x, y, w, h) {
|
|
351
|
+
Object.assign(el.style, {
|
|
352
|
+
left: x + "px",
|
|
353
|
+
top: y + "px",
|
|
354
|
+
width: w + "px",
|
|
355
|
+
height: h + "px",
|
|
356
|
+
});
|
|
357
|
+
return el;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const groupServices = (key) =>
|
|
361
|
+
M.services.filter((s) =>
|
|
362
|
+
st.group === "org" ? (s.org || "") === key : s.app === key,
|
|
363
|
+
);
|
|
364
|
+
const rollup = (svcs) =>
|
|
365
|
+
`${svcs.filter((s) => s.health.ok).length}/${svcs.length} healthy`;
|
|
366
|
+
function groupEl(key, x, y, w, h) {
|
|
367
|
+
const el = document.createElement("div");
|
|
368
|
+
el.className = "dg-grp dg-org";
|
|
369
|
+
el.dataset.key = key;
|
|
370
|
+
const svcs = groupServices(key);
|
|
371
|
+
const o =
|
|
372
|
+
st.group === "org"
|
|
373
|
+
? orgOf(M, key)
|
|
374
|
+
: { label: key, color: orgOf(M, svcs[0]?.org).color };
|
|
375
|
+
el.style.setProperty("--oc", o.color);
|
|
376
|
+
const repos = [...new Set(svcs.map((s) => s.repo))];
|
|
377
|
+
const where = new Set(svcs.map((s) => s.runs));
|
|
378
|
+
const glyph = (r) =>
|
|
379
|
+
({ remote: " ☁", hybrid: " ⇄" })[repoPlacement(M, r)] || "";
|
|
380
|
+
const sub =
|
|
381
|
+
st.group === "app"
|
|
382
|
+
? repos.map((r) => r + (M.mixed ? glyph(r) || " ⌂" : "")).join(" · ")
|
|
383
|
+
: `${repos.length} repo${repos.length === 1 ? "" : "s"}`;
|
|
384
|
+
el.innerHTML = `<div class="dg-ghead"><b>${esc(o.label)}</b><span class="meta">${esc(sub)}</span>${where.size > 1 ? '<span class="dg-badge hybrid">⇄ hybrid</span>' : ""}<span class="roll">${rollup(svcs)}</span></div>`;
|
|
385
|
+
return place(el, x, y, w, h);
|
|
386
|
+
}
|
|
387
|
+
function repoGroupEl(r, x, y, w, h) {
|
|
388
|
+
const el = document.createElement("div");
|
|
389
|
+
const svcs = M.byRepo.get(r);
|
|
390
|
+
el.className =
|
|
391
|
+
"dg-grp dg-repo" +
|
|
392
|
+
(svcs.some((s) => s.runs === "remote") ? " has-remote" : "");
|
|
393
|
+
el.dataset.repo = r;
|
|
394
|
+
const branch = svcs[0].branch ? `⎇ ${svcs[0].branch} · ` : "";
|
|
395
|
+
el.innerHTML = `<div class="dg-ghead"><button class="dg-gname" data-goto-repo-panel="${esc(r)}" title="Repo details">${esc(r)}</button><span class="meta">${esc(branch)}${svcs.length} services</span><button class="dg-lnk" data-collapse="${esc(r)}">Collapse</button></div>`;
|
|
396
|
+
return place(el, x, y, w, h);
|
|
397
|
+
}
|
|
398
|
+
function cardInner(s) {
|
|
399
|
+
const showPlace = s.runs === "remote" || M.mixed;
|
|
400
|
+
const acts = actionsFor(s);
|
|
401
|
+
return `<div class="r1"><span class="dg-dot ${status(s)}" title="${statusWord(s)}"></span><span class="nm" title="${esc(s.name)}">${esc(s.name)}</span>${showPlace ? `<span class="dg-badge ${s.runs}">${esc(placementText(s))}</span>` : ""}</div>
|
|
402
|
+
<div class="r2" title="${esc(s.id)}">${esc(s.repo)}</div>
|
|
403
|
+
<div class="r3">${s.entry ? '<span class="dg-tag entry">entry</span>' : ""}${s.infra ? '<span class="dg-tag">infra</span>' : ""}${s.missing.length ? `<span class="dg-tag warn" title="Missing: ${esc(s.missing.join(", "))}">⚠ missing</span>` : ""}${!s.cloned ? '<span class="dg-tag warn">not cloned</span>' : ""}<span class="h ${status(s)}">${esc(healthLine(s))}</span></div>
|
|
404
|
+
<div class="dg-acts">${[acts.primary, ...acts.common]
|
|
405
|
+
.filter(Boolean)
|
|
406
|
+
.slice(0, 3)
|
|
407
|
+
.map((a) => actBtn(a, s, "sm"))
|
|
408
|
+
.join("")}</div>`;
|
|
409
|
+
}
|
|
410
|
+
function card(s, x, y, w, h) {
|
|
411
|
+
const el = document.createElement("div");
|
|
412
|
+
el.className = `dg-card ${st.density === "compact" ? "compact" : ""} ${s.runs === "remote" ? "remote" : ""} ${s.health.ok === false ? "down" : ""}`;
|
|
413
|
+
el.dataset.id = s.id;
|
|
414
|
+
el.tabIndex = 0;
|
|
415
|
+
el.setAttribute("role", "button");
|
|
416
|
+
el.setAttribute(
|
|
417
|
+
"aria-label",
|
|
418
|
+
`${s.name} in ${s.repo}, ${statusWord(s)}${s.runs === "remote" ? ", runs remotely" : ""}. Open details.`,
|
|
419
|
+
);
|
|
420
|
+
el.innerHTML = cardInner(s);
|
|
421
|
+
return place(el, x, y, w, h);
|
|
422
|
+
}
|
|
423
|
+
function repoCardInner(r) {
|
|
424
|
+
const svcs = M.byRepo.get(r),
|
|
425
|
+
ok = svcs.filter((s) => s.health.ok).length,
|
|
426
|
+
pl = repoPlacement(M, r);
|
|
427
|
+
return `<div class="r1"><span class="dg-dot ${ok === svcs.length ? "ok" : svcs.some((s) => s.health.ok === false) ? "bad" : ""}"></span><span class="nm">${esc(r)}</span>${pl !== "local" || M.mixed ? `<span class="dg-badge ${pl}">${pl === "hybrid" ? "⇄ hybrid" : pl === "remote" ? "☁ remote" : "⌂ local"}</span>` : ""}</div>
|
|
428
|
+
<div class="r2">${svcs.length} services · ${ok}/${svcs.length} healthy</div>
|
|
429
|
+
<div class="r3 sum">${svcs.map((s) => `<span class="dg-dot ${status(s)}" title="${esc(s.name)}: ${statusWord(s)}"></span>`).join("")}</div>
|
|
430
|
+
<div class="dg-acts"><button class="dg-btn sm" data-expand="${esc(r)}">Expand</button></div>`;
|
|
431
|
+
}
|
|
432
|
+
function repoCard(r, x, y, w, h) {
|
|
433
|
+
const svcs = M.byRepo.get(r);
|
|
434
|
+
const el = document.createElement("div");
|
|
435
|
+
el.className = `dg-card ${st.density === "compact" ? "compact" : ""} ${repoPlacement(M, r) === "remote" ? "remote" : ""} ${svcs.some((s) => s.health.ok === false) ? "down" : ""}`;
|
|
436
|
+
el.dataset.repo = r;
|
|
437
|
+
el.tabIndex = 0;
|
|
438
|
+
el.setAttribute("role", "button");
|
|
439
|
+
el.setAttribute(
|
|
440
|
+
"aria-label",
|
|
441
|
+
`${r}, ${svcs.length} services, ${svcs.filter((s) => s.health.ok).length} healthy. Open details.`,
|
|
442
|
+
);
|
|
443
|
+
el.innerHTML = repoCardInner(r);
|
|
444
|
+
return place(el, x, y, w, h);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function legend() {
|
|
448
|
+
const kinds = new Set(edges.map((e) => (e.cross ? "cross" : e.kind)));
|
|
449
|
+
const items = [
|
|
450
|
+
["hard", "var(--accent)", "solid", "hard dependency"],
|
|
451
|
+
["soft", "#8b949e", "dashed", "soft"],
|
|
452
|
+
["event", "#bc8cff", "dotted", "event"],
|
|
453
|
+
["intra", "#8b949e", "solid", "within a repo"],
|
|
454
|
+
["cross", "var(--dg-remote)", "dashed", "local → remote"],
|
|
455
|
+
].filter(([k]) => kinds.has(k));
|
|
456
|
+
$(".dg-legend").innerHTML =
|
|
457
|
+
items
|
|
458
|
+
.map(
|
|
459
|
+
([, c, s, l]) =>
|
|
460
|
+
`<span><i style="border-top-color:${c};border-top-style:${s}"></i>${l}</span>`,
|
|
461
|
+
)
|
|
462
|
+
.join("") +
|
|
463
|
+
(M.mixed ? "<span>⌂ local · ☁ remote · ⇄ hybrid</span>" : "") +
|
|
464
|
+
"<span>click a card for details</span>";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── focus, find ──────────────────────────────────────────────────────────
|
|
468
|
+
const nodeKey = (sel) => {
|
|
469
|
+
if (!sel.repo) return nodeFor(M, st.collapsed, sel.id);
|
|
470
|
+
const l = M.byRepo.get(sel.repo) || [];
|
|
471
|
+
return isCollapsed(M, st.collapsed, sel.repo) || l.length === 1
|
|
472
|
+
? nodeFor(M, st.collapsed, l[0]?.id)
|
|
473
|
+
: "rg:" + sel.repo;
|
|
474
|
+
};
|
|
475
|
+
function applyFocus() {
|
|
476
|
+
if (!host) return;
|
|
477
|
+
const key = st.sel ? nodeKey(st.sel) : null;
|
|
478
|
+
const repoSel = key?.startsWith("rg:") ? st.sel.repo : null;
|
|
479
|
+
let focus = st.sel ? neighbours(edges, key) : null;
|
|
480
|
+
if (repoSel) {
|
|
481
|
+
focus = new Set();
|
|
482
|
+
for (const s of M.byRepo.get(repoSel))
|
|
483
|
+
neighbours(edges, s.id).forEach((k) => focus.add(k));
|
|
484
|
+
}
|
|
485
|
+
host
|
|
486
|
+
.querySelectorAll(".dg-grp.dg-repo")
|
|
487
|
+
.forEach((g) => g.classList.toggle("sel", g.dataset.repo === repoSel));
|
|
488
|
+
const q = st.q.trim().toLowerCase();
|
|
489
|
+
host.querySelectorAll(".dg-card").forEach((c) => {
|
|
490
|
+
const k = c.dataset.id || "repo:" + c.dataset.repo;
|
|
491
|
+
const hit = !!q && k.toLowerCase().includes(q);
|
|
492
|
+
c.classList.toggle("sel", key != null && k === key);
|
|
493
|
+
c.classList.toggle("hit", hit);
|
|
494
|
+
c.classList.toggle("dim", (focus && !focus.has(k)) || (!!q && !hit));
|
|
495
|
+
});
|
|
496
|
+
host.querySelectorAll("path.dg-e").forEach((p) => {
|
|
497
|
+
const on =
|
|
498
|
+
!!focus &&
|
|
499
|
+
(repoSel
|
|
500
|
+
? M.byId.get(p.dataset.f)?.repo === repoSel ||
|
|
501
|
+
M.byId.get(p.dataset.t)?.repo === repoSel
|
|
502
|
+
: p.dataset.f === key || p.dataset.t === key);
|
|
503
|
+
p.classList.toggle("hl", on);
|
|
504
|
+
p.classList.toggle("dim", (!!focus && !on) || !!q);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function focusOn(sel) {
|
|
508
|
+
if (sel.viaRepo) {
|
|
509
|
+
const l = M.byRepo.get(sel.repo) || [];
|
|
510
|
+
sel =
|
|
511
|
+
l.length === 1
|
|
512
|
+
? { id: l[0].id }
|
|
513
|
+
: isCollapsed(M, st.collapsed, sel.repo)
|
|
514
|
+
? { repo: sel.repo }
|
|
515
|
+
: { id: entryOf(M, sel.repo).id };
|
|
516
|
+
}
|
|
517
|
+
if (sel.tab) st.tab = sel.tab;
|
|
518
|
+
openDetail(
|
|
519
|
+
{ ...(sel.repo ? { repo: sel.repo } : { id: sel.id }) },
|
|
520
|
+
!!sel.tab,
|
|
521
|
+
);
|
|
522
|
+
const a = layoutAbs[nodeKey(sel)];
|
|
523
|
+
if (a) {
|
|
524
|
+
const vp = $(".dg-vp").getBoundingClientRect();
|
|
525
|
+
st.tx = vp.width / 2 - (a.x + a.w / 2) * st.z;
|
|
526
|
+
st.ty = vp.height / 2 - (a.y + a.h / 2) * st.z;
|
|
527
|
+
applyView();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── panel ────────────────────────────────────────────────────────────────
|
|
532
|
+
function openDetail(sel, keepTab) {
|
|
533
|
+
closeMenus(); // the panel re-renders: its menu's button goes away
|
|
534
|
+
if (!keepTab && (!st.sel || nodeKey(st.sel) !== nodeKey(sel))) st.tab = null;
|
|
535
|
+
st.sel = sel;
|
|
536
|
+
applyFocus();
|
|
537
|
+
const side = $(".dg-panel");
|
|
538
|
+
let target = side;
|
|
539
|
+
if (st.popped) {
|
|
540
|
+
side.hidden = true;
|
|
541
|
+
let back = document.querySelector(".dg-modal-back");
|
|
542
|
+
if (!back) {
|
|
543
|
+
back = document.createElement("div");
|
|
544
|
+
back.className = "dg dg-modal-back";
|
|
545
|
+
back.innerHTML =
|
|
546
|
+
'<aside class="dg-panel" role="dialog" aria-modal="true" aria-label="Details"></aside>';
|
|
547
|
+
document.body.appendChild(back);
|
|
548
|
+
back.addEventListener("click", (e) => {
|
|
549
|
+
if (e.target === back) closeDetail();
|
|
550
|
+
else onClick(e);
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
target = back.querySelector(".dg-panel");
|
|
554
|
+
} else {
|
|
555
|
+
document.querySelector(".dg-modal-back")?.remove();
|
|
556
|
+
}
|
|
557
|
+
if (!renderPanel(target, sel)) {
|
|
558
|
+
closeDetail();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (!st.popped) {
|
|
562
|
+
side.hidden = false;
|
|
563
|
+
side.style.width = st.panelW + "px";
|
|
564
|
+
requestAnimationFrame(() => keepVisible(sel));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function keepVisible(sel) {
|
|
568
|
+
const a = layoutAbs[nodeKey(sel)];
|
|
569
|
+
if (!a) return;
|
|
570
|
+
const vp = $(".dg-vp").getBoundingClientRect();
|
|
571
|
+
// On a phone the panel is a bottom sheet over the canvas.
|
|
572
|
+
const sheet = window.matchMedia("(max-width: 760px)").matches
|
|
573
|
+
? $(".dg-panel").getBoundingClientRect().height
|
|
574
|
+
: 0;
|
|
575
|
+
const x = a.x * st.z + st.tx,
|
|
576
|
+
y = a.y * st.z + st.ty,
|
|
577
|
+
w = a.w * st.z,
|
|
578
|
+
h = a.h * st.z;
|
|
579
|
+
const bottom = vp.height - 12 - sheet;
|
|
580
|
+
if (x < 12) st.tx += 12 - x;
|
|
581
|
+
else if (x + w > vp.width - 12) st.tx -= x + w - vp.width + 12;
|
|
582
|
+
if (y < 12) st.ty += 12 - y;
|
|
583
|
+
else if (y + h > bottom) st.ty -= y + h - bottom;
|
|
584
|
+
applyView();
|
|
585
|
+
}
|
|
586
|
+
function closeDetail() {
|
|
587
|
+
closeMenus();
|
|
588
|
+
disposePanel();
|
|
589
|
+
st.sel = null;
|
|
590
|
+
const side = $(".dg-panel");
|
|
591
|
+
if (side) side.hidden = true;
|
|
592
|
+
document.querySelector(".dg-modal-back")?.remove();
|
|
593
|
+
applyFocus();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ── pan & zoom ───────────────────────────────────────────────────────────
|
|
597
|
+
function applyView() {
|
|
598
|
+
const w = $(".dg-world");
|
|
599
|
+
if (!w) return;
|
|
600
|
+
w.style.transform = `translate(${st.tx}px,${st.ty}px) scale(${st.z})`;
|
|
601
|
+
$('[data-z="100"]').textContent = Math.round(st.z * 100) + "%";
|
|
602
|
+
}
|
|
603
|
+
function zoomAt(f, cx, cy) {
|
|
604
|
+
const vp = $(".dg-vp").getBoundingClientRect();
|
|
605
|
+
cx ??= vp.width / 2;
|
|
606
|
+
cy ??= vp.height / 2;
|
|
607
|
+
const nz = Math.min(2, Math.max(zoomFloor, st.z * f));
|
|
608
|
+
st.tx = cx - ((cx - st.tx) * nz) / st.z;
|
|
609
|
+
st.ty = cy - ((cy - st.ty) * nz) / st.z;
|
|
610
|
+
st.z = nz;
|
|
611
|
+
applyView();
|
|
612
|
+
}
|
|
613
|
+
/** Fit the graph; automatic fits pass floor 0.6, the Fit button 0.35. */
|
|
614
|
+
function fit(floor = zoomFloor) {
|
|
615
|
+
const vp = $(".dg-vp")?.getBoundingClientRect();
|
|
616
|
+
if (!vp) return;
|
|
617
|
+
const w = parseFloat($(".dg-world").style.width) || 1,
|
|
618
|
+
h = parseFloat($(".dg-world").style.height) || 1;
|
|
619
|
+
st.z = Math.max(
|
|
620
|
+
floor,
|
|
621
|
+
Math.min(1, (vp.width - 32) / w, (vp.height - 70) / h),
|
|
622
|
+
);
|
|
623
|
+
st.tx = Math.max(16, (vp.width - w * st.z) / 2);
|
|
624
|
+
st.ty = 16;
|
|
625
|
+
applyView();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ── events ───────────────────────────────────────────────────────────────
|
|
629
|
+
function toast(msg) {
|
|
630
|
+
document.querySelector(".dg-toast")?.remove();
|
|
631
|
+
const t = document.createElement("div");
|
|
632
|
+
t.className = "dg-toast";
|
|
633
|
+
t.setAttribute("role", "status");
|
|
634
|
+
t.textContent = msg;
|
|
635
|
+
document.body.appendChild(t);
|
|
636
|
+
setTimeout(() => t.remove(), 3200);
|
|
637
|
+
}
|
|
638
|
+
async function recheck(services) {
|
|
639
|
+
const env = hooks.env?.() || "local";
|
|
640
|
+
const rows = await Promise.all(
|
|
641
|
+
services.map((s) =>
|
|
642
|
+
fetch(
|
|
643
|
+
`/api/health?env=${encodeURIComponent(env)}&address=${encodeURIComponent(s.id)}`,
|
|
644
|
+
)
|
|
645
|
+
.then((r) => (r.ok ? r.json() : []))
|
|
646
|
+
.catch(() => []),
|
|
647
|
+
),
|
|
648
|
+
);
|
|
649
|
+
graphHealth(rows.flat());
|
|
650
|
+
toast(
|
|
651
|
+
`Checked ${services.length === 1 ? services[0].id : services.length + " services"}`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
// An open More menu moves to a layer on <body>: inside the panel, WebKit
|
|
655
|
+
// clips even a fixed menu to the panel's scroll box once the panel scrolls
|
|
656
|
+
// (the Logs tab). It goes back beside its button when it closes.
|
|
657
|
+
const menuOwner = new WeakMap(); // menu -> its More button
|
|
658
|
+
const ownMenu = new WeakMap(); // More button -> its menu, while in the layer
|
|
659
|
+
function menuLayer() {
|
|
660
|
+
let l = document.querySelector("body > .dg-layer");
|
|
661
|
+
if (!l) {
|
|
662
|
+
l = document.createElement("div");
|
|
663
|
+
l.className = "dg-layer";
|
|
664
|
+
l.addEventListener("click", onClick);
|
|
665
|
+
document.body.appendChild(l);
|
|
666
|
+
}
|
|
667
|
+
return l;
|
|
668
|
+
}
|
|
669
|
+
function menuOf(more) {
|
|
670
|
+
return ownMenu.get(more) ?? more.nextElementSibling;
|
|
671
|
+
}
|
|
672
|
+
function openMenus() {
|
|
673
|
+
return [...document.querySelectorAll(".dg-menu")].filter((m) => !m.hidden);
|
|
674
|
+
}
|
|
675
|
+
function closeMenus(except) {
|
|
676
|
+
for (const m of document.querySelectorAll(".dg-menu")) {
|
|
677
|
+
if (m === except) continue;
|
|
678
|
+
m.hidden = true;
|
|
679
|
+
const more = menuOwner.get(m) ?? m.previousElementSibling;
|
|
680
|
+
more?.setAttribute("aria-expanded", "false");
|
|
681
|
+
if (m.parentElement?.classList.contains("dg-layer")) {
|
|
682
|
+
menuOwner.delete(m);
|
|
683
|
+
if (more) ownMenu.delete(more);
|
|
684
|
+
if (more?.isConnected) more.after(m);
|
|
685
|
+
else m.remove();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function placeMenu(more, m) {
|
|
690
|
+
menuOwner.set(m, more);
|
|
691
|
+
ownMenu.set(more, m);
|
|
692
|
+
menuLayer().appendChild(m);
|
|
693
|
+
const r = more.getBoundingClientRect();
|
|
694
|
+
const below = window.innerHeight - r.bottom - 12,
|
|
695
|
+
above = r.top - 12;
|
|
696
|
+
const up = below < 260 && above > below;
|
|
697
|
+
Object.assign(m.style, {
|
|
698
|
+
right: Math.max(8, window.innerWidth - r.right) + "px",
|
|
699
|
+
left: "auto",
|
|
700
|
+
top: up ? "auto" : r.bottom + 4 + "px",
|
|
701
|
+
bottom: up ? window.innerHeight - r.top + 4 + "px" : "auto",
|
|
702
|
+
maxHeight: (up ? above : below) + "px",
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
function arm(btn) {
|
|
706
|
+
if (btn.dataset.armed) return true;
|
|
707
|
+
btn.dataset.armed = "1";
|
|
708
|
+
btn.textContent = "Confirm: " + btn.textContent;
|
|
709
|
+
btn.classList.add("armed");
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function onClick(e) {
|
|
714
|
+
const t = e.target;
|
|
715
|
+
const more = t.closest("[data-more]");
|
|
716
|
+
if (more) {
|
|
717
|
+
const m = menuOf(more);
|
|
718
|
+
if (!m.hidden) {
|
|
719
|
+
closeMenus();
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
closeMenus();
|
|
723
|
+
m.hidden = false;
|
|
724
|
+
more.setAttribute("aria-expanded", "true");
|
|
725
|
+
placeMenu(more, m);
|
|
726
|
+
m.querySelector("button:not(:disabled)")?.focus();
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (!t.closest(".dg-more, .dg-layer")) closeMenus();
|
|
730
|
+
const act = t.closest("[data-action]");
|
|
731
|
+
if (act) {
|
|
732
|
+
if (act.disabled) return;
|
|
733
|
+
if (act.hasAttribute("data-confirm") && !arm(act)) return;
|
|
734
|
+
const c = t.closest(".dg-card");
|
|
735
|
+
if (c && act.dataset.action === "logs") {
|
|
736
|
+
st.tab = "logs";
|
|
737
|
+
openDetail({ id: c.dataset.id }, true);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
runAction(act.dataset.action, act.dataset.key);
|
|
741
|
+
if (act.closest(".dg-menu")) closeMenus();
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const runb = t.closest("[data-run]");
|
|
745
|
+
if (runb) {
|
|
746
|
+
if (runb.hasAttribute("data-confirm") && !arm(runb)) return;
|
|
747
|
+
const s = M.byId.get(runb.dataset.key);
|
|
748
|
+
if (s) hooks.runCmd(s, runb.dataset.run);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const add = t.closest("[data-add-dep]");
|
|
752
|
+
if (add) {
|
|
753
|
+
hooks.addMissing?.(add.dataset.addDep);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const tab = t.closest("[data-tab]");
|
|
757
|
+
if (tab) {
|
|
758
|
+
st.tab = tab.dataset.tab;
|
|
759
|
+
openDetail(st.sel, true);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (t.closest("[data-popout]")) {
|
|
763
|
+
st.popped = !st.popped;
|
|
764
|
+
openDetail(st.sel, true);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (t.closest("[data-close]")) {
|
|
768
|
+
closeDetail();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const gp = t.closest("[data-goto-repo-panel]");
|
|
772
|
+
if (gp) {
|
|
773
|
+
focusOn({ repo: gp.dataset.gotoRepoPanel });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const ex = t.closest("[data-expand]");
|
|
777
|
+
if (ex) {
|
|
778
|
+
st.collapsed.delete(ex.dataset.expand);
|
|
779
|
+
store.set("collapsed", [...st.collapsed]);
|
|
780
|
+
render();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const co = t.closest("[data-collapse]");
|
|
784
|
+
if (co) {
|
|
785
|
+
st.collapsed.add(co.dataset.collapse);
|
|
786
|
+
store.set("collapsed", [...st.collapsed]);
|
|
787
|
+
render();
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const go = t.closest("[data-goto]");
|
|
791
|
+
if (go) {
|
|
792
|
+
focusOn({ id: go.dataset.goto });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const gr = t.closest("[data-goto-repo]");
|
|
796
|
+
if (gr) {
|
|
797
|
+
focusOn({ repo: gr.dataset.gotoRepo, viaRepo: true });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const c = t.closest(".dg-card");
|
|
801
|
+
if (c)
|
|
802
|
+
openDetail(
|
|
803
|
+
c.dataset.repo ? { repo: c.dataset.repo } : { id: c.dataset.id },
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function syncControls() {
|
|
808
|
+
const map = { dir: st.dir, group: st.group, density: st.density };
|
|
809
|
+
host
|
|
810
|
+
.querySelectorAll("[data-seg]")
|
|
811
|
+
.forEach((el) =>
|
|
812
|
+
el
|
|
813
|
+
.querySelectorAll("button")
|
|
814
|
+
.forEach((b) =>
|
|
815
|
+
b.setAttribute(
|
|
816
|
+
"aria-pressed",
|
|
817
|
+
String(b.dataset.v === map[el.dataset.seg]),
|
|
818
|
+
),
|
|
819
|
+
),
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function wire() {
|
|
824
|
+
const root = $(".dg");
|
|
825
|
+
root.addEventListener("click", onClick);
|
|
826
|
+
host.querySelectorAll("[data-seg]").forEach((el) =>
|
|
827
|
+
el.addEventListener("click", (e) => {
|
|
828
|
+
const b = e.target.closest("button");
|
|
829
|
+
if (!b) return;
|
|
830
|
+
st[el.dataset.seg] = b.dataset.v;
|
|
831
|
+
syncControls();
|
|
832
|
+
savePrefs();
|
|
833
|
+
render().then(() => {
|
|
834
|
+
if (el.dataset.seg !== "density") fit(0.6);
|
|
835
|
+
if (st.sel) keepVisible(st.sel);
|
|
836
|
+
});
|
|
837
|
+
}),
|
|
838
|
+
);
|
|
839
|
+
$("[data-expand]").addEventListener("click", (e) => {
|
|
840
|
+
const b = e.target.closest("button");
|
|
841
|
+
if (!b) return;
|
|
842
|
+
e.stopPropagation();
|
|
843
|
+
st.collapsed =
|
|
844
|
+
b.dataset.v === "all"
|
|
845
|
+
? new Set()
|
|
846
|
+
: new Set(
|
|
847
|
+
[...M.byRepo.keys()].filter((r) => M.byRepo.get(r).length > 1),
|
|
848
|
+
);
|
|
849
|
+
store.set("collapsed", [...st.collapsed]);
|
|
850
|
+
render().then(() => fit(0.6));
|
|
851
|
+
});
|
|
852
|
+
const find = $("[data-find]");
|
|
853
|
+
find.addEventListener("input", () => {
|
|
854
|
+
st.q = find.value;
|
|
855
|
+
applyFocus();
|
|
856
|
+
});
|
|
857
|
+
find.addEventListener("keydown", (e) => {
|
|
858
|
+
if (e.key !== "Enter") return;
|
|
859
|
+
const q = st.q.trim().toLowerCase();
|
|
860
|
+
if (!q) return;
|
|
861
|
+
const s = M.services.find((x) => x.id.toLowerCase().includes(q));
|
|
862
|
+
if (s)
|
|
863
|
+
focusOn(
|
|
864
|
+
isCollapsed(M, st.collapsed, s.repo) ? { repo: s.repo } : { id: s.id },
|
|
865
|
+
);
|
|
866
|
+
});
|
|
867
|
+
// pan: drag the background; scroll moves; Ctrl/⌘ + scroll zooms
|
|
868
|
+
const vp = $(".dg-vp");
|
|
869
|
+
let drag = null;
|
|
870
|
+
vp.addEventListener("pointerdown", (e) => {
|
|
871
|
+
if (e.button !== 0 || e.target.closest(".dg-card,button,.dg-zoom")) return;
|
|
872
|
+
drag = { x: e.clientX, y: e.clientY, tx: st.tx, ty: st.ty, moved: false };
|
|
873
|
+
vp.setPointerCapture(e.pointerId);
|
|
874
|
+
vp.classList.add("dragging");
|
|
875
|
+
});
|
|
876
|
+
vp.addEventListener("pointermove", (e) => {
|
|
877
|
+
if (!drag) return;
|
|
878
|
+
const dx = e.clientX - drag.x,
|
|
879
|
+
dy = e.clientY - drag.y;
|
|
880
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
|
|
881
|
+
st.tx = drag.tx + dx;
|
|
882
|
+
st.ty = drag.ty + dy;
|
|
883
|
+
applyView();
|
|
884
|
+
});
|
|
885
|
+
const end = (e) => {
|
|
886
|
+
if (drag && !drag.moved && !e.target.closest(".dg-card,button"))
|
|
887
|
+
closeDetail();
|
|
888
|
+
drag = null;
|
|
889
|
+
vp.classList.remove("dragging");
|
|
890
|
+
};
|
|
891
|
+
vp.addEventListener("pointerup", end);
|
|
892
|
+
vp.addEventListener("pointercancel", () => {
|
|
893
|
+
drag = null;
|
|
894
|
+
vp.classList.remove("dragging");
|
|
895
|
+
});
|
|
896
|
+
vp.addEventListener(
|
|
897
|
+
"wheel",
|
|
898
|
+
(e) => {
|
|
899
|
+
e.preventDefault();
|
|
900
|
+
const r = vp.getBoundingClientRect();
|
|
901
|
+
if (e.ctrlKey || e.metaKey)
|
|
902
|
+
zoomAt(
|
|
903
|
+
Math.exp(-e.deltaY * 0.0022),
|
|
904
|
+
e.clientX - r.left,
|
|
905
|
+
e.clientY - r.top,
|
|
906
|
+
);
|
|
907
|
+
else {
|
|
908
|
+
st.tx -= e.deltaX;
|
|
909
|
+
st.ty -= e.deltaY;
|
|
910
|
+
applyView();
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
{ passive: false },
|
|
914
|
+
);
|
|
915
|
+
host.querySelector(".dg-zoom").addEventListener("click", (e) => {
|
|
916
|
+
const z = e.target.closest("[data-z]")?.dataset.z;
|
|
917
|
+
if (z === "in") zoomAt(1.2);
|
|
918
|
+
if (z === "out") zoomAt(1 / 1.2);
|
|
919
|
+
if (z === "100") {
|
|
920
|
+
st.z = 1;
|
|
921
|
+
st.tx = 24;
|
|
922
|
+
st.ty = 24;
|
|
923
|
+
applyView();
|
|
924
|
+
}
|
|
925
|
+
if (z === "fit") fit(zoomFloor);
|
|
926
|
+
});
|
|
927
|
+
// panel resize (360–760px, remembered)
|
|
928
|
+
let rs = null;
|
|
929
|
+
root.addEventListener("pointerdown", (e) => {
|
|
930
|
+
if (!e.target.closest("[data-resize]")) return;
|
|
931
|
+
rs = { x: e.clientX, w: st.panelW };
|
|
932
|
+
e.target.setPointerCapture(e.pointerId);
|
|
933
|
+
e.preventDefault();
|
|
934
|
+
});
|
|
935
|
+
root.addEventListener("pointermove", (e) => {
|
|
936
|
+
if (!rs) return;
|
|
937
|
+
st.panelW = Math.round(
|
|
938
|
+
Math.max(360, Math.min(760, rs.w + (rs.x - e.clientX))),
|
|
939
|
+
);
|
|
940
|
+
$(".dg-panel").style.width = st.panelW + "px";
|
|
941
|
+
});
|
|
942
|
+
root.addEventListener("pointerup", () => {
|
|
943
|
+
if (rs) {
|
|
944
|
+
rs = null;
|
|
945
|
+
savePrefs();
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Esc closes the menu first, then the panel. Enter/Space opens a card.
|
|
951
|
+
document.addEventListener("keydown", (e) => {
|
|
952
|
+
if (!host?.isConnected) return;
|
|
953
|
+
if (e.key === "Escape") {
|
|
954
|
+
const m = openMenus()[0];
|
|
955
|
+
if (m) {
|
|
956
|
+
const more = menuOwner.get(m) ?? m.previousElementSibling;
|
|
957
|
+
closeMenus();
|
|
958
|
+
more?.focus();
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (st.sel) closeDetail();
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
if (
|
|
965
|
+
(e.key === "Enter" || e.key === " ") &&
|
|
966
|
+
e.target.classList?.contains("dg-card")
|
|
967
|
+
) {
|
|
968
|
+
e.preventDefault();
|
|
969
|
+
e.target.click();
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
document.addEventListener("click", (e) => {
|
|
973
|
+
if (!e.target.closest?.(".dg-more, .dg-layer")) closeMenus();
|
|
974
|
+
});
|
|
975
|
+
// The menu is placed once, against its button: a scroll that moves the
|
|
976
|
+
// button (not the log view following new lines) or a resize closes it.
|
|
977
|
+
document.addEventListener(
|
|
978
|
+
"scroll",
|
|
979
|
+
(e) => {
|
|
980
|
+
const t = e.target === document ? document.documentElement : e.target;
|
|
981
|
+
if (openMenus().some((m) => t.contains?.(menuOwner.get(m) ?? m)))
|
|
982
|
+
closeMenus();
|
|
983
|
+
},
|
|
984
|
+
true,
|
|
985
|
+
);
|
|
986
|
+
window.addEventListener("resize", () => openMenus().length && closeMenus());
|