@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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>__DEN_TITLE__ · Local Dev</title>
|
|
7
|
+
<link rel="stylesheet" href="/graph.css" />
|
|
7
8
|
<style>
|
|
8
9
|
*,
|
|
9
10
|
*::before,
|
|
@@ -136,57 +137,6 @@
|
|
|
136
137
|
opacity: 0.35;
|
|
137
138
|
}
|
|
138
139
|
}
|
|
139
|
-
/* SVG health dots in the dependency graph — mirror .dot states */
|
|
140
|
-
.health-dot {
|
|
141
|
-
fill: var(--muted);
|
|
142
|
-
}
|
|
143
|
-
.health-dot.ok {
|
|
144
|
-
fill: var(--green);
|
|
145
|
-
animation: none;
|
|
146
|
-
}
|
|
147
|
-
.health-dot.err {
|
|
148
|
-
fill: var(--red);
|
|
149
|
-
animation: none;
|
|
150
|
-
}
|
|
151
|
-
.health-dot.running {
|
|
152
|
-
fill: var(--yellow);
|
|
153
|
-
animation: pulse 1s ease-in-out infinite;
|
|
154
|
-
}
|
|
155
|
-
.health-refresh-icon {
|
|
156
|
-
opacity: 0;
|
|
157
|
-
pointer-events: none;
|
|
158
|
-
}
|
|
159
|
-
.health-dot-wrap:hover .health-dot {
|
|
160
|
-
opacity: 0;
|
|
161
|
-
}
|
|
162
|
-
.health-dot-wrap:hover .health-refresh-icon {
|
|
163
|
-
opacity: 1;
|
|
164
|
-
}
|
|
165
|
-
.open-link-hit {
|
|
166
|
-
fill: transparent;
|
|
167
|
-
cursor: pointer;
|
|
168
|
-
}
|
|
169
|
-
.open-link:hover .open-link-hit,
|
|
170
|
-
.open-link:focus .open-link-hit {
|
|
171
|
-
fill: #8b949e30;
|
|
172
|
-
}
|
|
173
|
-
.open-link:focus {
|
|
174
|
-
outline: none;
|
|
175
|
-
}
|
|
176
|
-
.open-link:focus-visible .open-link-hit {
|
|
177
|
-
stroke: var(--accent, #58a6ff);
|
|
178
|
-
stroke-width: 1.5;
|
|
179
|
-
}
|
|
180
|
-
.open-link-icon {
|
|
181
|
-
fill: none;
|
|
182
|
-
stroke: var(--accent, #58a6ff);
|
|
183
|
-
stroke-width: 1.4;
|
|
184
|
-
pointer-events: none;
|
|
185
|
-
}
|
|
186
|
-
.open-link:hover .open-link-icon {
|
|
187
|
-
stroke: var(--text);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
140
|
/* ── Layout ───────────────────────────────────────────────────────────── */
|
|
191
141
|
main {
|
|
192
142
|
flex: 1;
|
|
@@ -196,6 +146,29 @@
|
|
|
196
146
|
min-height: 0;
|
|
197
147
|
}
|
|
198
148
|
|
|
149
|
+
/* A phone gets the workspace content only (the header's workspace pill
|
|
150
|
+
still switches workspaces), so the graph has the width it needs. */
|
|
151
|
+
@media (max-width: 760px) {
|
|
152
|
+
main {
|
|
153
|
+
grid-template-columns: minmax(0, 1fr);
|
|
154
|
+
}
|
|
155
|
+
main > .sidebar {
|
|
156
|
+
display: none;
|
|
157
|
+
}
|
|
158
|
+
.content-body {
|
|
159
|
+
padding: 10px 8px;
|
|
160
|
+
}
|
|
161
|
+
/* The page scrolls instead of squeezing the graph to a strip. */
|
|
162
|
+
#ctab-graph.active {
|
|
163
|
+
overflow: visible;
|
|
164
|
+
flex: none;
|
|
165
|
+
}
|
|
166
|
+
#ctab-graph #dep-graph-wrap {
|
|
167
|
+
flex: none;
|
|
168
|
+
height: 70vh;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
199
172
|
/* ── Sidebar ──────────────────────────────────────────────────────────── */
|
|
200
173
|
.sidebar {
|
|
201
174
|
border-right: 1px solid var(--border);
|
|
@@ -1285,6 +1258,11 @@
|
|
|
1285
1258
|
background: var(--border);
|
|
1286
1259
|
}
|
|
1287
1260
|
|
|
1261
|
+
/* With nothing run yet, only the tab bar shows: the graph gets the room. */
|
|
1262
|
+
#output-panel:has(.tab-empty) #output,
|
|
1263
|
+
#output-panel:has(.tab-empty) .output-actions {
|
|
1264
|
+
display: none;
|
|
1265
|
+
}
|
|
1288
1266
|
#output {
|
|
1289
1267
|
height: 200px;
|
|
1290
1268
|
overflow-y: auto;
|
|
@@ -1492,10 +1470,6 @@
|
|
|
1492
1470
|
flex: 1;
|
|
1493
1471
|
min-height: 0;
|
|
1494
1472
|
position: relative;
|
|
1495
|
-
cursor: grab;
|
|
1496
|
-
}
|
|
1497
|
-
#dep-graph-wrap:active {
|
|
1498
|
-
cursor: grabbing;
|
|
1499
1473
|
}
|
|
1500
1474
|
|
|
1501
1475
|
/* ── Applications tab ─────────────────────────────────────────────────── */
|
|
@@ -1552,6 +1526,21 @@
|
|
|
1552
1526
|
font-weight: 400;
|
|
1553
1527
|
font-size: 12px;
|
|
1554
1528
|
}
|
|
1529
|
+
.app-place {
|
|
1530
|
+
font-size: 11px;
|
|
1531
|
+
border: 1px solid var(--border);
|
|
1532
|
+
border-radius: 10px;
|
|
1533
|
+
padding: 0 8px;
|
|
1534
|
+
color: var(--muted);
|
|
1535
|
+
}
|
|
1536
|
+
.app-place.remote {
|
|
1537
|
+
color: #39c5cf;
|
|
1538
|
+
border-color: #39c5cf88;
|
|
1539
|
+
}
|
|
1540
|
+
.app-place.hybrid {
|
|
1541
|
+
color: var(--yellow);
|
|
1542
|
+
border-color: #d2992288;
|
|
1543
|
+
}
|
|
1555
1544
|
.app-source {
|
|
1556
1545
|
font-size: 11px;
|
|
1557
1546
|
color: var(--muted);
|
|
@@ -1618,84 +1607,6 @@
|
|
|
1618
1607
|
.app-into {
|
|
1619
1608
|
opacity: 0.8;
|
|
1620
1609
|
}
|
|
1621
|
-
#dep-graph {
|
|
1622
|
-
display: block;
|
|
1623
|
-
width: 100%;
|
|
1624
|
-
height: 100%;
|
|
1625
|
-
}
|
|
1626
|
-
.gnode rect {
|
|
1627
|
-
stroke-width: 1;
|
|
1628
|
-
}
|
|
1629
|
-
.gnode rect:hover {
|
|
1630
|
-
cursor: pointer;
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
/* ── Node actions popup ────────────────────────────────────────────────── */
|
|
1634
|
-
#node-actions-popup {
|
|
1635
|
-
position: fixed;
|
|
1636
|
-
z-index: 9999;
|
|
1637
|
-
background: var(--surface);
|
|
1638
|
-
border: 1px solid var(--border);
|
|
1639
|
-
border-radius: 6px;
|
|
1640
|
-
padding: 4px;
|
|
1641
|
-
min-width: 130px;
|
|
1642
|
-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
|
1643
|
-
}
|
|
1644
|
-
#node-actions-popup .popup-hdr {
|
|
1645
|
-
font-size: 10px;
|
|
1646
|
-
font-weight: 600;
|
|
1647
|
-
color: var(--muted);
|
|
1648
|
-
text-transform: uppercase;
|
|
1649
|
-
letter-spacing: 0.06em;
|
|
1650
|
-
padding: 3px 8px 5px;
|
|
1651
|
-
border-bottom: 1px solid var(--border);
|
|
1652
|
-
margin-bottom: 3px;
|
|
1653
|
-
font-family: var(--mono);
|
|
1654
|
-
}
|
|
1655
|
-
#node-actions-popup .popup-empty {
|
|
1656
|
-
font-size: 11px;
|
|
1657
|
-
color: var(--muted);
|
|
1658
|
-
padding: 4px 8px;
|
|
1659
|
-
}
|
|
1660
|
-
#node-actions-popup .popup-cmd-btn {
|
|
1661
|
-
display: block;
|
|
1662
|
-
width: 100%;
|
|
1663
|
-
background: none;
|
|
1664
|
-
border: none;
|
|
1665
|
-
text-align: left;
|
|
1666
|
-
color: var(--text);
|
|
1667
|
-
font-size: 11px;
|
|
1668
|
-
font-family: var(--mono);
|
|
1669
|
-
padding: 4px 8px;
|
|
1670
|
-
border-radius: 4px;
|
|
1671
|
-
cursor: pointer;
|
|
1672
|
-
white-space: nowrap;
|
|
1673
|
-
}
|
|
1674
|
-
#node-actions-popup .popup-cmd-btn:hover {
|
|
1675
|
-
background: var(--border);
|
|
1676
|
-
}
|
|
1677
|
-
#node-actions-popup .popup-divider {
|
|
1678
|
-
border-top: 1px solid var(--border);
|
|
1679
|
-
margin: 4px 0;
|
|
1680
|
-
}
|
|
1681
|
-
.gnode text {
|
|
1682
|
-
fill: var(--text);
|
|
1683
|
-
font-size: 11px;
|
|
1684
|
-
font-family: var(--mono);
|
|
1685
|
-
pointer-events: none;
|
|
1686
|
-
}
|
|
1687
|
-
.gnode .gnode-mode {
|
|
1688
|
-
fill: var(--muted);
|
|
1689
|
-
font-size: 9px;
|
|
1690
|
-
}
|
|
1691
|
-
.gnode .gnode-dot {
|
|
1692
|
-
}
|
|
1693
|
-
.gedge {
|
|
1694
|
-
stroke: var(--border);
|
|
1695
|
-
stroke-width: 1.5;
|
|
1696
|
-
fill: none;
|
|
1697
|
-
marker-end: url(#arrow);
|
|
1698
|
-
}
|
|
1699
1610
|
.graph-empty {
|
|
1700
1611
|
padding: 40px;
|
|
1701
1612
|
text-align: center;
|
|
@@ -1986,6 +1897,7 @@
|
|
|
1986
1897
|
copyToClipboard,
|
|
1987
1898
|
computePrimaryByRepo,
|
|
1988
1899
|
} from "/header.js";
|
|
1900
|
+
import { mountGraph, graphHealth } from "/graph.js";
|
|
1989
1901
|
|
|
1990
1902
|
// Re-ping all health dots (top nav + cards) NOW — used after lifecycle
|
|
1991
1903
|
// commands so status doesn't wait out the 30s poll interval.
|
|
@@ -4509,11 +4421,7 @@
|
|
|
4509
4421
|
setTabStatus(tabName, { live: false, errored: !data.valid });
|
|
4510
4422
|
}
|
|
4511
4423
|
|
|
4512
|
-
// ──
|
|
4513
|
-
// Multi-service repos show every service by default; repos in this Set were
|
|
4514
|
-
// collapsed by hand (the ▾ strip) into one node.
|
|
4515
|
-
const collapsedRepos = new Set();
|
|
4516
|
-
const expandedRepos = { has: (repo) => !collapsedRepos.has(repo) };
|
|
4424
|
+
// ── Service ordering (cards, config table) ───────────────────────────────
|
|
4517
4425
|
// Sort by service name (leaf): e.g. "api", "db", "worker" — ignores repo path.
|
|
4518
4426
|
function _sortByName(services) {
|
|
4519
4427
|
return [...services].sort((a, b) => {
|
|
@@ -4638,224 +4546,6 @@
|
|
|
4638
4546
|
_graphApps = []; // /api/applications for the viewed workspace
|
|
4639
4547
|
let _lastHealthResults = []; // cache of most recent __DEN_CLI__:health-update payload
|
|
4640
4548
|
|
|
4641
|
-
// ── Graph selection highlight ─────────────────────────────────────────────
|
|
4642
|
-
// Clicking a card selects it: its border lights up and the arrows to/from it
|
|
4643
|
-
// stay bright while every other edge dims. Collected per render (elements are
|
|
4644
|
-
// rebuilt each renderDepGraph) so we can toggle styles in place without a
|
|
4645
|
-
// re-render (which would reset the user's zoom/pan). Selecting a card also
|
|
4646
|
-
// opens that system's logs below — see openSystemLogs.
|
|
4647
|
-
let _selectedNode = null;
|
|
4648
|
-
let _graphEdgeEls = []; // { el, from, to }
|
|
4649
|
-
let _graphNodeEls = []; // { g, rect, address }
|
|
4650
|
-
function clearGraphSelection() {
|
|
4651
|
-
_selectedNode = null;
|
|
4652
|
-
for (const e of _graphEdgeEls) e.el.style.opacity = "";
|
|
4653
|
-
for (const n of _graphNodeEls) {
|
|
4654
|
-
if (!n.rect) continue;
|
|
4655
|
-
n.rect.style.stroke = "";
|
|
4656
|
-
n.rect.style.strokeWidth = "";
|
|
4657
|
-
}
|
|
4658
|
-
}
|
|
4659
|
-
function selectGraphNode(address) {
|
|
4660
|
-
if (_selectedNode === address) {
|
|
4661
|
-
clearGraphSelection();
|
|
4662
|
-
return;
|
|
4663
|
-
}
|
|
4664
|
-
_selectedNode = address;
|
|
4665
|
-
for (const e of _graphEdgeEls) {
|
|
4666
|
-
const connected = e.from === address || e.to === address;
|
|
4667
|
-
e.el.style.opacity = connected ? "1" : "0.08";
|
|
4668
|
-
}
|
|
4669
|
-
for (const n of _graphNodeEls) {
|
|
4670
|
-
if (!n.rect) continue;
|
|
4671
|
-
const isSel = n.address === address;
|
|
4672
|
-
n.rect.style.stroke = isSel ? "#f0f6fc" : "";
|
|
4673
|
-
n.rect.style.strokeWidth = isSel ? "3" : "";
|
|
4674
|
-
}
|
|
4675
|
-
}
|
|
4676
|
-
|
|
4677
|
-
function healthState(keys, healthMap) {
|
|
4678
|
-
// Only true/false are "known"; null (no health check) / undefined (not
|
|
4679
|
-
// polled) are unknown → excluded, leaving the dot muted.
|
|
4680
|
-
const known = keys
|
|
4681
|
-
.map((k) => healthMap[k])
|
|
4682
|
-
.filter((v) => v === true || v === false);
|
|
4683
|
-
if (known.length === 0) return ""; // no health data — leave as muted default
|
|
4684
|
-
const allOk = known.every((v) => v === true);
|
|
4685
|
-
const anyOk = known.some((v) => v === true);
|
|
4686
|
-
return allOk ? "ok" : anyOk ? "running" : "err";
|
|
4687
|
-
}
|
|
4688
|
-
|
|
4689
|
-
// The health key(s) a system's SINGLE dot reflects: the entrypoint only for an
|
|
4690
|
-
// app-repo (so a down app isn't masked green by shared-infra sidecars that
|
|
4691
|
-
// read ok off infra-stack — mirrors the CLI's rollupRepoHealth), else every
|
|
4692
|
-
// subsystem for an entrypoint-less infra provider like infra-stack (its
|
|
4693
|
-
// subsystems ARE the system, so their aggregate is the meaningful signal).
|
|
4694
|
-
function groupDotKeys(services) {
|
|
4695
|
-
const list = services || [];
|
|
4696
|
-
const entry = list.find((s) => s.isEntrypoint);
|
|
4697
|
-
return (entry ? [entry] : list).map((s) => `${s.repo}/${s.service}`);
|
|
4698
|
-
}
|
|
4699
|
-
|
|
4700
|
-
function applyHealthToGraph(results) {
|
|
4701
|
-
const healthMap = {};
|
|
4702
|
-
const openMap = {};
|
|
4703
|
-
for (const h of results || []) {
|
|
4704
|
-
healthMap[`${h.repo}/${h.service}`] = h.ok;
|
|
4705
|
-
if (h.openUrl) openMap[`${h.repo}/${h.service}`] = h.openUrl;
|
|
4706
|
-
}
|
|
4707
|
-
document.querySelectorAll("#dep-graph .gnode").forEach((g) => {
|
|
4708
|
-
const keys = g.dataset.healthKeys
|
|
4709
|
-
? g.dataset.healthKeys.split(",")
|
|
4710
|
-
: [];
|
|
4711
|
-
// A collapsed card's keys are its entrypoint, so it opens the app.
|
|
4712
|
-
const openUrl =
|
|
4713
|
-
keys.map((k) => openMap[k]).find(Boolean) ||
|
|
4714
|
-
openMap[`${g.dataset.repo}/${g.dataset.service}`] ||
|
|
4715
|
-
null;
|
|
4716
|
-
setOpenLink(g, openUrl);
|
|
4717
|
-
const dot = g.querySelector(".health-dot");
|
|
4718
|
-
if (!dot) return;
|
|
4719
|
-
if (keys.length === 0) return;
|
|
4720
|
-
const state = healthState(keys, healthMap);
|
|
4721
|
-
dot.setAttribute("class", `health-dot${state ? " " + state : ""}`);
|
|
4722
|
-
});
|
|
4723
|
-
// Open-in-browser links follow the env the results were checked for.
|
|
4724
|
-
const openUrls = {};
|
|
4725
|
-
for (const h of results || [])
|
|
4726
|
-
if (h.openUrl) openUrls[`${h.repo}/${h.service}`] = h.openUrl;
|
|
4727
|
-
document.querySelectorAll("#dep-graph a.open-browser").forEach((a) => {
|
|
4728
|
-
const url = openUrls[a.dataset.openKey];
|
|
4729
|
-
if (url && /^https?:/.test(url)) {
|
|
4730
|
-
a.setAttribute("href", url);
|
|
4731
|
-
a.setAttribute(
|
|
4732
|
-
"aria-label",
|
|
4733
|
-
`Open ${a.dataset.openKey} in a new tab`,
|
|
4734
|
-
);
|
|
4735
|
-
const tip = a.querySelector("title");
|
|
4736
|
-
if (tip)
|
|
4737
|
-
tip.textContent = `Open in a new tab: ${url} (shift-click copies)`;
|
|
4738
|
-
a.style.display = "";
|
|
4739
|
-
} else {
|
|
4740
|
-
a.removeAttribute("href");
|
|
4741
|
-
a.style.display = "none";
|
|
4742
|
-
}
|
|
4743
|
-
});
|
|
4744
|
-
}
|
|
4745
|
-
|
|
4746
|
-
// Globe at a card's lower right: opens the app for the selected env in a new
|
|
4747
|
-
// tab (Shift-click copies the URL). Hidden until health supplies a URL.
|
|
4748
|
-
// An SVG <a>, so it's focusable and Enter opens it.
|
|
4749
|
-
function setOpenLink(g, url) {
|
|
4750
|
-
const ns = "http://www.w3.org/2000/svg";
|
|
4751
|
-
let a = g.querySelector("a.open-link");
|
|
4752
|
-
if (!url) {
|
|
4753
|
-
if (a) a.remove();
|
|
4754
|
-
return;
|
|
4755
|
-
}
|
|
4756
|
-
if (!a) {
|
|
4757
|
-
const card = g.querySelector("rect");
|
|
4758
|
-
if (!card) return;
|
|
4759
|
-
const x =
|
|
4760
|
-
Number(card.getAttribute("x")) +
|
|
4761
|
-
Number(card.getAttribute("width")) -
|
|
4762
|
-
34;
|
|
4763
|
-
const y =
|
|
4764
|
-
Number(card.getAttribute("y")) +
|
|
4765
|
-
Number(card.getAttribute("height")) -
|
|
4766
|
-
17;
|
|
4767
|
-
a = document.createElementNS(ns, "a");
|
|
4768
|
-
a.setAttribute("class", "open-link");
|
|
4769
|
-
a.setAttribute("target", "_blank");
|
|
4770
|
-
a.setAttribute("rel", "noopener");
|
|
4771
|
-
const title = document.createElementNS(ns, "title");
|
|
4772
|
-
a.appendChild(title);
|
|
4773
|
-
const hit = document.createElementNS(ns, "rect");
|
|
4774
|
-
hit.setAttribute("x", x - 2);
|
|
4775
|
-
hit.setAttribute("y", y - 2);
|
|
4776
|
-
hit.setAttribute("width", 16);
|
|
4777
|
-
hit.setAttribute("height", 16);
|
|
4778
|
-
hit.setAttribute("rx", 3);
|
|
4779
|
-
hit.setAttribute("class", "open-link-hit");
|
|
4780
|
-
a.appendChild(hit);
|
|
4781
|
-
const globe = document.createElementNS(ns, "g");
|
|
4782
|
-
globe.setAttribute("transform", `translate(${x},${y})`);
|
|
4783
|
-
globe.setAttribute("class", "open-link-icon");
|
|
4784
|
-
globe.innerHTML =
|
|
4785
|
-
'<circle cx="6" cy="6" r="5.25"/><ellipse cx="6" cy="6" rx="2.3" ry="5.25"/><path d="M0.75 6h10.5"/>';
|
|
4786
|
-
a.appendChild(globe);
|
|
4787
|
-
a.addEventListener("click", (e) => {
|
|
4788
|
-
e.stopPropagation();
|
|
4789
|
-
if (e.shiftKey) {
|
|
4790
|
-
e.preventDefault();
|
|
4791
|
-
const t = a.querySelector("title");
|
|
4792
|
-
navigator.clipboard?.writeText(a.getAttribute("href")).then(
|
|
4793
|
-
() => {
|
|
4794
|
-
t.textContent = "Copied " + a.getAttribute("href");
|
|
4795
|
-
},
|
|
4796
|
-
() => {},
|
|
4797
|
-
);
|
|
4798
|
-
}
|
|
4799
|
-
});
|
|
4800
|
-
a.addEventListener("keydown", (e) => e.stopPropagation());
|
|
4801
|
-
g.appendChild(a);
|
|
4802
|
-
}
|
|
4803
|
-
if (a.getAttribute("href") !== url) {
|
|
4804
|
-
a.setAttribute("href", url);
|
|
4805
|
-
a.querySelector("title").textContent =
|
|
4806
|
-
`Open ${url} (Shift-click copies)`;
|
|
4807
|
-
}
|
|
4808
|
-
}
|
|
4809
|
-
|
|
4810
|
-
async function reloadNodeHealth(address) {
|
|
4811
|
-
const env = document.getElementById("env-select")?.value || "local";
|
|
4812
|
-
// Pulse the dot while checking
|
|
4813
|
-
document.querySelectorAll("#dep-graph .gnode").forEach((g) => {
|
|
4814
|
-
if (g.dataset.address !== address) return;
|
|
4815
|
-
const dot = g.querySelector(".health-dot");
|
|
4816
|
-
if (dot) dot.setAttribute("class", "health-dot running");
|
|
4817
|
-
});
|
|
4818
|
-
try {
|
|
4819
|
-
const res = await fetch(
|
|
4820
|
-
`/api/health?env=${env}&address=${encodeURIComponent(address)}`,
|
|
4821
|
-
);
|
|
4822
|
-
const results = await res.json();
|
|
4823
|
-
if (!results.length) return;
|
|
4824
|
-
const healthMap = {};
|
|
4825
|
-
for (const h of results) healthMap[`${h.repo}/${h.service}`] = h.ok;
|
|
4826
|
-
document.querySelectorAll("#dep-graph .gnode").forEach((g) => {
|
|
4827
|
-
if (g.dataset.address !== address) return;
|
|
4828
|
-
const dot = g.querySelector(".health-dot");
|
|
4829
|
-
if (!dot) return;
|
|
4830
|
-
const keys = g.dataset.healthKeys
|
|
4831
|
-
? g.dataset.healthKeys.split(",")
|
|
4832
|
-
: [];
|
|
4833
|
-
const state = healthState(keys, healthMap);
|
|
4834
|
-
dot.setAttribute("class", `health-dot${state ? " " + state : ""}`);
|
|
4835
|
-
});
|
|
4836
|
-
} catch {
|
|
4837
|
-
/* ignore */
|
|
4838
|
-
}
|
|
4839
|
-
}
|
|
4840
|
-
|
|
4841
|
-
// Repo colour palette: assigned by stable hash of repo name.
|
|
4842
|
-
const REPO_PALETTE = [
|
|
4843
|
-
{ accent: "#58a6ff", bg: "#141b26", stroke: "#1d3a5e" },
|
|
4844
|
-
{ accent: "#3fb950", bg: "#131d16", stroke: "#1a3d22" },
|
|
4845
|
-
{ accent: "#a371f7", bg: "#1a1428", stroke: "#3d2066" },
|
|
4846
|
-
{ accent: "#d29922", bg: "#1d1a13", stroke: "#3d3216" },
|
|
4847
|
-
{ accent: "#f0883e", bg: "#1d1814", stroke: "#3d2a18" },
|
|
4848
|
-
{ accent: "#56d364", bg: "#131d16", stroke: "#1a3d22" },
|
|
4849
|
-
{ accent: "#ff7b72", bg: "#1d1414", stroke: "#3d1a1a" },
|
|
4850
|
-
{ accent: "#79c0ff", bg: "#141b26", stroke: "#1d3a5e" },
|
|
4851
|
-
];
|
|
4852
|
-
function repoColor(name) {
|
|
4853
|
-
let h = 5381;
|
|
4854
|
-
for (let i = 0; i < name.length; i++)
|
|
4855
|
-
h = (Math.imul(h, 31) + name.charCodeAt(i)) | 0;
|
|
4856
|
-
return REPO_PALETTE[Math.abs(h) % REPO_PALETTE.length];
|
|
4857
|
-
}
|
|
4858
|
-
|
|
4859
4549
|
// ── Applications tab ───────────────────────────────────────────────────
|
|
4860
4550
|
// Same as `__DEN_CLI__ apps`: Org > Application > Repo > Services. Declared
|
|
4861
4551
|
// applications (config `applications:` / manifest `application:`), then
|
|
@@ -4884,11 +4574,40 @@
|
|
|
4884
4574
|
'<div class="graph-empty">No applications in this workspace</div>';
|
|
4885
4575
|
return;
|
|
4886
4576
|
}
|
|
4577
|
+
// Service entries are addresses; placement (R13) sits beside them in
|
|
4578
|
+
// `placements[address]`. A remote service gets ☁ after its name.
|
|
4579
|
+
const svcLabel = (a, addr, r) => {
|
|
4580
|
+
const name = addr.includes("/") ? addr.split("/")[1] : addr;
|
|
4581
|
+
const p = a.placements?.[addr] ?? tree.placements?.[addr];
|
|
4582
|
+
return { name, remote: p?.runs === "remote", root: name === r };
|
|
4583
|
+
};
|
|
4887
4584
|
const repoChip = (a, r) => {
|
|
4888
|
-
const
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4585
|
+
const all = (a.services?.[r] || []).map((addr) =>
|
|
4586
|
+
svcLabel(a, addr, r),
|
|
4587
|
+
);
|
|
4588
|
+
const subs = all
|
|
4589
|
+
.filter((x) => !x.root)
|
|
4590
|
+
.map((x) => x.name + (x.remote ? " ☁" : ""));
|
|
4591
|
+
const rootRemote = all.some((x) => x.root && x.remote);
|
|
4592
|
+
return `<span class="app-repo" data-repo="${escHtml(r)}"><span class="app-dot"></span>${escHtml(r)}${rootRemote ? " ☁" : ""}${subs.length ? ` <span class="app-subs">${escHtml(subs.join(", "))}</span>` : ""}</span>`;
|
|
4593
|
+
};
|
|
4594
|
+
const PLACE = {
|
|
4595
|
+
remote: "☁ remote",
|
|
4596
|
+
hybrid: "⇄ hybrid",
|
|
4597
|
+
none: "no runtime",
|
|
4598
|
+
};
|
|
4599
|
+
const placeBadge = (a) =>
|
|
4600
|
+
PLACE[a.placement]
|
|
4601
|
+
? `<span class="app-place ${escHtml(a.placement)}" title="${escHtml(a.summary ?? "")}">${PLACE[a.placement]}</span>`
|
|
4602
|
+
: "";
|
|
4603
|
+
const placeSummary = (a) => {
|
|
4604
|
+
const t = a.summary;
|
|
4605
|
+
return a.placement &&
|
|
4606
|
+
a.placement !== "local" &&
|
|
4607
|
+
typeof t === "string" &&
|
|
4608
|
+
t
|
|
4609
|
+
? `<div class="app-desc">${escHtml(t)}</div>`
|
|
4610
|
+
: "";
|
|
4892
4611
|
};
|
|
4893
4612
|
wrap.innerHTML =
|
|
4894
4613
|
(tree.org
|
|
@@ -4903,6 +4622,7 @@
|
|
|
4903
4622
|
${a.title !== a.name ? `<code class="app-name">${escHtml(a.name)}</code>` : ""}
|
|
4904
4623
|
${a.owner ? `<span class="app-owner">${escHtml(a.owner)}</span>` : ""}
|
|
4905
4624
|
${a.source === "repo" ? '<span class="app-source">repo is the application</span>' : ""}
|
|
4625
|
+
${placeBadge(a)}
|
|
4906
4626
|
<span class="cmd-bar-spacer"></span>
|
|
4907
4627
|
${
|
|
4908
4628
|
a.repos.length
|
|
@@ -4914,6 +4634,7 @@
|
|
|
4914
4634
|
}
|
|
4915
4635
|
</div>
|
|
4916
4636
|
${a.description ? `<div class="app-desc">${escHtml(a.description)}</div>` : ""}
|
|
4637
|
+
${placeSummary(a)}
|
|
4917
4638
|
<div class="app-repos">${a.repos.map((r) => repoChip(a, r)).join("")}</div>
|
|
4918
4639
|
${
|
|
4919
4640
|
a.contributors.length
|
|
@@ -4967,19 +4688,52 @@
|
|
|
4967
4688
|
if (
|
|
4968
4689
|
_orgData &&
|
|
4969
4690
|
_graphServices &&
|
|
4970
|
-
document.getElementById("dep-graph")
|
|
4691
|
+
document.getElementById("dep-graph-wrap")
|
|
4971
4692
|
)
|
|
4972
4693
|
renderDepGraph(_graphServices, _graphDepsMap);
|
|
4973
4694
|
})
|
|
4974
4695
|
.catch(() => {});
|
|
4975
4696
|
|
|
4697
|
+
// ── Dependency graph ─────────────────────────────────────────────────────
|
|
4698
|
+
// ELK-laid-out cards and the details panel live in graph.js / panel.js
|
|
4699
|
+
// (registries: ACTIONS, TABS, SECTIONS). The page hands them the data it
|
|
4700
|
+
// already loads and its own action functions, so every action runs the
|
|
4701
|
+
// same calls as before (runCmd, up/down with deps, sync, editor, …).
|
|
4702
|
+
const GRAPH_HOOKS = {
|
|
4703
|
+
env: () => document.getElementById("env-sel")?.value ?? "local",
|
|
4704
|
+
runCmd: (s, cmd) => window.runCmd(s.id, s.repo, s.name, cmd),
|
|
4705
|
+
upWithDeps: (repo) => window.runUpWithDeps(repo),
|
|
4706
|
+
downWithDeps: (repo) => window.runDownWithDeps(repo),
|
|
4707
|
+
sync: (repo) => window.runRepoSync(repo),
|
|
4708
|
+
toggleAutostart: (repo, turnOn) => window.toggleAutostart(repo, turnOn),
|
|
4709
|
+
switchMode: (repo, mode) => window.switchMode(repo, mode),
|
|
4710
|
+
openEditor: (p) => openInEditor(p),
|
|
4711
|
+
openTerminal: (p) => openInTerminal(p),
|
|
4712
|
+
showOutput: (s) => openSystemLogs(s.id, s.repo, s.name),
|
|
4713
|
+
openLogsPage: (s) => {
|
|
4714
|
+
storeOpenLog(s.repo, s.name, "local");
|
|
4715
|
+
window.location.href =
|
|
4716
|
+
"/logs?open=" + encodeURIComponent(s.repo + "/" + s.name);
|
|
4717
|
+
},
|
|
4718
|
+
addMissing: (dep) => {
|
|
4719
|
+
if (!activeWs) return;
|
|
4720
|
+
openAddSvcModal(activeWs);
|
|
4721
|
+
// Pre-fill the search with the missing dep so it is easy to find.
|
|
4722
|
+
setTimeout(() => {
|
|
4723
|
+
const input = document.getElementById("add-svc-global-search");
|
|
4724
|
+
if (input) {
|
|
4725
|
+
input.value = dep;
|
|
4726
|
+
input.dispatchEvent(new Event("input"));
|
|
4727
|
+
}
|
|
4728
|
+
}, 0);
|
|
4729
|
+
},
|
|
4730
|
+
};
|
|
4731
|
+
|
|
4976
4732
|
function renderDepGraph(services, depsMap, derivedGraph) {
|
|
4977
4733
|
_graphServices = services;
|
|
4978
4734
|
_graphDepsMap = depsMap;
|
|
4979
4735
|
_graphDerived = derivedGraph ??
|
|
4980
4736
|
_graphDerived ?? { nodes: [], edges: [] };
|
|
4981
|
-
derivedGraph = _graphDerived;
|
|
4982
|
-
|
|
4983
4737
|
const wrap = document.getElementById("dep-graph-wrap");
|
|
4984
4738
|
if (!wrap) return;
|
|
4985
4739
|
if (!services || services.length === 0) {
|
|
@@ -4987,1833 +4741,19 @@
|
|
|
4987
4741
|
'<div class="graph-empty">No services in workspace</div>';
|
|
4988
4742
|
return;
|
|
4989
4743
|
}
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
for (const [repo, svcs] of repoGroups) {
|
|
5004
|
-
const color = repoColor(repo);
|
|
5005
|
-
const isMulti = svcs.length > 1;
|
|
5006
|
-
const isExpanded = expandedRepos.has(repo);
|
|
5007
|
-
|
|
5008
|
-
if (isMulti && !isExpanded) {
|
|
5009
|
-
// Collapsed: one group node for the whole repo
|
|
5010
|
-
displayNodes.push({
|
|
5011
|
-
type: "group",
|
|
5012
|
-
address: repo,
|
|
5013
|
-
repo,
|
|
5014
|
-
service: repo,
|
|
5015
|
-
services: svcs,
|
|
5016
|
-
mode: svcs[0].mode,
|
|
5017
|
-
repoPath: svcs[0].repoPath,
|
|
5018
|
-
cloned: !svcs.some((x) => x.cloned === false),
|
|
5019
|
-
color,
|
|
5020
|
-
});
|
|
5021
|
-
for (const s of svcs) addrToDisplay.set(s.address, repo);
|
|
5022
|
-
} else {
|
|
5023
|
-
// Expanded (or single-service): individual nodes
|
|
5024
|
-
for (const s of svcs) {
|
|
5025
|
-
displayNodes.push({ ...s, type: "service", color });
|
|
5026
|
-
addrToDisplay.set(s.address, s.address);
|
|
5027
|
-
}
|
|
5028
|
-
}
|
|
5029
|
-
}
|
|
5030
|
-
|
|
5031
|
-
// A repo's display-node address — the collapsed group node ("shop"), or,
|
|
5032
|
-
// when expanded, its representative subsystem node. Cross-repo edges attach
|
|
5033
|
-
// here, so they always resolve to a node that exists (collapsed or
|
|
5034
|
-
// expanded) — fixing the dangling-arrow/orphan bug on expand.
|
|
5035
|
-
const repoDisplayAddr = new Map();
|
|
5036
|
-
for (const n of displayNodes) {
|
|
5037
|
-
if (n.type === "group") repoDisplayAddr.set(n.repo, n.address);
|
|
5038
|
-
else if (!repoDisplayAddr.has(n.repo))
|
|
5039
|
-
repoDisplayAddr.set(n.repo, n.address);
|
|
5040
|
-
}
|
|
5041
|
-
const repoToDisplay = (repo) => repoDisplayAddr.get(repo) ?? null;
|
|
5042
|
-
const repoOf = (addr) =>
|
|
5043
|
-
addr.includes("/") ? addr.slice(0, addr.indexOf("/")) : addr;
|
|
5044
|
-
|
|
5045
|
-
// ── Build display edges ─────────────────────────────────────────────────
|
|
5046
|
-
const edgeKey = new Set();
|
|
5047
|
-
const displayEdges = [];
|
|
5048
|
-
const ghostAddrs = new Set(); // missing-dep ghost addresses still needed after remapping
|
|
5049
|
-
|
|
5050
|
-
// 1. CROSS-REPO edges come from the DERIVED graph (same as `__DEN_CLI__ graph` /
|
|
5051
|
-
// `__DEN_CLI__ up`), carrying kind (infra/http/jwks/event) + blocking
|
|
5052
|
-
// (hard/soft) so we can style soft vs hard distinctly.
|
|
5053
|
-
for (const e of derivedGraph.edges || []) {
|
|
5054
|
-
const fromD = repoToDisplay(e.from),
|
|
5055
|
-
toD = repoToDisplay(e.to);
|
|
5056
|
-
if (!fromD || !toD || fromD === toD) continue; // both must be workspace repos
|
|
5057
|
-
const key = fromD + "→" + toD + ":x";
|
|
5058
|
-
if (edgeKey.has(key)) continue;
|
|
5059
|
-
edgeKey.add(key);
|
|
5060
|
-
displayEdges.push({
|
|
5061
|
-
from: fromD,
|
|
5062
|
-
to: toD,
|
|
5063
|
-
kind: e.kind,
|
|
5064
|
-
blocking: e.blocking,
|
|
5065
|
-
});
|
|
5066
|
-
}
|
|
5067
|
-
|
|
5068
|
-
// 2. INTRA-REPO edges (only visible when a repo is expanded) come from the
|
|
5069
|
-
// manifest deps within the same repo. Cross-repo manifest deps are
|
|
5070
|
-
// ignored — the derived graph above is the source of truth for those.
|
|
5071
|
-
for (const s of services) {
|
|
5072
|
-
for (const dep of s.deps || []) {
|
|
5073
|
-
if (repoOf(dep) !== s.repo) continue; // not intra-repo
|
|
5074
|
-
const fromD = addrToDisplay.get(s.address) ?? s.address;
|
|
5075
|
-
const toD = addrToDisplay.get(dep) ?? dep;
|
|
5076
|
-
if (fromD === toD) continue; // collapsed → same group node
|
|
5077
|
-
const key = fromD + "→" + toD + ":i";
|
|
5078
|
-
if (edgeKey.has(key)) continue;
|
|
5079
|
-
edgeKey.add(key);
|
|
5080
|
-
displayEdges.push({ from: fromD, to: toD, isIntra: true });
|
|
5081
|
-
}
|
|
5082
|
-
}
|
|
5083
|
-
|
|
5084
|
-
// 3. MISSING deps (declared but not in the workspace) → yellow ghost nodes.
|
|
5085
|
-
for (const [addr, missing] of Object.entries(depsMap || {})) {
|
|
5086
|
-
const fromD = addrToDisplay.get(addr) ?? repoToDisplay(addr) ?? addr;
|
|
5087
|
-
for (const dep of missing || []) {
|
|
5088
|
-
if (
|
|
5089
|
-
addrToDisplay.has(dep) ||
|
|
5090
|
-
repoDisplayAddr.has(dep) ||
|
|
5091
|
-
repoDisplayAddr.has(repoOf(dep))
|
|
5092
|
-
)
|
|
5093
|
-
continue;
|
|
5094
|
-
ghostAddrs.add(dep);
|
|
5095
|
-
const key = fromD + "→" + dep + ":m";
|
|
5096
|
-
if (edgeKey.has(key)) continue;
|
|
5097
|
-
edgeKey.add(key);
|
|
5098
|
-
displayEdges.push({ from: fromD, to: dep, isMissing: true });
|
|
5099
|
-
}
|
|
5100
|
-
}
|
|
5101
|
-
|
|
5102
|
-
// Build the graph synchronously using the last cached health results.
|
|
5103
|
-
// The __DEN_CLI__:health-update event keeps dots in sync; using the cache here
|
|
5104
|
-
// prevents a race where the event fires before the SVG exists.
|
|
5105
|
-
{
|
|
5106
|
-
const healthList = _lastHealthResults;
|
|
5107
|
-
const healthMap = {};
|
|
5108
|
-
for (const h of healthList) {
|
|
5109
|
-
healthMap[`${h.repo}/${h.service}`] = h.ok;
|
|
5110
|
-
}
|
|
5111
|
-
|
|
5112
|
-
// Layer the graph along the canonical startup order (derivedGraph.order:
|
|
5113
|
-
// infra-stack first … entry repo last). The entry repo (e.g. shop-web)
|
|
5114
|
-
// goes to the top, foundations to the bottom. The derived graph has real
|
|
5115
|
-
// 2-cycles — shop ⇄ shop-web (BFF callback), shop ⇄ control-plane /
|
|
5116
|
-
// jobs-runtime / billing-api (a request one way, an event/JWKS call
|
|
5117
|
-
// back). We break them deterministically by only following edges that go
|
|
5118
|
-
// FORWARD in the start order (from a later-started repo to an
|
|
5119
|
-
// earlier-started one), which is exactly how `__DEN_CLI__ up` resolves order —
|
|
5120
|
-
// so the layout matches the real dependency flow instead of an arbitrary
|
|
5121
|
-
// cycle break.
|
|
5122
|
-
const order =
|
|
5123
|
-
derivedGraph.order && derivedGraph.order.length
|
|
5124
|
-
? derivedGraph.order
|
|
5125
|
-
: [...new Set(displayNodes.map((n) => n.repo))];
|
|
5126
|
-
const upRank = new Map(order.map((r, i) => [r, i])); // infra=0 … entry=last
|
|
5127
|
-
const rankOf = (repo) => (upRank.has(repo) ? upRank.get(repo) : -1);
|
|
5128
|
-
|
|
5129
|
-
const repoIn = new Map(); // repo → forward predecessor repos (start-later → start-earlier)
|
|
5130
|
-
for (const e of derivedGraph.edges || []) {
|
|
5131
|
-
if (rankOf(e.from) > rankOf(e.to)) {
|
|
5132
|
-
if (!repoIn.has(e.to)) repoIn.set(e.to, []);
|
|
5133
|
-
repoIn.get(e.to).push(e.from);
|
|
5134
|
-
}
|
|
5135
|
-
}
|
|
5136
|
-
const repoLayer = new Map();
|
|
5137
|
-
const _rs = new Map();
|
|
5138
|
-
const computeRepoLayer = (r) => {
|
|
5139
|
-
if (_rs.get(r) === 2) return repoLayer.get(r);
|
|
5140
|
-
if (_rs.get(r) === 1) return 0; // (forward subgraph is acyclic; guard anyway)
|
|
5141
|
-
_rs.set(r, 1);
|
|
5142
|
-
let best = 0;
|
|
5143
|
-
for (const p of repoIn.get(r) || [])
|
|
5144
|
-
best = Math.max(best, computeRepoLayer(p) + 1);
|
|
5145
|
-
_rs.set(r, 2);
|
|
5146
|
-
repoLayer.set(r, best);
|
|
5147
|
-
return best;
|
|
5148
|
-
};
|
|
5149
|
-
for (const r of order) computeRepoLayer(r);
|
|
5150
|
-
|
|
5151
|
-
// A repo NOTHING depends on is a "root" by the dependent-distance
|
|
5152
|
-
// measure above, so it lands in the top row even when its only
|
|
5153
|
-
// relationship is a dependency on the bottom-most infra — tasks-api
|
|
5154
|
-
// (edge: tasks-api → infra-stack, no dependents) sat next to
|
|
5155
|
-
// shop-web, a full canvas away from the one thing it connects to.
|
|
5156
|
-
// Pull sink-only consumers down to just above their shallowest
|
|
5157
|
-
// dependency: the row their edges actually justify. The entry repo is
|
|
5158
|
-
// unaffected (min(dep)-1 is already its row), and true islands keep
|
|
5159
|
-
// row 0 — there is nothing to be near.
|
|
5160
|
-
for (const r of order) {
|
|
5161
|
-
if ((repoIn.get(r) || []).length > 0) continue; // placed by dependents
|
|
5162
|
-
let minDep = Infinity;
|
|
5163
|
-
for (const e of derivedGraph.edges || []) {
|
|
5164
|
-
if (e.from === r && repoLayer.has(e.to)) {
|
|
5165
|
-
minDep = Math.min(minDep, repoLayer.get(e.to));
|
|
5166
|
-
}
|
|
5167
|
-
}
|
|
5168
|
-
if (minDep !== Infinity) {
|
|
5169
|
-
repoLayer.set(r, Math.max(repoLayer.get(r) ?? 0, minDep - 1));
|
|
5170
|
-
}
|
|
5171
|
-
}
|
|
5172
|
-
|
|
5173
|
-
// Every display node sits at its repo's layer; an expanded repo's
|
|
5174
|
-
// subsystems share that row (the bounding box spans them). Ghosts sit
|
|
5175
|
-
// just below the deepest dependent that references them.
|
|
5176
|
-
const layerOf = new Map();
|
|
5177
|
-
for (const n of displayNodes)
|
|
5178
|
-
layerOf.set(n.address, repoLayer.get(n.repo) ?? 0);
|
|
5179
|
-
for (const dep of ghostAddrs) {
|
|
5180
|
-
let lo = 0;
|
|
5181
|
-
for (const e of displayEdges)
|
|
5182
|
-
if (e.to === dep)
|
|
5183
|
-
lo = Math.max(lo, (layerOf.get(e.from) ?? 0) + 1);
|
|
5184
|
-
layerOf.set(dep, lo);
|
|
5185
|
-
}
|
|
5186
|
-
|
|
5187
|
-
// Compact layer numbers to consecutive 0..k so empty layers don't leave
|
|
5188
|
-
// gaps (and svgH/fit stay correct).
|
|
5189
|
-
const usedLayers = [...new Set([...layerOf.values()])].sort(
|
|
5190
|
-
(a, b) => a - b,
|
|
5191
|
-
);
|
|
5192
|
-
const layerRemap = new Map(usedLayers.map((v, i) => [v, i]));
|
|
5193
|
-
for (const [addr, v] of layerOf) layerOf.set(addr, layerRemap.get(v));
|
|
5194
|
-
|
|
5195
|
-
// Group by layer
|
|
5196
|
-
const layers = new Map();
|
|
5197
|
-
for (const [addr, layer] of layerOf) {
|
|
5198
|
-
if (!layers.has(layer)) layers.set(layer, []);
|
|
5199
|
-
layers.get(layer).push(addr);
|
|
5200
|
-
}
|
|
5201
|
-
const numLayers = layers.size || 1;
|
|
5202
|
-
|
|
5203
|
-
// Layout — TOP-DOWN (vertical): dependency layers stack as ROWS (roots at
|
|
5204
|
-
// top, dependencies flowing down), nodes within a layer spread across
|
|
5205
|
-
// COLUMNS. A dependency graph is mostly a deep chain, so laying layers
|
|
5206
|
-
// left-to-right made it short-and-wide; stacking them top-to-bottom gives
|
|
5207
|
-
// a far more balanced shape.
|
|
5208
|
-
const NW = 160,
|
|
5209
|
-
NH = 52,
|
|
5210
|
-
HGAP = 36,
|
|
5211
|
-
VGAP = 46,
|
|
5212
|
-
PAD = 24;
|
|
5213
|
-
const STRIP_H = 18,
|
|
5214
|
-
STRIP_GAP = 3,
|
|
5215
|
-
CPAD = 10; // collapse strip + container padding
|
|
5216
|
-
|
|
5217
|
-
const hasExpandedGroups = [...repoGroups.entries()].some(
|
|
5218
|
-
([repo, svcs]) => svcs.length > 1 && expandedRepos.has(repo),
|
|
5219
|
-
);
|
|
5220
|
-
const isExpandedRepo = (repo) =>
|
|
5221
|
-
(repoGroups.get(repo)?.length ?? 0) > 1 && expandedRepos.has(repo);
|
|
5222
|
-
// Room above an expanded group's top row for its collapse strip.
|
|
5223
|
-
const extraTopPad = hasExpandedGroups
|
|
5224
|
-
? STRIP_H + STRIP_GAP + CPAD + 4
|
|
5225
|
-
: 0;
|
|
5226
|
-
|
|
5227
|
-
// ── Blocks: Org > Application > Repo > Services ───────────────────────
|
|
5228
|
-
// Each declared application's repos form one block, boxed and titled
|
|
5229
|
-
// with name + owner. The rest only belong together when an edge says
|
|
5230
|
-
// so: each connected set of nodes (any edge kind, plus a repo's own
|
|
5231
|
-
// subsystems) is its own block, side by side; repos with no edges at
|
|
5232
|
-
// all share an "independent" block per org. A repo that is its own
|
|
5233
|
-
// application needs no box: its card is the boundary.
|
|
5234
|
-
const nodeByAddrEarly = new Map(
|
|
5235
|
-
displayNodes.map((n) => [n.address, n]),
|
|
5236
|
-
);
|
|
5237
|
-
const repoNameOf = (a) => nodeByAddrEarly.get(a)?.repo ?? repoOf(a);
|
|
5238
|
-
const appOfRepo = new Map();
|
|
5239
|
-
for (const a of _graphApps || [])
|
|
5240
|
-
if (a.source === "declared")
|
|
5241
|
-
for (const r of a.repos || [])
|
|
5242
|
-
if (!appOfRepo.has(r)) appOfRepo.set(r, a);
|
|
5243
|
-
// Ghost nodes are never part of an application.
|
|
5244
|
-
const appOfAddr = (a) =>
|
|
5245
|
-
nodeByAddrEarly.has(a) ? appOfRepo.get(repoNameOf(a)) : undefined;
|
|
5246
|
-
|
|
5247
|
-
const parent = new Map(
|
|
5248
|
-
[...layerOf.keys()].filter((a) => !appOfAddr(a)).map((a) => [a, a]),
|
|
5249
|
-
);
|
|
5250
|
-
const find = (a) => {
|
|
5251
|
-
while (parent.get(a) !== a) a = parent.get(a);
|
|
5252
|
-
return a;
|
|
5253
|
-
};
|
|
5254
|
-
const join = (a, b) => {
|
|
5255
|
-
if (!parent.has(a) || !parent.has(b)) return;
|
|
5256
|
-
const ra = find(a),
|
|
5257
|
-
rb = find(b);
|
|
5258
|
-
if (ra !== rb) parent.set(ra, rb);
|
|
5259
|
-
};
|
|
5260
|
-
for (const e of displayEdges) join(e.from, e.to);
|
|
5261
|
-
const firstOfRepo = new Map();
|
|
5262
|
-
for (const n of displayNodes) {
|
|
5263
|
-
if (firstOfRepo.has(n.repo))
|
|
5264
|
-
join(n.address, firstOfRepo.get(n.repo));
|
|
5265
|
-
else firstOfRepo.set(n.repo, n.address);
|
|
5266
|
-
}
|
|
5267
|
-
const comps = new Map();
|
|
5268
|
-
for (const a of parent.keys()) {
|
|
5269
|
-
const r = find(a);
|
|
5270
|
-
if (!comps.has(r)) comps.set(r, []);
|
|
5271
|
-
comps.get(r).push(a);
|
|
5272
|
-
}
|
|
5273
|
-
const isLoner = (c) =>
|
|
5274
|
-
new Set(c.map(repoNameOf)).size === 1 &&
|
|
5275
|
-
!displayEdges.some((e) => c.includes(e.from) || c.includes(e.to));
|
|
5276
|
-
const linked = [...comps.values()]
|
|
5277
|
-
.filter((c) => !isLoner(c))
|
|
5278
|
-
.sort((a, b) => b.length - a.length);
|
|
5279
|
-
const loners = [...comps.values()].filter(isLoner).flat();
|
|
5280
|
-
// Blocks sit together by org (the GitHub owner), in the configured
|
|
5281
|
-
// order, so each org's blocks fall inside one org frame; within an
|
|
5282
|
-
// org, declared applications come first, in config order. A block
|
|
5283
|
-
// whose repos span orgs gets no org.
|
|
5284
|
-
const orgOfRepo = (r) => _orgData?.repos?.[r] || "";
|
|
5285
|
-
const orgIds = _orgData ? _orgData.orgs.map((o) => o.id) : [];
|
|
5286
|
-
const blockOrg = (addrs) => {
|
|
5287
|
-
const os = new Set(
|
|
5288
|
-
addrs
|
|
5289
|
-
.filter((a) => nodeByAddrEarly.has(a))
|
|
5290
|
-
.map((a) => orgOfRepo(repoNameOf(a))),
|
|
5291
|
-
);
|
|
5292
|
-
return os.size === 1 ? [...os][0] : "";
|
|
5293
|
-
};
|
|
5294
|
-
const lonersByOrg = new Map();
|
|
5295
|
-
for (const a of loners) {
|
|
5296
|
-
const o = orgOfRepo(repoNameOf(a));
|
|
5297
|
-
if (!lonersByOrg.has(o)) lonersByOrg.set(o, []);
|
|
5298
|
-
lonersByOrg.get(o).push(a);
|
|
5299
|
-
}
|
|
5300
|
-
const orgRank = (o) => {
|
|
5301
|
-
const i = orgIds.indexOf(o);
|
|
5302
|
-
return o === "" ? 1e6 : i < 0 ? 1e5 : i;
|
|
5303
|
-
};
|
|
5304
|
-
const appBlocks = [];
|
|
5305
|
-
for (const a of _graphApps || []) {
|
|
5306
|
-
if (a.source !== "declared") continue;
|
|
5307
|
-
const addrs = [...layerOf.keys()].filter((x) => appOfAddr(x) === a);
|
|
5308
|
-
if (addrs.length)
|
|
5309
|
-
appBlocks.push({
|
|
5310
|
-
addrs,
|
|
5311
|
-
flat: false,
|
|
5312
|
-
app: a,
|
|
5313
|
-
org: blockOrg(addrs),
|
|
5314
|
-
label: a.title || a.name,
|
|
5315
|
-
});
|
|
5316
|
-
}
|
|
5317
|
-
const blocks = [
|
|
5318
|
-
...appBlocks,
|
|
5319
|
-
...linked.map((addrs) => ({
|
|
5320
|
-
addrs,
|
|
5321
|
-
flat: false,
|
|
5322
|
-
org: blockOrg(addrs),
|
|
5323
|
-
label: [
|
|
5324
|
-
...new Set(
|
|
5325
|
-
addrs.filter((a) => nodeByAddrEarly.has(a)).map(repoNameOf),
|
|
5326
|
-
),
|
|
5327
|
-
].join(" · "),
|
|
5328
|
-
})),
|
|
5329
|
-
...[...lonersByOrg].map(([org, addrs]) => ({
|
|
5330
|
-
addrs,
|
|
5331
|
-
flat: true,
|
|
5332
|
-
org,
|
|
5333
|
-
label: "independent",
|
|
5334
|
-
})),
|
|
5335
|
-
]
|
|
5336
|
-
.map((b, i) => ({ ...b, i }))
|
|
5337
|
-
.sort((a, b) => orgRank(a.org) - orgRank(b.org) || a.i - b.i);
|
|
5338
|
-
const orgsShown = new Set(blocks.map((b) => b.org).filter(Boolean));
|
|
5339
|
-
const ORG_H = orgsShown.size > 0 ? 22 : 0; // org frame title band
|
|
5340
|
-
|
|
5341
|
-
const BGAP = 56,
|
|
5342
|
-
// An application box has two title rows (name, owner); a group one.
|
|
5343
|
-
LABEL_H = blocks.some((b) => b.app)
|
|
5344
|
-
? 32
|
|
5345
|
-
: blocks.length > 1
|
|
5346
|
-
? 20
|
|
5347
|
-
: 0;
|
|
5348
|
-
const labelW = (b) =>
|
|
5349
|
-
blocks.length > 1 || b.app
|
|
5350
|
-
? Math.min(
|
|
5351
|
-
Math.max(b.label.length, b.app?.owner?.length ?? 0),
|
|
5352
|
-
60,
|
|
5353
|
-
) *
|
|
5354
|
-
6.8 +
|
|
5355
|
-
20 -
|
|
5356
|
-
CPAD * 3
|
|
5357
|
-
: 0;
|
|
5358
|
-
const pos = new Map();
|
|
5359
|
-
const blockBoxes = [];
|
|
5360
|
-
let xCursor = PAD,
|
|
5361
|
-
maxBlockH = 0;
|
|
5362
|
-
const top = PAD + extraTopPad + LABEL_H + ORG_H;
|
|
5363
|
-
// Between repo layers, room for an expanded repo's collapse strip.
|
|
5364
|
-
const LAYER_GAP = VGAP + STRIP_H + STRIP_GAP + CPAD;
|
|
5365
|
-
const RGAP = 40; // between repos side by side
|
|
5366
|
-
const nodeRepo = (a) => nodeByAddrEarly.get(a)?.repo ?? a; // ghosts: themselves
|
|
5367
|
-
for (const b of blocks) {
|
|
5368
|
-
if (b.flat) {
|
|
5369
|
-
const cols = Math.max(
|
|
5370
|
-
1,
|
|
5371
|
-
Math.min(b.addrs.length, Math.ceil(Math.sqrt(b.addrs.length))),
|
|
5372
|
-
);
|
|
5373
|
-
const rows = [];
|
|
5374
|
-
b.addrs.forEach((a, i) => {
|
|
5375
|
-
(rows[Math.floor(i / cols)] ??= []).push(a);
|
|
5376
|
-
});
|
|
5377
|
-
const per = Math.max(...rows.map((r) => r.length));
|
|
5378
|
-
const bw = Math.max(per * NW + (per - 1) * HGAP, labelW(b));
|
|
5379
|
-
rows.forEach((r, li) => {
|
|
5380
|
-
const tw = r.length * NW + (r.length - 1) * HGAP;
|
|
5381
|
-
const sx = xCursor + (bw - tw) / 2;
|
|
5382
|
-
r.forEach((a, i) =>
|
|
5383
|
-
pos.set(a, {
|
|
5384
|
-
x: sx + i * (NW + HGAP),
|
|
5385
|
-
y: top + li * (NH + VGAP),
|
|
5386
|
-
}),
|
|
5387
|
-
);
|
|
5388
|
-
});
|
|
5389
|
-
const bh = rows.length * NH + (rows.length - 1) * VGAP;
|
|
5390
|
-
blockBoxes.push({
|
|
5391
|
-
x: xCursor - CPAD * 1.5,
|
|
5392
|
-
y: PAD / 2 + ORG_H,
|
|
5393
|
-
w: bw + CPAD * 3,
|
|
5394
|
-
h: top + bh + CPAD - PAD / 2 - ORG_H,
|
|
5395
|
-
label: b.label,
|
|
5396
|
-
org: b.org,
|
|
5397
|
-
});
|
|
5398
|
-
maxBlockH = Math.max(maxBlockH, bh);
|
|
5399
|
-
xCursor += bw + BGAP;
|
|
5400
|
-
continue;
|
|
5401
|
-
}
|
|
5402
|
-
|
|
5403
|
-
// Each repo stacks its own services by depends_on (a service sits
|
|
5404
|
-
// above what it depends on: web over db); repos stack by the
|
|
5405
|
-
// cross-repo layer, side by side within a layer.
|
|
5406
|
-
const byRepo = new Map();
|
|
5407
|
-
for (const a of b.addrs) {
|
|
5408
|
-
const r = nodeRepo(a);
|
|
5409
|
-
if (!byRepo.has(r)) byRepo.set(r, []);
|
|
5410
|
-
byRepo.get(r).push(a);
|
|
5411
|
-
}
|
|
5412
|
-
const repoRows = new Map(); // repo → rows of addrs
|
|
5413
|
-
for (const [r, addrs] of byRepo) {
|
|
5414
|
-
const inRepo = new Set(addrs);
|
|
5415
|
-
const sub = new Map(addrs.map((a) => [a, 0]));
|
|
5416
|
-
const intra = displayEdges.filter(
|
|
5417
|
-
(e) => inRepo.has(e.from) && inRepo.has(e.to),
|
|
5418
|
-
);
|
|
5419
|
-
for (let i = 0; i < addrs.length; i++) {
|
|
5420
|
-
let moved = false;
|
|
5421
|
-
for (const e of intra) {
|
|
5422
|
-
const want = sub.get(e.from) + 1;
|
|
5423
|
-
if (want > sub.get(e.to) && want < addrs.length) {
|
|
5424
|
-
sub.set(e.to, want);
|
|
5425
|
-
moved = true;
|
|
5426
|
-
}
|
|
5427
|
-
}
|
|
5428
|
-
if (!moved) break;
|
|
5429
|
-
}
|
|
5430
|
-
const rows = [];
|
|
5431
|
-
for (const a of [...addrs].sort((x, y) => x.localeCompare(y)))
|
|
5432
|
-
(rows[sub.get(a)] ??= []).push(a);
|
|
5433
|
-
repoRows.set(r, rows.filter(Boolean));
|
|
5434
|
-
}
|
|
5435
|
-
const repoW = (r) => {
|
|
5436
|
-
const per = Math.max(...repoRows.get(r).map((x) => x.length));
|
|
5437
|
-
return per * NW + (per - 1) * HGAP;
|
|
5438
|
-
};
|
|
5439
|
-
const repoH = (r) => repoRows.get(r).length;
|
|
5440
|
-
const layerOfRepo = (r) => layerOf.get(byRepo.get(r)[0]) ?? 0;
|
|
5441
|
-
const repoLayers = [
|
|
5442
|
-
...new Set([...byRepo.keys()].map(layerOfRepo)),
|
|
5443
|
-
].sort((x, y) => x - y);
|
|
5444
|
-
const layerRepos = repoLayers.map((L) =>
|
|
5445
|
-
[...byRepo.keys()].filter((r) => layerOfRepo(r) === L).sort(),
|
|
5446
|
-
);
|
|
5447
|
-
const layerW = layerRepos.map(
|
|
5448
|
-
(rs) =>
|
|
5449
|
-
rs.reduce((w, r) => w + repoW(r), 0) + (rs.length - 1) * RGAP,
|
|
5450
|
-
);
|
|
5451
|
-
const bw = Math.max(...layerW, labelW(b));
|
|
5452
|
-
let y = top;
|
|
5453
|
-
layerRepos.forEach((rs, li) => {
|
|
5454
|
-
let x = xCursor + (bw - layerW[li]) / 2;
|
|
5455
|
-
for (const r of rs) {
|
|
5456
|
-
const w = repoW(r);
|
|
5457
|
-
repoRows.get(r).forEach((row, ri) => {
|
|
5458
|
-
const tw = row.length * NW + (row.length - 1) * HGAP;
|
|
5459
|
-
const sx = x + (w - tw) / 2;
|
|
5460
|
-
row.forEach((a, i) =>
|
|
5461
|
-
pos.set(a, {
|
|
5462
|
-
x: sx + i * (NW + HGAP),
|
|
5463
|
-
y: y + ri * (NH + VGAP),
|
|
5464
|
-
}),
|
|
5465
|
-
);
|
|
5466
|
-
});
|
|
5467
|
-
x += w + RGAP;
|
|
5468
|
-
}
|
|
5469
|
-
const rowsHere = Math.max(...rs.map(repoH));
|
|
5470
|
-
y +=
|
|
5471
|
-
rowsHere * (NH + VGAP) -
|
|
5472
|
-
VGAP +
|
|
5473
|
-
(li < layerRepos.length - 1 ? LAYER_GAP : 0);
|
|
5474
|
-
});
|
|
5475
|
-
const bh = y - top;
|
|
5476
|
-
// A group of one repo needs no frame of its own: the repo's box is
|
|
5477
|
-
// it. A declared application is always boxed.
|
|
5478
|
-
blockBoxes.push({
|
|
5479
|
-
x: xCursor - CPAD * 1.5,
|
|
5480
|
-
y: PAD / 2 + ORG_H,
|
|
5481
|
-
w: bw + CPAD * 3,
|
|
5482
|
-
h: top + bh + CPAD - PAD / 2 - ORG_H,
|
|
5483
|
-
label: b.label,
|
|
5484
|
-
solo: byRepo.size === 1,
|
|
5485
|
-
org: b.org,
|
|
5486
|
-
app: b.app,
|
|
5487
|
-
});
|
|
5488
|
-
maxBlockH = Math.max(maxBlockH, bh);
|
|
5489
|
-
xCursor += bw + BGAP;
|
|
5490
|
-
}
|
|
5491
|
-
const svgW = xCursor - BGAP + PAD;
|
|
5492
|
-
const svgH = top + maxBlockH + PAD;
|
|
5493
|
-
|
|
5494
|
-
// SVG root — no fixed size; CSS makes it fill the container.
|
|
5495
|
-
const ns = "http://www.w3.org/2000/svg";
|
|
5496
|
-
const svg = document.createElementNS(ns, "svg");
|
|
5497
|
-
svg.setAttribute("id", "dep-graph");
|
|
5498
|
-
|
|
5499
|
-
// Arrow markers
|
|
5500
|
-
const defs = document.createElementNS(ns, "defs");
|
|
5501
|
-
defs.innerHTML =
|
|
5502
|
-
'<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#4d9ee8"/></marker>' +
|
|
5503
|
-
'<marker id="arrow-soft" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#8b949e"/></marker>' +
|
|
5504
|
-
'<marker id="arrow-event" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#d2a8ff"/></marker>' +
|
|
5505
|
-
'<marker id="arrow-intra" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#6e7681"/></marker>' +
|
|
5506
|
-
'<marker id="arrow-warn" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#d29922"/></marker>';
|
|
5507
|
-
svg.appendChild(defs);
|
|
5508
|
-
|
|
5509
|
-
// All content lives inside this group — pan/zoom is applied via transform.
|
|
5510
|
-
const viewport = document.createElementNS(ns, "g");
|
|
5511
|
-
viewport.setAttribute("id", "graph-viewport");
|
|
5512
|
-
svg.appendChild(viewport);
|
|
5513
|
-
|
|
5514
|
-
// ── Org frames: one around each org's consecutive blocks ─────────────
|
|
5515
|
-
if (orgsShown.size > 0) {
|
|
5516
|
-
const runs = [];
|
|
5517
|
-
for (const bb of blockBoxes) {
|
|
5518
|
-
const last = runs[runs.length - 1];
|
|
5519
|
-
if (last && last.org === bb.org) last.boxes.push(bb);
|
|
5520
|
-
else runs.push({ org: bb.org, boxes: [bb] });
|
|
5521
|
-
}
|
|
5522
|
-
for (const run of runs) {
|
|
5523
|
-
if (!run.org) continue;
|
|
5524
|
-
const info = _orgData.orgs.find((o) => o.id === run.org) || {
|
|
5525
|
-
label: run.org,
|
|
5526
|
-
};
|
|
5527
|
-
const x0 = Math.min(...run.boxes.map((b) => b.x)) - 8;
|
|
5528
|
-
const x1 = Math.max(...run.boxes.map((b) => b.x + b.w)) + 8;
|
|
5529
|
-
const y0 = PAD / 2 - 6;
|
|
5530
|
-
const y1 = Math.max(...run.boxes.map((b) => b.y + b.h)) + 8;
|
|
5531
|
-
const stroke = info.color || "#6e7681";
|
|
5532
|
-
const fr = document.createElementNS(ns, "rect");
|
|
5533
|
-
fr.setAttribute("x", x0);
|
|
5534
|
-
fr.setAttribute("y", y0);
|
|
5535
|
-
fr.setAttribute("width", x1 - x0);
|
|
5536
|
-
fr.setAttribute("height", y1 - y0);
|
|
5537
|
-
fr.setAttribute("rx", "12");
|
|
5538
|
-
fr.setAttribute("fill", stroke + "0d");
|
|
5539
|
-
fr.setAttribute("stroke", stroke + "88");
|
|
5540
|
-
fr.setAttribute("stroke-width", "1.5");
|
|
5541
|
-
fr.setAttribute("pointer-events", "none");
|
|
5542
|
-
viewport.appendChild(fr);
|
|
5543
|
-
const ft = document.createElementNS(ns, "text");
|
|
5544
|
-
ft.setAttribute("x", x0 + 12);
|
|
5545
|
-
ft.setAttribute("y", y0 + 16);
|
|
5546
|
-
ft.setAttribute("fill", stroke);
|
|
5547
|
-
ft.setAttribute("font-size", "12");
|
|
5548
|
-
ft.setAttribute("font-weight", "600");
|
|
5549
|
-
ft.setAttribute("pointer-events", "none");
|
|
5550
|
-
ft.textContent = info.label;
|
|
5551
|
-
viewport.appendChild(ft);
|
|
5552
|
-
}
|
|
5553
|
-
}
|
|
5554
|
-
|
|
5555
|
-
// ── Block boxes ──────────────────────────────────────────────────────
|
|
5556
|
-
// A declared application: a solid box titled with name + owner. Any
|
|
5557
|
-
// other group: a dashed box, only when there is more than one block.
|
|
5558
|
-
for (const bb of blockBoxes) {
|
|
5559
|
-
if (bb.app) {
|
|
5560
|
-
const ag = document.createElementNS(ns, "g");
|
|
5561
|
-
ag.setAttribute("class", "app-box");
|
|
5562
|
-
ag.setAttribute("data-app", bb.app.name);
|
|
5563
|
-
const ar = document.createElementNS(ns, "rect");
|
|
5564
|
-
ar.setAttribute("x", bb.x);
|
|
5565
|
-
ar.setAttribute("y", bb.y);
|
|
5566
|
-
ar.setAttribute("width", bb.w);
|
|
5567
|
-
ar.setAttribute("height", bb.h);
|
|
5568
|
-
ar.setAttribute("rx", "12");
|
|
5569
|
-
ar.setAttribute("fill", "rgba(139,148,158,0.04)");
|
|
5570
|
-
ar.setAttribute("stroke", "#3d444d");
|
|
5571
|
-
ar.setAttribute("stroke-width", "1");
|
|
5572
|
-
ar.setAttribute("pointer-events", "none"); // pan/drag passes through
|
|
5573
|
-
ag.appendChild(ar);
|
|
5574
|
-
// Title, then owner on a second line, each truncated to the box.
|
|
5575
|
-
const maxChars = Math.floor((bb.w - 24) / 6.6);
|
|
5576
|
-
const clip = (t) =>
|
|
5577
|
-
t.length > maxChars
|
|
5578
|
-
? t.slice(0, Math.max(1, maxChars - 1)) + "…"
|
|
5579
|
-
: t;
|
|
5580
|
-
const at = document.createElementNS(ns, "text");
|
|
5581
|
-
at.setAttribute("x", bb.x + 12);
|
|
5582
|
-
at.setAttribute("y", bb.y + 15);
|
|
5583
|
-
at.setAttribute("font-size", "11");
|
|
5584
|
-
at.setAttribute("font-family", "var(--mono)");
|
|
5585
|
-
at.setAttribute("font-weight", "600");
|
|
5586
|
-
at.setAttribute("fill", "var(--text)");
|
|
5587
|
-
at.setAttribute("pointer-events", "none");
|
|
5588
|
-
at.textContent = clip(bb.label);
|
|
5589
|
-
if (bb.app.owner) {
|
|
5590
|
-
const ot = document.createElementNS(ns, "text");
|
|
5591
|
-
ot.setAttribute("x", bb.x + 12);
|
|
5592
|
-
ot.setAttribute("y", bb.y + 29);
|
|
5593
|
-
ot.setAttribute("font-size", "10");
|
|
5594
|
-
ot.setAttribute("font-family", "var(--mono)");
|
|
5595
|
-
ot.setAttribute("fill", "var(--muted)");
|
|
5596
|
-
ot.setAttribute("pointer-events", "none");
|
|
5597
|
-
ot.textContent = clip(bb.app.owner);
|
|
5598
|
-
ag.appendChild(ot);
|
|
5599
|
-
}
|
|
5600
|
-
const tip = document.createElementNS(ns, "title");
|
|
5601
|
-
tip.textContent = [bb.label, bb.app.owner, bb.app.description]
|
|
5602
|
-
.filter(Boolean)
|
|
5603
|
-
.join(" · ");
|
|
5604
|
-
ag.appendChild(tip);
|
|
5605
|
-
ag.appendChild(at);
|
|
5606
|
-
viewport.appendChild(ag);
|
|
5607
|
-
continue;
|
|
5608
|
-
}
|
|
5609
|
-
if (blockBoxes.length < 2 || bb.solo) continue;
|
|
5610
|
-
const r = document.createElementNS(ns, "rect");
|
|
5611
|
-
r.setAttribute("x", bb.x);
|
|
5612
|
-
r.setAttribute("y", bb.y);
|
|
5613
|
-
r.setAttribute("width", bb.w);
|
|
5614
|
-
r.setAttribute("height", bb.h);
|
|
5615
|
-
r.setAttribute("rx", "10");
|
|
5616
|
-
r.setAttribute("fill", "none");
|
|
5617
|
-
r.setAttribute("stroke", "#30363d");
|
|
5618
|
-
r.setAttribute("stroke-width", "1");
|
|
5619
|
-
r.setAttribute("stroke-dasharray", "5 4");
|
|
5620
|
-
r.setAttribute("pointer-events", "none");
|
|
5621
|
-
viewport.appendChild(r);
|
|
5622
|
-
const t = document.createElementNS(ns, "text");
|
|
5623
|
-
t.setAttribute("x", bb.x + 10);
|
|
5624
|
-
t.setAttribute("y", bb.y + 14);
|
|
5625
|
-
t.setAttribute("fill", "#8b949e");
|
|
5626
|
-
t.setAttribute("font-size", "11");
|
|
5627
|
-
t.setAttribute("font-family", "var(--mono)");
|
|
5628
|
-
t.setAttribute("pointer-events", "none");
|
|
5629
|
-
t.textContent =
|
|
5630
|
-
bb.label.length > 60 ? bb.label.slice(0, 57) + "…" : bb.label;
|
|
5631
|
-
viewport.appendChild(t);
|
|
5632
|
-
}
|
|
5633
|
-
|
|
5634
|
-
// ── Group container backgrounds (expanded repos) ──────────────────────
|
|
5635
|
-
// Drawn first so edges and nodes render on top. One bounding box around
|
|
5636
|
-
// all of an expanded repo's subsystem nodes (orientation-agnostic).
|
|
5637
|
-
const repoBox = new Map(); // repo → {x,y,w,h} (used by edges to attach to the box)
|
|
5638
|
-
for (const [repo, svcs] of repoGroups) {
|
|
5639
|
-
if (svcs.length <= 1 || !expandedRepos.has(repo)) continue;
|
|
5640
|
-
const color = repoColor(repo);
|
|
5641
|
-
const ps = displayNodes
|
|
5642
|
-
.filter((n) => n.repo === repo && n.type === "service")
|
|
5643
|
-
.map((n) => pos.get(n.address))
|
|
5644
|
-
.filter(Boolean);
|
|
5645
|
-
if (!ps.length) continue;
|
|
5646
|
-
const minX = Math.min(...ps.map((p) => p.x));
|
|
5647
|
-
const minY = Math.min(...ps.map((p) => p.y));
|
|
5648
|
-
const maxX = Math.max(...ps.map((p) => p.x));
|
|
5649
|
-
const maxY = Math.max(...ps.map((p) => p.y));
|
|
5650
|
-
const box = {
|
|
5651
|
-
x: minX - CPAD,
|
|
5652
|
-
y: minY - CPAD,
|
|
5653
|
-
w: maxX - minX + NW + 2 * CPAD,
|
|
5654
|
-
h: maxY - minY + NH + 2 * CPAD,
|
|
5655
|
-
};
|
|
5656
|
-
repoBox.set(repo, box);
|
|
5657
|
-
const crect = document.createElementNS(ns, "rect");
|
|
5658
|
-
crect.setAttribute("x", box.x);
|
|
5659
|
-
crect.setAttribute("y", box.y);
|
|
5660
|
-
crect.setAttribute("width", box.w);
|
|
5661
|
-
crect.setAttribute("height", box.h);
|
|
5662
|
-
crect.setAttribute("rx", "8");
|
|
5663
|
-
crect.setAttribute("fill", color.bg);
|
|
5664
|
-
crect.setAttribute("stroke", color.stroke);
|
|
5665
|
-
crect.setAttribute("stroke-width", "1.5");
|
|
5666
|
-
crect.setAttribute("pointer-events", "none");
|
|
5667
|
-
viewport.appendChild(crect);
|
|
5668
|
-
}
|
|
5669
|
-
|
|
5670
|
-
// ── Edges ─────────────────────────────────────────────────────────────
|
|
5671
|
-
// Cross-repo edges attach to the whole repo box (collapsed node, or the
|
|
5672
|
-
// expanded repo's bounding box) so they always meet a visible node;
|
|
5673
|
-
// intra-repo edges attach to the individual subsystem nodes.
|
|
5674
|
-
const nodeByAddr = new Map(displayNodes.map((n) => [n.address, n]));
|
|
5675
|
-
const nodeRect = (addr) => {
|
|
5676
|
-
const p = pos.get(addr);
|
|
5677
|
-
return p ? { x: p.x, y: p.y, w: NW, h: NH } : null;
|
|
5678
|
-
};
|
|
5679
|
-
const endpointRect = (addr) => {
|
|
5680
|
-
const n = nodeByAddr.get(addr);
|
|
5681
|
-
if (n && repoBox.has(n.repo)) return repoBox.get(n.repo);
|
|
5682
|
-
return nodeRect(addr);
|
|
5683
|
-
};
|
|
5684
|
-
// Reset selection collections — elements below are rebuilt this render.
|
|
5685
|
-
_graphEdgeEls = [];
|
|
5686
|
-
_graphNodeEls = [];
|
|
5687
|
-
_selectedNode = null;
|
|
5688
|
-
for (const e of displayEdges) {
|
|
5689
|
-
const sR = e.isIntra ? nodeRect(e.from) : endpointRect(e.from);
|
|
5690
|
-
const dR = e.isIntra ? nodeRect(e.to) : endpointRect(e.to);
|
|
5691
|
-
if (!sR || !dR) continue;
|
|
5692
|
-
// Top-down: exit the bottom of the source, enter the top of the target.
|
|
5693
|
-
const x1 = sR.x + sR.w / 2,
|
|
5694
|
-
y1 = sR.y + sR.h;
|
|
5695
|
-
const x2 = dR.x + dR.w / 2,
|
|
5696
|
-
y2 = dR.y;
|
|
5697
|
-
const my = (y1 + y2) / 2;
|
|
5698
|
-
const path = document.createElementNS(ns, "path");
|
|
5699
|
-
if (e.isMissing) {
|
|
5700
|
-
path.setAttribute("stroke", "var(--yellow)");
|
|
5701
|
-
path.setAttribute("stroke-width", "1.5");
|
|
5702
|
-
path.setAttribute("stroke-dasharray", "4 3");
|
|
5703
|
-
path.setAttribute("marker-end", "url(#arrow-warn)");
|
|
5704
|
-
} else if (e.isIntra) {
|
|
5705
|
-
path.setAttribute("stroke", "#6e7681");
|
|
5706
|
-
path.setAttribute("stroke-width", "1");
|
|
5707
|
-
path.setAttribute("stroke-dasharray", "3 3");
|
|
5708
|
-
path.setAttribute("marker-end", "url(#arrow-intra)");
|
|
5709
|
-
} else if (e.kind === "event") {
|
|
5710
|
-
// event flow — dotted, distinct color; never gates startup.
|
|
5711
|
-
path.setAttribute("stroke", "#d2a8ff");
|
|
5712
|
-
path.setAttribute("stroke-width", "1.6");
|
|
5713
|
-
path.setAttribute("stroke-dasharray", "2 4");
|
|
5714
|
-
path.setAttribute("marker-end", "url(#arrow-event)");
|
|
5715
|
-
} else if (e.blocking === "soft") {
|
|
5716
|
-
// soft dependency — dashed + muted, visibly weaker than a hard edge.
|
|
5717
|
-
path.setAttribute("stroke", "#8b949e");
|
|
5718
|
-
path.setAttribute("stroke-width", "1.4");
|
|
5719
|
-
path.setAttribute("stroke-dasharray", "6 4");
|
|
5720
|
-
path.setAttribute("marker-end", "url(#arrow-soft)");
|
|
5721
|
-
} else {
|
|
5722
|
-
// hard dependency — solid + bold; gates startup order.
|
|
5723
|
-
path.setAttribute("stroke", "#4d9ee8");
|
|
5724
|
-
path.setAttribute("stroke-width", "2.4");
|
|
5725
|
-
path.setAttribute("marker-end", "url(#arrow)");
|
|
5726
|
-
}
|
|
5727
|
-
path.setAttribute("fill", "none");
|
|
5728
|
-
path.setAttribute(
|
|
5729
|
-
"d",
|
|
5730
|
-
"M " +
|
|
5731
|
-
x1 +
|
|
5732
|
-
" " +
|
|
5733
|
-
y1 +
|
|
5734
|
-
" C " +
|
|
5735
|
-
x1 +
|
|
5736
|
-
" " +
|
|
5737
|
-
my +
|
|
5738
|
-
" " +
|
|
5739
|
-
x2 +
|
|
5740
|
-
" " +
|
|
5741
|
-
my +
|
|
5742
|
-
" " +
|
|
5743
|
-
x2 +
|
|
5744
|
-
" " +
|
|
5745
|
-
y2,
|
|
5746
|
-
);
|
|
5747
|
-
_graphEdgeEls.push({ el: path, from: e.from, to: e.to });
|
|
5748
|
-
viewport.appendChild(path);
|
|
5749
|
-
}
|
|
5750
|
-
|
|
5751
|
-
// ── Service / group nodes ──────────────────────────────────────────────
|
|
5752
|
-
for (const n of displayNodes) {
|
|
5753
|
-
const p = pos.get(n.address);
|
|
5754
|
-
if (!p) continue;
|
|
5755
|
-
const { color } = n;
|
|
5756
|
-
|
|
5757
|
-
const g = document.createElementNS(ns, "g");
|
|
5758
|
-
g.setAttribute("class", "gnode");
|
|
5759
|
-
g.setAttribute("data-address", n.address);
|
|
5760
|
-
g.setAttribute("data-repo", n.repo);
|
|
5761
|
-
g.setAttribute("data-service", n.service);
|
|
5762
|
-
// health-keys: comma-separated "repo/service" keys used by applyHealthToGraph.
|
|
5763
|
-
// A collapsed group's dot reflects its main component (entrypoint), not
|
|
5764
|
-
// an all-subsystem aggregate.
|
|
5765
|
-
const healthKeys =
|
|
5766
|
-
n.type === "group"
|
|
5767
|
-
? groupDotKeys(n.services).join(",")
|
|
5768
|
-
: `${n.repo}/${n.service}`;
|
|
5769
|
-
if (healthKeys) g.setAttribute("data-health-keys", healthKeys);
|
|
5770
|
-
|
|
5771
|
-
// Nodes inside an expanded group get a more saturated tinted rect so
|
|
5772
|
-
// they visually read as "children" of the container behind them.
|
|
5773
|
-
const isGrouped =
|
|
5774
|
-
n.type === "service" && (repoGroups.get(n.repo)?.length ?? 0) > 1;
|
|
5775
|
-
const rect = document.createElementNS(ns, "rect");
|
|
5776
|
-
rect.setAttribute("x", p.x);
|
|
5777
|
-
rect.setAttribute("y", p.y);
|
|
5778
|
-
rect.setAttribute("width", NW);
|
|
5779
|
-
rect.setAttribute("height", NH);
|
|
5780
|
-
rect.setAttribute("rx", "6");
|
|
5781
|
-
// Collapsed group node matches the container (color.bg + color.stroke, stroke-width 1.5).
|
|
5782
|
-
// Expanded grouped nodes are more saturated: accent-tinted fill + full accent border.
|
|
5783
|
-
// Standalone service nodes use the base palette.
|
|
5784
|
-
const isCollapsedGroup = n.type === "group";
|
|
5785
|
-
rect.setAttribute(
|
|
5786
|
-
"fill",
|
|
5787
|
-
isGrouped ? color.accent + "2e" : color.bg,
|
|
5788
|
-
);
|
|
5789
|
-
rect.setAttribute(
|
|
5790
|
-
"stroke",
|
|
5791
|
-
isGrouped ? color.accent : color.stroke,
|
|
5792
|
-
);
|
|
5793
|
-
rect.setAttribute(
|
|
5794
|
-
"stroke-width",
|
|
5795
|
-
isGrouped || isCollapsedGroup ? "1.5" : "1",
|
|
5796
|
-
);
|
|
5797
|
-
// Card rect first — decorations (⏸ pill etc.) paint on top of it.
|
|
5798
|
-
g.appendChild(rect);
|
|
5799
|
-
_graphNodeEls.push({ g, rect, address: n.address });
|
|
5800
|
-
|
|
5801
|
-
// Auto-start off: dashed border + a compact ⏸ glyph beside the health
|
|
5802
|
-
// dot (a full pill didn't fit on the card and overlaid other info) +
|
|
5803
|
-
// tooltip so it's clear the master Up/Restart won't start it (Down
|
|
5804
|
-
// still stops it).
|
|
5805
|
-
if (_noAutostartRepos.has(n.repo)) {
|
|
5806
|
-
rect.setAttribute("stroke-dasharray", "4 3");
|
|
5807
|
-
g.setAttribute("data-autostart-off", "1");
|
|
5808
|
-
const title = document.createElementNS(ns, "title");
|
|
5809
|
-
title.textContent = `${n.repo} — auto-start off: start / don't start with workspace (toggle in ⋮)`;
|
|
5810
|
-
g.appendChild(title);
|
|
5811
|
-
const offGlyph = document.createElementNS(ns, "text");
|
|
5812
|
-
offGlyph.setAttribute("x", p.x + NW - 26);
|
|
5813
|
-
offGlyph.setAttribute("y", p.y + 18);
|
|
5814
|
-
offGlyph.setAttribute("font-size", "10");
|
|
5815
|
-
offGlyph.setAttribute("fill", "var(--yellow)");
|
|
5816
|
-
offGlyph.setAttribute("text-anchor", "end");
|
|
5817
|
-
offGlyph.setAttribute("pointer-events", "none");
|
|
5818
|
-
offGlyph.textContent = "⏸";
|
|
5819
|
-
g.appendChild(offGlyph);
|
|
5820
|
-
}
|
|
5821
|
-
|
|
5822
|
-
// Not cloned yet: dim the card and mark it with ⬇ so it reads as
|
|
5823
|
-
// "configured but not on disk" rather than as a healthy system.
|
|
5824
|
-
// Deliberately NOT dashed — dashes already mean auto-start off.
|
|
5825
|
-
if (n.cloned === false) {
|
|
5826
|
-
g.setAttribute("data-uncloned", "1");
|
|
5827
|
-
rect.setAttribute("opacity", "0.55");
|
|
5828
|
-
const title = document.createElementNS(ns, "title");
|
|
5829
|
-
title.textContent = `${n.repo} — not cloned yet: run Sync (or ⋮ → clone now)`;
|
|
5830
|
-
g.appendChild(title);
|
|
5831
|
-
const dlGlyph = document.createElementNS(ns, "text");
|
|
5832
|
-
dlGlyph.setAttribute("x", p.x + NW - 26);
|
|
5833
|
-
dlGlyph.setAttribute("y", p.y + 18);
|
|
5834
|
-
dlGlyph.setAttribute("font-size", "10");
|
|
5835
|
-
dlGlyph.setAttribute("fill", "var(--yellow)");
|
|
5836
|
-
dlGlyph.setAttribute("text-anchor", "end");
|
|
5837
|
-
dlGlyph.setAttribute("pointer-events", "none");
|
|
5838
|
-
dlGlyph.textContent = "⬇";
|
|
5839
|
-
g.appendChild(dlGlyph);
|
|
5840
|
-
}
|
|
5841
|
-
|
|
5842
|
-
if (n.type === "group") {
|
|
5843
|
-
// ── Collapsed group node ─────────────────────────────────────────
|
|
5844
|
-
const isDevGroup = n.mode === "dev-managed" && n.repoPath;
|
|
5845
|
-
const groupLabelX = isDevGroup ? p.x + 47 : p.x + 10;
|
|
5846
|
-
const groupMaxChars = isDevGroup ? 10 : 15;
|
|
5847
|
-
const label = document.createElementNS(ns, "text");
|
|
5848
|
-
label.setAttribute("x", groupLabelX);
|
|
5849
|
-
label.setAttribute("y", p.y + 19);
|
|
5850
|
-
label.setAttribute("font-size", "11");
|
|
5851
|
-
label.setAttribute("fill", color.accent);
|
|
5852
|
-
label.setAttribute("font-family", "var(--mono)");
|
|
5853
|
-
label.setAttribute("font-weight", "600");
|
|
5854
|
-
label.setAttribute("pointer-events", "none");
|
|
5855
|
-
label.textContent =
|
|
5856
|
-
n.repo.length > groupMaxChars
|
|
5857
|
-
? n.repo.slice(0, groupMaxChars - 1) + "…"
|
|
5858
|
-
: n.repo;
|
|
5859
|
-
g.appendChild(label);
|
|
5860
|
-
|
|
5861
|
-
// Mode pill (__DEN_CLI__/dev) — same element leaf cards render, so
|
|
5862
|
-
// multi-service systems show their management mode too.
|
|
5863
|
-
const gIsDen = n.mode === "managed";
|
|
5864
|
-
const gPillColor = gIsDen ? "#a371f7" : color.accent;
|
|
5865
|
-
const gPillW = gIsDen ? 44 : 30;
|
|
5866
|
-
const gPillX = p.x + 10,
|
|
5867
|
-
gPillY = p.y + 30;
|
|
5868
|
-
const gPillRect = document.createElementNS(ns, "rect");
|
|
5869
|
-
gPillRect.setAttribute("x", gPillX);
|
|
5870
|
-
gPillRect.setAttribute("y", gPillY);
|
|
5871
|
-
gPillRect.setAttribute("width", gPillW);
|
|
5872
|
-
gPillRect.setAttribute("height", 14);
|
|
5873
|
-
gPillRect.setAttribute("rx", "7");
|
|
5874
|
-
gPillRect.setAttribute(
|
|
5875
|
-
"fill",
|
|
5876
|
-
gIsDen ? "#a371f720" : color.bg + "aa",
|
|
5877
|
-
);
|
|
5878
|
-
gPillRect.setAttribute("stroke", gPillColor);
|
|
5879
|
-
gPillRect.setAttribute("stroke-width", "1");
|
|
5880
|
-
gPillRect.setAttribute("pointer-events", "none");
|
|
5881
|
-
g.appendChild(gPillRect);
|
|
5882
|
-
const gPillText = document.createElementNS(ns, "text");
|
|
5883
|
-
gPillText.setAttribute("x", gPillX + gPillW / 2);
|
|
5884
|
-
gPillText.setAttribute("y", gPillY + 10);
|
|
5885
|
-
gPillText.setAttribute("font-size", "9");
|
|
5886
|
-
gPillText.setAttribute("fill", gPillColor);
|
|
5887
|
-
gPillText.setAttribute("font-family", "var(--mono)");
|
|
5888
|
-
gPillText.setAttribute("text-anchor", "middle");
|
|
5889
|
-
gPillText.setAttribute("pointer-events", "none");
|
|
5890
|
-
gPillText.textContent = gIsDen ? "__DEN_CLI__" : "dev";
|
|
5891
|
-
g.appendChild(gPillText);
|
|
5892
|
-
|
|
5893
|
-
// "▶ N svcs" expand affordance, right of the mode pill. The TEXT is
|
|
5894
|
-
// pointer-events:none by CSS (.gnode text), so a transparent hit RECT
|
|
5895
|
-
// carries the click — the text-glyph hit area was ~5px tall and the
|
|
5896
|
-
// CSS rule silently disabled it entirely (the expand-regression bug).
|
|
5897
|
-
const subX = gPillX + gPillW + 6;
|
|
5898
|
-
const sub = document.createElementNS(ns, "text");
|
|
5899
|
-
sub.setAttribute("x", subX);
|
|
5900
|
-
sub.setAttribute("y", p.y + 40);
|
|
5901
|
-
sub.setAttribute("font-size", "9");
|
|
5902
|
-
sub.setAttribute("fill", color.accent);
|
|
5903
|
-
sub.setAttribute("font-family", "var(--mono)");
|
|
5904
|
-
sub.setAttribute("opacity", "0.6");
|
|
5905
|
-
sub.setAttribute("pointer-events", "none");
|
|
5906
|
-
sub.textContent = "▶ " + n.services.length + " svcs";
|
|
5907
|
-
g.appendChild(sub);
|
|
5908
|
-
const subHit = document.createElementNS(ns, "rect");
|
|
5909
|
-
subHit.setAttribute("x", subX - 3);
|
|
5910
|
-
subHit.setAttribute("y", p.y + 28);
|
|
5911
|
-
subHit.setAttribute("width", 56);
|
|
5912
|
-
subHit.setAttribute("height", 18);
|
|
5913
|
-
subHit.setAttribute("fill", "transparent");
|
|
5914
|
-
subHit.setAttribute("pointer-events", "all");
|
|
5915
|
-
subHit.setAttribute("cursor", "pointer");
|
|
5916
|
-
const subTitle = document.createElementNS(ns, "title");
|
|
5917
|
-
subTitle.textContent =
|
|
5918
|
-
"expand " + n.services.length + " services";
|
|
5919
|
-
subHit.appendChild(subTitle);
|
|
5920
|
-
subHit.addEventListener("click", (e) => {
|
|
5921
|
-
e.stopPropagation();
|
|
5922
|
-
collapsedRepos.delete(n.repo);
|
|
5923
|
-
renderDepGraph(_graphServices, _graphDepsMap);
|
|
5924
|
-
});
|
|
5925
|
-
g.appendChild(subHit);
|
|
5926
|
-
|
|
5927
|
-
// System health dot (top-right): the main component (entrypoint), not
|
|
5928
|
-
// an all-subsystem aggregate — so a down app doesn't read green/yellow
|
|
5929
|
-
// just because its shared-infra sidecars are up.
|
|
5930
|
-
const groupHealthKeys = groupDotKeys(n.services);
|
|
5931
|
-
const groupState = healthState(groupHealthKeys, healthMap);
|
|
5932
|
-
// Health dot — hover swaps to refresh icon, click reloads
|
|
5933
|
-
const grpDotG = document.createElementNS(ns, "g");
|
|
5934
|
-
grpDotG.setAttribute("class", "health-dot-wrap");
|
|
5935
|
-
const groupDot = document.createElementNS(ns, "circle");
|
|
5936
|
-
groupDot.setAttribute("cx", p.x + NW - 12);
|
|
5937
|
-
groupDot.setAttribute("cy", p.y + 14);
|
|
5938
|
-
groupDot.setAttribute("r", 4);
|
|
5939
|
-
groupDot.setAttribute(
|
|
5940
|
-
"class",
|
|
5941
|
-
`health-dot${groupState ? " " + groupState : ""}`,
|
|
5942
|
-
);
|
|
5943
|
-
grpDotG.appendChild(groupDot);
|
|
5944
|
-
const grpRefresh = document.createElementNS(ns, "path");
|
|
5945
|
-
grpRefresh.setAttribute(
|
|
5946
|
-
"d",
|
|
5947
|
-
"M3 1.5A4.5 4.5 0 0 1 7.5 6H6l2.5 2.5L11 6H9A6 6 0 1 0 3 12",
|
|
5948
|
-
);
|
|
5949
|
-
grpRefresh.setAttribute(
|
|
5950
|
-
"transform",
|
|
5951
|
-
`translate(${p.x + NW - 16},${p.y + 10}) scale(0.65)`,
|
|
5952
|
-
);
|
|
5953
|
-
grpRefresh.setAttribute("fill", "none");
|
|
5954
|
-
grpRefresh.setAttribute("stroke", "var(--text)");
|
|
5955
|
-
grpRefresh.setAttribute("stroke-width", "2");
|
|
5956
|
-
grpRefresh.setAttribute("stroke-linecap", "round");
|
|
5957
|
-
grpRefresh.setAttribute("stroke-linejoin", "round");
|
|
5958
|
-
grpRefresh.setAttribute("class", "health-refresh-icon");
|
|
5959
|
-
grpDotG.appendChild(grpRefresh);
|
|
5960
|
-
const grpHit = document.createElementNS(ns, "rect");
|
|
5961
|
-
grpHit.setAttribute("x", p.x + NW - 20);
|
|
5962
|
-
grpHit.setAttribute("y", p.y + 6);
|
|
5963
|
-
grpHit.setAttribute("width", 16);
|
|
5964
|
-
grpHit.setAttribute("height", 16);
|
|
5965
|
-
grpHit.setAttribute("fill", "transparent");
|
|
5966
|
-
grpHit.setAttribute("cursor", "pointer");
|
|
5967
|
-
grpHit.addEventListener("click", (e) => {
|
|
5968
|
-
e.stopPropagation();
|
|
5969
|
-
for (const s of n.services || []) reloadNodeHealth(s.address);
|
|
5970
|
-
});
|
|
5971
|
-
grpDotG.appendChild(grpHit);
|
|
5972
|
-
g.appendChild(grpDotG);
|
|
5973
|
-
|
|
5974
|
-
// ⋮ Actions — system-level lifecycle for the whole repo, run against
|
|
5975
|
-
// its entrypoint (up/restart seed the repo closure; down/setup hit the
|
|
5976
|
-
// app). The ▶ expand affordance lives in the label + whole-card click.
|
|
5977
|
-
const epSvc =
|
|
5978
|
-
n.services.find((s) => s.isEntrypoint) ||
|
|
5979
|
-
n.services.find((s) => s.service === n.repo) ||
|
|
5980
|
-
n.services[0];
|
|
5981
|
-
// up/restart target ONLY the entrypoint's address, so just the main
|
|
5982
|
-
// system's `up` runs (its `make up` orchestrates its own subsystems —
|
|
5983
|
-
// we don't invoke each subsystem's up). down/setup use repo+service.
|
|
5984
|
-
const sysSvc = {
|
|
5985
|
-
address: epSvc ? epSvc.address : n.repo,
|
|
5986
|
-
repo: n.repo,
|
|
5987
|
-
service: epSvc ? epSvc.service : n.repo,
|
|
5988
|
-
commands: epSvc ? epSvc.commands : [],
|
|
5989
|
-
mode: n.mode,
|
|
5990
|
-
repoPath: n.repoPath,
|
|
5991
|
-
};
|
|
5992
|
-
const grpAct = document.createElementNS(ns, "rect");
|
|
5993
|
-
grpAct.setAttribute("x", p.x + NW - 21);
|
|
5994
|
-
grpAct.setAttribute("y", p.y + 28);
|
|
5995
|
-
grpAct.setAttribute("width", 15);
|
|
5996
|
-
grpAct.setAttribute("height", 16);
|
|
5997
|
-
grpAct.setAttribute("rx", "3");
|
|
5998
|
-
grpAct.setAttribute("fill", "transparent");
|
|
5999
|
-
grpAct.setAttribute("pointer-events", "all");
|
|
6000
|
-
grpAct.setAttribute("cursor", "pointer");
|
|
6001
|
-
for (let i = 0; i < 3; i++) {
|
|
6002
|
-
const dc = document.createElementNS(ns, "circle");
|
|
6003
|
-
dc.setAttribute("cx", p.x + NW - 14);
|
|
6004
|
-
dc.setAttribute("cy", p.y + 32 + i * 5);
|
|
6005
|
-
dc.setAttribute("r", "1.5");
|
|
6006
|
-
dc.setAttribute("fill", "var(--muted)");
|
|
6007
|
-
dc.setAttribute("pointer-events", "none");
|
|
6008
|
-
g.appendChild(dc);
|
|
6009
|
-
}
|
|
6010
|
-
grpAct.addEventListener("mouseenter", () =>
|
|
6011
|
-
grpAct.setAttribute("fill", "#8b949e30"),
|
|
6012
|
-
);
|
|
6013
|
-
grpAct.addEventListener("mouseleave", () =>
|
|
6014
|
-
grpAct.setAttribute("fill", "transparent"),
|
|
6015
|
-
);
|
|
6016
|
-
grpAct.addEventListener("click", (e) => {
|
|
6017
|
-
e.stopPropagation();
|
|
6018
|
-
showActionsPopup(sysSvc, grpAct);
|
|
6019
|
-
});
|
|
6020
|
-
g.appendChild(grpAct);
|
|
6021
|
-
|
|
6022
|
-
g.style.cursor = "pointer";
|
|
6023
|
-
g.addEventListener("mouseenter", () =>
|
|
6024
|
-
rect.setAttribute("stroke", color.accent),
|
|
6025
|
-
);
|
|
6026
|
-
g.addEventListener("mouseleave", () =>
|
|
6027
|
-
rect.setAttribute("stroke", color.stroke),
|
|
6028
|
-
);
|
|
6029
|
-
// Selecting opens this system's logs below; deselecting leaves them
|
|
6030
|
-
// up, so a second click only drops the highlight. sysSvc.address is
|
|
6031
|
-
// the entrypoint — n.address is the bare repo on a group card.
|
|
6032
|
-
g.addEventListener("click", (e) => {
|
|
6033
|
-
e.stopPropagation();
|
|
6034
|
-
const wasSelected = _selectedNode === n.address;
|
|
6035
|
-
selectGraphNode(n.address);
|
|
6036
|
-
if (!wasSelected)
|
|
6037
|
-
openSystemLogs(sysSvc.address, sysSvc.repo, sysSvc.service);
|
|
6038
|
-
});
|
|
6039
|
-
} else {
|
|
6040
|
-
// ── Individual service node ──────────────────────────────────────
|
|
6041
|
-
// Every service gets its own dot: each one declares its own check
|
|
6042
|
-
// in __DEN_MANIFEST_YML__ (web over HTTP, db/jobs by container state), so a
|
|
6043
|
-
// red db shows on the db, not hidden behind a green web.
|
|
6044
|
-
{
|
|
6045
|
-
// Health dot — hover swaps to refresh icon, click reloads
|
|
6046
|
-
const dotG = document.createElementNS(ns, "g");
|
|
6047
|
-
dotG.setAttribute("class", "health-dot-wrap");
|
|
6048
|
-
const dot = document.createElementNS(ns, "circle");
|
|
6049
|
-
dot.setAttribute("cx", p.x + NW - 12);
|
|
6050
|
-
dot.setAttribute("cy", p.y + 14);
|
|
6051
|
-
dot.setAttribute("r", 4);
|
|
6052
|
-
dot.setAttribute("fill", "var(--muted)");
|
|
6053
|
-
dot.setAttribute("class", "health-dot");
|
|
6054
|
-
dotG.appendChild(dot);
|
|
6055
|
-
const refreshIcon = document.createElementNS(ns, "path");
|
|
6056
|
-
refreshIcon.setAttribute(
|
|
6057
|
-
"d",
|
|
6058
|
-
"M3 1.5A4.5 4.5 0 0 1 7.5 6H6l2.5 2.5L11 6H9A6 6 0 1 0 3 12",
|
|
6059
|
-
);
|
|
6060
|
-
refreshIcon.setAttribute(
|
|
6061
|
-
"transform",
|
|
6062
|
-
`translate(${p.x + NW - 16},${p.y + 10}) scale(0.65)`,
|
|
6063
|
-
);
|
|
6064
|
-
refreshIcon.setAttribute("fill", "none");
|
|
6065
|
-
refreshIcon.setAttribute("stroke", "var(--text)");
|
|
6066
|
-
refreshIcon.setAttribute("stroke-width", "2");
|
|
6067
|
-
refreshIcon.setAttribute("stroke-linecap", "round");
|
|
6068
|
-
refreshIcon.setAttribute("stroke-linejoin", "round");
|
|
6069
|
-
refreshIcon.setAttribute("class", "health-refresh-icon");
|
|
6070
|
-
dotG.appendChild(refreshIcon);
|
|
6071
|
-
const dotHit = document.createElementNS(ns, "rect");
|
|
6072
|
-
dotHit.setAttribute("x", p.x + NW - 20);
|
|
6073
|
-
dotHit.setAttribute("y", p.y + 6);
|
|
6074
|
-
dotHit.setAttribute("width", 16);
|
|
6075
|
-
dotHit.setAttribute("height", 16);
|
|
6076
|
-
dotHit.setAttribute("fill", "transparent");
|
|
6077
|
-
dotHit.setAttribute("cursor", "pointer");
|
|
6078
|
-
dotHit.addEventListener("click", (e) => {
|
|
6079
|
-
e.stopPropagation();
|
|
6080
|
-
reloadNodeHealth(n.address);
|
|
6081
|
-
});
|
|
6082
|
-
dotG.appendChild(dotHit);
|
|
6083
|
-
g.appendChild(dotG);
|
|
6084
|
-
}
|
|
6085
|
-
|
|
6086
|
-
// Address label (shifted right for dev-managed to make room for ↗ and >_ icon overlays)
|
|
6087
|
-
const isDev = n.mode === "dev-managed" && n.repoPath;
|
|
6088
|
-
const labelX = isDev ? p.x + 47 : p.x + 10;
|
|
6089
|
-
const maxChars = isDev ? 11 : 16;
|
|
6090
|
-
const label = document.createElementNS(ns, "text");
|
|
6091
|
-
label.setAttribute("x", labelX);
|
|
6092
|
-
label.setAttribute("y", p.y + 20);
|
|
6093
|
-
label.setAttribute("font-size", "11");
|
|
6094
|
-
label.setAttribute("fill", color.accent);
|
|
6095
|
-
label.setAttribute("font-family", "var(--mono)");
|
|
6096
|
-
label.setAttribute("pointer-events", "none");
|
|
6097
|
-
// Inside a multi-service repo's box the box names the repo, so the
|
|
6098
|
-
// node shows its service alone (web, db), not repo/service.
|
|
6099
|
-
const shown =
|
|
6100
|
-
(repoGroups.get(n.repo)?.length ?? 1) > 1
|
|
6101
|
-
? n.service
|
|
6102
|
-
: n.address;
|
|
6103
|
-
label.textContent =
|
|
6104
|
-
shown.length > maxChars
|
|
6105
|
-
? shown.slice(0, maxChars - 1) + "…"
|
|
6106
|
-
: shown;
|
|
6107
|
-
const full = document.createElementNS(ns, "title");
|
|
6108
|
-
full.textContent = n.address;
|
|
6109
|
-
label.appendChild(full);
|
|
6110
|
-
g.appendChild(label);
|
|
6111
|
-
|
|
6112
|
-
// Mode badge
|
|
6113
|
-
const isDen = n.mode === "managed";
|
|
6114
|
-
const pillColor = isDen ? "#a371f7" : color.accent;
|
|
6115
|
-
const pillLabel = isDen ? "__DEN_CLI__" : "dev";
|
|
6116
|
-
const pillW = isDen ? 44 : 30;
|
|
6117
|
-
const pillX = p.x + 10,
|
|
6118
|
-
pillY = p.y + 30;
|
|
6119
|
-
const pillRect = document.createElementNS(ns, "rect");
|
|
6120
|
-
pillRect.setAttribute("x", pillX);
|
|
6121
|
-
pillRect.setAttribute("y", pillY);
|
|
6122
|
-
pillRect.setAttribute("width", pillW);
|
|
6123
|
-
pillRect.setAttribute("height", 14);
|
|
6124
|
-
pillRect.setAttribute("rx", "7");
|
|
6125
|
-
pillRect.setAttribute(
|
|
6126
|
-
"fill",
|
|
6127
|
-
isDen ? "#a371f720" : color.bg + "aa",
|
|
6128
|
-
);
|
|
6129
|
-
pillRect.setAttribute("stroke", pillColor);
|
|
6130
|
-
pillRect.setAttribute("stroke-width", "1");
|
|
6131
|
-
pillRect.setAttribute("pointer-events", "none");
|
|
6132
|
-
g.appendChild(pillRect);
|
|
6133
|
-
const pillText = document.createElementNS(ns, "text");
|
|
6134
|
-
pillText.setAttribute("x", pillX + pillW / 2);
|
|
6135
|
-
pillText.setAttribute("y", pillY + 10);
|
|
6136
|
-
pillText.setAttribute("font-size", "9");
|
|
6137
|
-
pillText.setAttribute("fill", pillColor);
|
|
6138
|
-
pillText.setAttribute("font-family", "var(--mono)");
|
|
6139
|
-
pillText.setAttribute("text-anchor", "middle");
|
|
6140
|
-
pillText.setAttribute("pointer-events", "none");
|
|
6141
|
-
pillText.textContent = pillLabel;
|
|
6142
|
-
g.appendChild(pillText);
|
|
6143
|
-
|
|
6144
|
-
// ⋮ Actions button (logs + commands). Standalone repo cards get it;
|
|
6145
|
-
// grouped subsystem cards do NOT — the collapsed repo card owns the
|
|
6146
|
-
// system-level menu, so subsystems stay action-free.
|
|
6147
|
-
if (!isGrouped) {
|
|
6148
|
-
const actBg = document.createElementNS(ns, "rect");
|
|
6149
|
-
actBg.setAttribute("x", p.x + NW - 21);
|
|
6150
|
-
actBg.setAttribute("y", p.y + 28);
|
|
6151
|
-
actBg.setAttribute("width", 15);
|
|
6152
|
-
actBg.setAttribute("height", 16);
|
|
6153
|
-
actBg.setAttribute("rx", "3");
|
|
6154
|
-
actBg.setAttribute("fill", "transparent");
|
|
6155
|
-
actBg.setAttribute("pointer-events", "all");
|
|
6156
|
-
actBg.setAttribute("cursor", "pointer");
|
|
6157
|
-
for (let i = 0; i < 3; i++) {
|
|
6158
|
-
const dc = document.createElementNS(ns, "circle");
|
|
6159
|
-
dc.setAttribute("cx", p.x + NW - 14);
|
|
6160
|
-
dc.setAttribute("cy", p.y + 32 + i * 5);
|
|
6161
|
-
dc.setAttribute("r", "1.5");
|
|
6162
|
-
dc.setAttribute("fill", "var(--muted)");
|
|
6163
|
-
dc.setAttribute("pointer-events", "none");
|
|
6164
|
-
g.appendChild(dc);
|
|
6165
|
-
}
|
|
6166
|
-
actBg.addEventListener("mouseenter", () =>
|
|
6167
|
-
actBg.setAttribute("fill", "#8b949e30"),
|
|
6168
|
-
);
|
|
6169
|
-
actBg.addEventListener("mouseleave", () =>
|
|
6170
|
-
actBg.setAttribute("fill", "transparent"),
|
|
6171
|
-
);
|
|
6172
|
-
actBg.addEventListener("click", (e) => {
|
|
6173
|
-
e.stopPropagation();
|
|
6174
|
-
showActionsPopup(n, actBg);
|
|
6175
|
-
});
|
|
6176
|
-
g.appendChild(actBg);
|
|
6177
|
-
}
|
|
6178
|
-
|
|
6179
|
-
g.style.cursor = "pointer";
|
|
6180
|
-
g.addEventListener("mouseenter", () => {
|
|
6181
|
-
rect.setAttribute("stroke", color.accent);
|
|
6182
|
-
if (isGrouped) rect.setAttribute("fill", color.accent + "50");
|
|
6183
|
-
});
|
|
6184
|
-
g.addEventListener("mouseleave", () => {
|
|
6185
|
-
rect.setAttribute(
|
|
6186
|
-
"stroke",
|
|
6187
|
-
isGrouped ? color.accent : color.stroke,
|
|
6188
|
-
);
|
|
6189
|
-
if (isGrouped) rect.setAttribute("fill", color.accent + "2e");
|
|
6190
|
-
});
|
|
6191
|
-
|
|
6192
|
-
// Click selects the card (highlight border + its edges) and opens its
|
|
6193
|
-
// logs in the output panel below. Deselecting leaves the logs up.
|
|
6194
|
-
g.addEventListener("click", (e) => {
|
|
6195
|
-
e.stopPropagation();
|
|
6196
|
-
const wasSelected = _selectedNode === n.address;
|
|
6197
|
-
selectGraphNode(n.address);
|
|
6198
|
-
if (!wasSelected) openSystemLogs(n.address, n.repo, n.service);
|
|
6199
|
-
});
|
|
6200
|
-
}
|
|
6201
|
-
|
|
6202
|
-
viewport.appendChild(g);
|
|
6203
|
-
}
|
|
6204
|
-
|
|
6205
|
-
// ── Collapse strips for expanded groups ────────────────────────────────
|
|
6206
|
-
// Rendered along the top of each expanded repo's bounding box; click collapses.
|
|
6207
|
-
for (const [repo, svcs] of repoGroups) {
|
|
6208
|
-
if (svcs.length <= 1 || !expandedRepos.has(repo)) continue;
|
|
6209
|
-
const color = repoColor(repo);
|
|
6210
|
-
const box = repoBox.get(repo);
|
|
6211
|
-
if (!box) continue;
|
|
6212
|
-
|
|
6213
|
-
const stripX = box.x;
|
|
6214
|
-
const stripW = Math.min(box.w, NW + 2 * CPAD); // cap label strip width
|
|
6215
|
-
const sy = box.y - STRIP_H - STRIP_GAP;
|
|
6216
|
-
const sg = document.createElementNS(ns, "g");
|
|
6217
|
-
sg.style.cursor = "pointer";
|
|
6218
|
-
|
|
6219
|
-
// Transparent fill so it acts as a hit area without covering the container border.
|
|
6220
|
-
const srect = document.createElementNS(ns, "rect");
|
|
6221
|
-
srect.setAttribute("x", stripX);
|
|
6222
|
-
srect.setAttribute("y", sy);
|
|
6223
|
-
srect.setAttribute("width", stripW);
|
|
6224
|
-
srect.setAttribute("height", STRIP_H);
|
|
6225
|
-
srect.setAttribute("rx", "4");
|
|
6226
|
-
srect.setAttribute("fill", "transparent");
|
|
6227
|
-
srect.setAttribute("stroke", "none");
|
|
6228
|
-
sg.appendChild(srect);
|
|
6229
|
-
|
|
6230
|
-
const stext = document.createElementNS(ns, "text");
|
|
6231
|
-
stext.setAttribute("x", stripX + 8);
|
|
6232
|
-
stext.setAttribute("y", sy + STRIP_H - 4);
|
|
6233
|
-
stext.setAttribute("font-size", "10");
|
|
6234
|
-
stext.setAttribute("fill", color.accent);
|
|
6235
|
-
stext.setAttribute("font-family", "var(--mono)");
|
|
6236
|
-
stext.setAttribute("font-weight", "600");
|
|
6237
|
-
stext.setAttribute("pointer-events", "none");
|
|
6238
|
-
stext.textContent =
|
|
6239
|
-
"▾ " + (repo.length > 18 ? repo.slice(0, 17) + "…" : repo);
|
|
6240
|
-
sg.appendChild(stext);
|
|
6241
|
-
|
|
6242
|
-
sg.addEventListener("mouseenter", () =>
|
|
6243
|
-
stext.setAttribute("opacity", "0.75"),
|
|
6244
|
-
);
|
|
6245
|
-
sg.addEventListener("mouseleave", () =>
|
|
6246
|
-
stext.setAttribute("opacity", "1"),
|
|
6247
|
-
);
|
|
6248
|
-
sg.addEventListener("click", () => {
|
|
6249
|
-
collapsedRepos.add(repo);
|
|
6250
|
-
renderDepGraph(_graphServices, _graphDepsMap);
|
|
6251
|
-
});
|
|
6252
|
-
|
|
6253
|
-
viewport.appendChild(sg);
|
|
6254
|
-
}
|
|
6255
|
-
|
|
6256
|
-
// ── Ghost nodes (missing deps) ─────────────────────────────────────────
|
|
6257
|
-
for (const dep of ghostAddrs) {
|
|
6258
|
-
const p = pos.get(dep);
|
|
6259
|
-
if (!p) continue;
|
|
6260
|
-
const g = document.createElementNS(ns, "g");
|
|
6261
|
-
const rect = document.createElementNS(ns, "rect");
|
|
6262
|
-
rect.setAttribute("x", p.x);
|
|
6263
|
-
rect.setAttribute("y", p.y);
|
|
6264
|
-
rect.setAttribute("width", NW);
|
|
6265
|
-
rect.setAttribute("height", NH);
|
|
6266
|
-
rect.setAttribute("rx", "6");
|
|
6267
|
-
rect.setAttribute("fill", "#1a1200");
|
|
6268
|
-
rect.setAttribute("stroke", "var(--yellow)");
|
|
6269
|
-
rect.setAttribute("stroke-width", "1.5");
|
|
6270
|
-
rect.setAttribute("stroke-dasharray", "5 3");
|
|
6271
|
-
g.appendChild(rect);
|
|
6272
|
-
const warn = document.createElementNS(ns, "text");
|
|
6273
|
-
warn.setAttribute("x", p.x + NW - 14);
|
|
6274
|
-
warn.setAttribute("y", p.y + 16);
|
|
6275
|
-
warn.setAttribute("font-size", "12");
|
|
6276
|
-
warn.setAttribute("fill", "var(--yellow)");
|
|
6277
|
-
warn.setAttribute("text-anchor", "middle");
|
|
6278
|
-
warn.textContent = "⚠";
|
|
6279
|
-
g.appendChild(warn);
|
|
6280
|
-
const label = document.createElementNS(ns, "text");
|
|
6281
|
-
label.setAttribute("x", p.x + 10);
|
|
6282
|
-
label.setAttribute("y", p.y + 20);
|
|
6283
|
-
label.setAttribute("font-size", "11");
|
|
6284
|
-
label.setAttribute("fill", "#d29922");
|
|
6285
|
-
label.setAttribute("font-family", "var(--mono)");
|
|
6286
|
-
label.textContent = dep.length > 16 ? dep.slice(0, 15) + "…" : dep;
|
|
6287
|
-
g.appendChild(label);
|
|
6288
|
-
const pr = document.createElementNS(ns, "rect");
|
|
6289
|
-
pr.setAttribute("x", p.x + 10);
|
|
6290
|
-
pr.setAttribute("y", p.y + 30);
|
|
6291
|
-
pr.setAttribute("width", 42);
|
|
6292
|
-
pr.setAttribute("height", 14);
|
|
6293
|
-
pr.setAttribute("rx", "7");
|
|
6294
|
-
pr.setAttribute("fill", "#d2992220");
|
|
6295
|
-
pr.setAttribute("stroke", "var(--yellow)");
|
|
6296
|
-
pr.setAttribute("stroke-width", "1");
|
|
6297
|
-
g.appendChild(pr);
|
|
6298
|
-
const pt = document.createElementNS(ns, "text");
|
|
6299
|
-
pt.setAttribute("x", p.x + 31);
|
|
6300
|
-
pt.setAttribute("y", p.y + 40);
|
|
6301
|
-
pt.setAttribute("font-size", "9");
|
|
6302
|
-
pt.setAttribute("fill", "var(--yellow)");
|
|
6303
|
-
pt.setAttribute("font-family", "var(--mono)");
|
|
6304
|
-
pt.setAttribute("text-anchor", "middle");
|
|
6305
|
-
pt.textContent = "missing";
|
|
6306
|
-
g.appendChild(pt);
|
|
6307
|
-
g.style.cursor = "pointer";
|
|
6308
|
-
g.addEventListener("mouseenter", () => {
|
|
6309
|
-
rect.setAttribute("stroke-width", "2.5");
|
|
6310
|
-
});
|
|
6311
|
-
g.addEventListener("mouseleave", () => {
|
|
6312
|
-
rect.setAttribute("stroke-width", "1.5");
|
|
6313
|
-
});
|
|
6314
|
-
g.addEventListener("click", () => {
|
|
6315
|
-
if (activeWs) {
|
|
6316
|
-
openAddSvcModal(activeWs);
|
|
6317
|
-
// Pre-fill the search with the missing dep name so it's easy to find
|
|
6318
|
-
setTimeout(() => {
|
|
6319
|
-
const input = document.getElementById(
|
|
6320
|
-
"add-svc-global-search",
|
|
6321
|
-
);
|
|
6322
|
-
if (input) {
|
|
6323
|
-
input.value = dep;
|
|
6324
|
-
input.dispatchEvent(new Event("input"));
|
|
6325
|
-
}
|
|
6326
|
-
}, 50);
|
|
6327
|
-
}
|
|
6328
|
-
});
|
|
6329
|
-
viewport.appendChild(g);
|
|
6330
|
-
}
|
|
6331
|
-
|
|
6332
|
-
// ── Open-in-editor + open-in-terminal icon overlays (above all nodes) ──
|
|
6333
|
-
// Rendered last so SVG z-order puts them on top; not inside node g elements
|
|
6334
|
-
// so clicks never bubble to the node's "open logs" handler.
|
|
6335
|
-
const openIconOverlay = document.createElementNS(ns, "g");
|
|
6336
|
-
for (const n of displayNodes) {
|
|
6337
|
-
if (n.type !== "service" && n.type !== "group") continue;
|
|
6338
|
-
if (!(n.mode === "dev-managed" && n.repoPath)) continue;
|
|
6339
|
-
const p = pos.get(n.address);
|
|
6340
|
-
if (!p) continue;
|
|
6341
|
-
const color = repoColor(n.repo);
|
|
6342
|
-
|
|
6343
|
-
// ── Terminal icon (>_) at p.x+5 — left, consistent with sidebar/config order ──
|
|
6344
|
-
const tg = document.createElementNS(ns, "g");
|
|
6345
|
-
tg.setAttribute("cursor", "pointer");
|
|
6346
|
-
const tHitRect = document.createElementNS(ns, "rect");
|
|
6347
|
-
tHitRect.setAttribute("x", p.x + 5);
|
|
6348
|
-
tHitRect.setAttribute("y", p.y + 6);
|
|
6349
|
-
tHitRect.setAttribute("width", 18);
|
|
6350
|
-
tHitRect.setAttribute("height", 18);
|
|
6351
|
-
tHitRect.setAttribute("rx", "3");
|
|
6352
|
-
tHitRect.setAttribute("fill", "transparent");
|
|
6353
|
-
tHitRect.setAttribute("stroke", "none");
|
|
6354
|
-
tg.appendChild(tHitRect);
|
|
6355
|
-
const tx = p.x + 14,
|
|
6356
|
-
ty = p.y + 15; // center of terminal hit rect
|
|
6357
|
-
// Draw >_ as two SVG paths: chevron + underline bar
|
|
6358
|
-
const chevron = document.createElementNS(ns, "path");
|
|
6359
|
-
chevron.setAttribute(
|
|
6360
|
-
"d",
|
|
6361
|
-
`M ${tx - 5} ${ty - 2.5} L ${tx - 1.5} ${ty} L ${tx - 5} ${ty + 2.5}`,
|
|
6362
|
-
);
|
|
6363
|
-
chevron.setAttribute("stroke", "var(--muted)");
|
|
6364
|
-
chevron.setAttribute("stroke-width", "1.5");
|
|
6365
|
-
chevron.setAttribute("stroke-linecap", "round");
|
|
6366
|
-
chevron.setAttribute("stroke-linejoin", "round");
|
|
6367
|
-
chevron.setAttribute("fill", "none");
|
|
6368
|
-
chevron.setAttribute("pointer-events", "none");
|
|
6369
|
-
tg.appendChild(chevron);
|
|
6370
|
-
const underline = document.createElementNS(ns, "line");
|
|
6371
|
-
underline.setAttribute("x1", tx - 0.5);
|
|
6372
|
-
underline.setAttribute("y1", ty + 3.5);
|
|
6373
|
-
underline.setAttribute("x2", tx + 4.5);
|
|
6374
|
-
underline.setAttribute("y2", ty + 3.5);
|
|
6375
|
-
underline.setAttribute("stroke", "var(--muted)");
|
|
6376
|
-
underline.setAttribute("stroke-width", "1.5");
|
|
6377
|
-
underline.setAttribute("stroke-linecap", "round");
|
|
6378
|
-
underline.setAttribute("pointer-events", "none");
|
|
6379
|
-
tg.appendChild(underline);
|
|
6380
|
-
const termAccent = "#3fb950"; // green — consistent with .ws-term-btn
|
|
6381
|
-
tg.addEventListener("mouseenter", () => {
|
|
6382
|
-
chevron.setAttribute("stroke", termAccent);
|
|
6383
|
-
underline.setAttribute("stroke", termAccent);
|
|
6384
|
-
tHitRect.setAttribute("stroke", termAccent + "44");
|
|
6385
|
-
tHitRect.setAttribute("fill", termAccent + "15");
|
|
6386
|
-
});
|
|
6387
|
-
tg.addEventListener("mouseleave", () => {
|
|
6388
|
-
chevron.setAttribute("stroke", "var(--muted)");
|
|
6389
|
-
underline.setAttribute("stroke", "var(--muted)");
|
|
6390
|
-
tHitRect.setAttribute("stroke", "none");
|
|
6391
|
-
tHitRect.setAttribute("fill", "transparent");
|
|
6392
|
-
});
|
|
6393
|
-
tg.addEventListener("click", (e) => {
|
|
6394
|
-
e.stopPropagation();
|
|
6395
|
-
openInTerminal(n.repoPath);
|
|
6396
|
-
});
|
|
6397
|
-
openIconOverlay.appendChild(tg);
|
|
6398
|
-
|
|
6399
|
-
// ── Editor icon (↗) at p.x+25 — right, after terminal ─────────────
|
|
6400
|
-
const ig = document.createElementNS(ns, "g");
|
|
6401
|
-
ig.setAttribute("cursor", "pointer");
|
|
6402
|
-
const hitRect = document.createElementNS(ns, "rect");
|
|
6403
|
-
hitRect.setAttribute("x", p.x + 25);
|
|
6404
|
-
hitRect.setAttribute("y", p.y + 6);
|
|
6405
|
-
hitRect.setAttribute("width", 18);
|
|
6406
|
-
hitRect.setAttribute("height", 18);
|
|
6407
|
-
hitRect.setAttribute("rx", "3");
|
|
6408
|
-
hitRect.setAttribute("fill", "transparent");
|
|
6409
|
-
hitRect.setAttribute("stroke", "none");
|
|
6410
|
-
ig.appendChild(hitRect);
|
|
6411
|
-
// Draw arrow as a path so it's pixel-perfectly centered — text glyphs
|
|
6412
|
-
// have uneven metrics across fonts and can't be reliably centered.
|
|
6413
|
-
const bx = p.x + 34,
|
|
6414
|
-
by = p.y + 15; // center of hitRect
|
|
6415
|
-
const arrow = document.createElementNS(ns, "path");
|
|
6416
|
-
arrow.setAttribute(
|
|
6417
|
-
"d",
|
|
6418
|
-
`M ${bx - 3.5} ${by + 3.5} L ${bx + 3.5} ${by - 3.5} M ${bx + 0} ${by - 3.5} L ${bx + 3.5} ${by - 3.5} L ${bx + 3.5} ${by + 0}`,
|
|
6419
|
-
);
|
|
6420
|
-
arrow.setAttribute("stroke", "var(--muted)");
|
|
6421
|
-
arrow.setAttribute("stroke-width", "1.5");
|
|
6422
|
-
arrow.setAttribute("stroke-linecap", "round");
|
|
6423
|
-
arrow.setAttribute("stroke-linejoin", "round");
|
|
6424
|
-
arrow.setAttribute("fill", "none");
|
|
6425
|
-
arrow.setAttribute("pointer-events", "none");
|
|
6426
|
-
ig.appendChild(arrow);
|
|
6427
|
-
ig.addEventListener("mouseenter", () => {
|
|
6428
|
-
arrow.setAttribute("stroke", color.accent);
|
|
6429
|
-
hitRect.setAttribute("stroke", color.accent + "44");
|
|
6430
|
-
hitRect.setAttribute("fill", color.accent + "15");
|
|
6431
|
-
});
|
|
6432
|
-
ig.addEventListener("mouseleave", () => {
|
|
6433
|
-
arrow.setAttribute("stroke", "var(--muted)");
|
|
6434
|
-
hitRect.setAttribute("stroke", "none");
|
|
6435
|
-
hitRect.setAttribute("fill", "transparent");
|
|
6436
|
-
});
|
|
6437
|
-
ig.addEventListener("click", (e) => {
|
|
6438
|
-
e.stopPropagation();
|
|
6439
|
-
openInEditor(n.repoPath);
|
|
6440
|
-
});
|
|
6441
|
-
openIconOverlay.appendChild(ig);
|
|
6442
|
-
}
|
|
6443
|
-
viewport.appendChild(openIconOverlay);
|
|
6444
|
-
|
|
6445
|
-
// ── Open-in-browser (globe) at the card's lower right ───────────────
|
|
6446
|
-
// One per card, hidden until health results carry an openUrl for the
|
|
6447
|
-
// selected env (applyHealthToGraph fills href). A collapsed repo card
|
|
6448
|
-
// opens its entrypoint. Shift-click copies the URL instead.
|
|
6449
|
-
const browserOverlay = document.createElementNS(ns, "g");
|
|
6450
|
-
for (const n of displayNodes) {
|
|
6451
|
-
if (n.type !== "service" && n.type !== "group") continue;
|
|
6452
|
-
const p = pos.get(n.address);
|
|
6453
|
-
if (!p) continue;
|
|
6454
|
-
const key =
|
|
6455
|
-
n.type === "group"
|
|
6456
|
-
? groupDotKeys(n.services)[0] || ""
|
|
6457
|
-
: `${n.repo}/${n.service}`;
|
|
6458
|
-
if (!key) continue;
|
|
6459
|
-
const color = repoColor(n.repo);
|
|
6460
|
-
const a = document.createElementNS(ns, "a");
|
|
6461
|
-
a.setAttribute("class", "open-browser");
|
|
6462
|
-
a.setAttribute("data-open-key", key);
|
|
6463
|
-
a.setAttribute("target", "_blank");
|
|
6464
|
-
a.setAttribute("rel", "noopener");
|
|
6465
|
-
a.style.display = "none";
|
|
6466
|
-
const hit = document.createElementNS(ns, "rect");
|
|
6467
|
-
hit.setAttribute("x", p.x + NW - 42);
|
|
6468
|
-
hit.setAttribute("y", p.y + 29);
|
|
6469
|
-
hit.setAttribute("width", 16);
|
|
6470
|
-
hit.setAttribute("height", 16);
|
|
6471
|
-
hit.setAttribute("rx", "3");
|
|
6472
|
-
hit.setAttribute("fill", "transparent");
|
|
6473
|
-
a.appendChild(hit);
|
|
6474
|
-
const cx = p.x + NW - 34,
|
|
6475
|
-
cy = p.y + 37;
|
|
6476
|
-
const globe = document.createElementNS(ns, "path");
|
|
6477
|
-
globe.setAttribute(
|
|
6478
|
-
"d",
|
|
6479
|
-
`M ${cx - 5} ${cy} a 5 5 0 1 0 10 0 a 5 5 0 1 0 -10 0 ` +
|
|
6480
|
-
`M ${cx} ${cy - 5} a 2.2 5 0 1 0 0 10 a 2.2 5 0 1 0 0 -10 ` +
|
|
6481
|
-
`M ${cx - 5} ${cy} L ${cx + 5} ${cy}`,
|
|
6482
|
-
);
|
|
6483
|
-
globe.setAttribute("stroke", "var(--muted)");
|
|
6484
|
-
globe.setAttribute("stroke-width", "1.2");
|
|
6485
|
-
globe.setAttribute("fill", "none");
|
|
6486
|
-
globe.setAttribute("pointer-events", "none");
|
|
6487
|
-
a.appendChild(globe);
|
|
6488
|
-
const tip = document.createElementNS(ns, "title");
|
|
6489
|
-
a.appendChild(tip);
|
|
6490
|
-
a.addEventListener("mouseenter", () => {
|
|
6491
|
-
globe.setAttribute("stroke", color.accent);
|
|
6492
|
-
hit.setAttribute("fill", color.accent + "15");
|
|
6493
|
-
});
|
|
6494
|
-
a.addEventListener("mouseleave", () => {
|
|
6495
|
-
globe.setAttribute("stroke", "var(--muted)");
|
|
6496
|
-
hit.setAttribute("fill", "transparent");
|
|
6497
|
-
});
|
|
6498
|
-
a.addEventListener("focus", () => {
|
|
6499
|
-
globe.setAttribute("stroke", color.accent);
|
|
6500
|
-
});
|
|
6501
|
-
a.addEventListener("blur", () => {
|
|
6502
|
-
globe.setAttribute("stroke", "var(--muted)");
|
|
6503
|
-
});
|
|
6504
|
-
a.addEventListener("click", (e) => {
|
|
6505
|
-
e.stopPropagation();
|
|
6506
|
-
if (e.shiftKey) {
|
|
6507
|
-
e.preventDefault();
|
|
6508
|
-
navigator.clipboard
|
|
6509
|
-
?.writeText(a.getAttribute("href") || "")
|
|
6510
|
-
.catch(() => {});
|
|
6511
|
-
}
|
|
6512
|
-
});
|
|
6513
|
-
browserOverlay.appendChild(a);
|
|
6514
|
-
}
|
|
6515
|
-
viewport.appendChild(browserOverlay);
|
|
6516
|
-
|
|
6517
|
-
wrap.innerHTML = "";
|
|
6518
|
-
wrap.appendChild(svg);
|
|
6519
|
-
// Re-apply cached health after SVG is in DOM (handles race where
|
|
6520
|
-
// __DEN_CLI__:health-update fired while SVG was being constructed)
|
|
6521
|
-
applyHealthToGraph(_lastHealthResults);
|
|
6522
|
-
|
|
6523
|
-
// ── Fit-to-view + pan/zoom ──────────────────────────────────────────
|
|
6524
|
-
const cW = wrap.clientWidth || 800;
|
|
6525
|
-
const cH = wrap.clientHeight || 600;
|
|
6526
|
-
const scaleToFit = Math.min(
|
|
6527
|
-
1,
|
|
6528
|
-
(cW - 32) / Math.max(svgW, 1),
|
|
6529
|
-
(cH - 32) / Math.max(svgH, 1),
|
|
6530
|
-
);
|
|
6531
|
-
let vx = Math.max(8, (cW - svgW * scaleToFit) / 2);
|
|
6532
|
-
let vy = Math.max(8, (cH - svgH * scaleToFit) / 2);
|
|
6533
|
-
let vscale = scaleToFit;
|
|
6534
|
-
|
|
6535
|
-
function applyTransform() {
|
|
6536
|
-
viewport.setAttribute(
|
|
6537
|
-
"transform",
|
|
6538
|
-
`translate(${vx},${vy}) scale(${vscale})`,
|
|
6539
|
-
);
|
|
6540
|
-
}
|
|
6541
|
-
applyTransform();
|
|
6542
|
-
|
|
6543
|
-
// Clean up any pan handlers from previous render.
|
|
6544
|
-
if (wrap._panCleanup) wrap._panCleanup();
|
|
6545
|
-
|
|
6546
|
-
let isDragging = false,
|
|
6547
|
-
_didPan = false;
|
|
6548
|
-
let dragStartX = 0,
|
|
6549
|
-
dragStartY = 0,
|
|
6550
|
-
dragVX = 0,
|
|
6551
|
-
dragVY = 0;
|
|
6552
|
-
|
|
6553
|
-
const onWheel = (e) => {
|
|
6554
|
-
e.preventDefault();
|
|
6555
|
-
const rect = svg.getBoundingClientRect();
|
|
6556
|
-
// Gentle, magnitude-aware zoom: scale by the wheel delta so a trackpad's
|
|
6557
|
-
// many tiny events and a mouse's big notches both stay controllable
|
|
6558
|
-
// (the old fixed 1.12/0.9 per event compounded into runaway zoom).
|
|
6559
|
-
const target = Math.max(
|
|
6560
|
-
0.12,
|
|
6561
|
-
Math.min(4, vscale * Math.exp(-e.deltaY * 0.0008)),
|
|
6562
|
-
);
|
|
6563
|
-
const factor = target / vscale; // actual factor after clamping (keeps cursor anchored)
|
|
6564
|
-
const mx = e.clientX - rect.left,
|
|
6565
|
-
my = e.clientY - rect.top;
|
|
6566
|
-
vx = mx + (vx - mx) * factor;
|
|
6567
|
-
vy = my + (vy - my) * factor;
|
|
6568
|
-
vscale = target;
|
|
6569
|
-
applyTransform();
|
|
6570
|
-
};
|
|
6571
|
-
const onMouseDown = (e) => {
|
|
6572
|
-
if (e.button !== 0) return;
|
|
6573
|
-
isDragging = true;
|
|
6574
|
-
_didPan = false;
|
|
6575
|
-
dragStartX = e.clientX;
|
|
6576
|
-
dragStartY = e.clientY;
|
|
6577
|
-
dragVX = vx;
|
|
6578
|
-
dragVY = vy;
|
|
6579
|
-
e.preventDefault();
|
|
6580
|
-
};
|
|
6581
|
-
const onMouseMove = (e) => {
|
|
6582
|
-
if (!isDragging) return;
|
|
6583
|
-
const dx = e.clientX - dragStartX,
|
|
6584
|
-
dy = e.clientY - dragStartY;
|
|
6585
|
-
if (Math.abs(dx) + Math.abs(dy) > 4) _didPan = true;
|
|
6586
|
-
vx = dragVX + dx;
|
|
6587
|
-
vy = dragVY + dy;
|
|
6588
|
-
applyTransform();
|
|
6589
|
-
};
|
|
6590
|
-
const onMouseUp = () => {
|
|
6591
|
-
isDragging = false;
|
|
6592
|
-
};
|
|
6593
|
-
const onClickCapture = (e) => {
|
|
6594
|
-
if (_didPan) {
|
|
6595
|
-
e.stopPropagation();
|
|
6596
|
-
_didPan = false;
|
|
6597
|
-
}
|
|
6598
|
-
};
|
|
6599
|
-
// Click on empty graph background clears the selection. Node clicks call
|
|
6600
|
-
// stopPropagation so they never bubble here; a real pan is swallowed by
|
|
6601
|
-
// onClickCapture above, so this only fires on a genuine background click.
|
|
6602
|
-
const onBgClick = () => clearGraphSelection();
|
|
6603
|
-
|
|
6604
|
-
svg.addEventListener("wheel", onWheel, { passive: false });
|
|
6605
|
-
svg.addEventListener("mousedown", onMouseDown);
|
|
6606
|
-
window.addEventListener("mousemove", onMouseMove);
|
|
6607
|
-
window.addEventListener("mouseup", onMouseUp);
|
|
6608
|
-
svg.addEventListener("click", onClickCapture, true);
|
|
6609
|
-
svg.addEventListener("click", onBgClick);
|
|
6610
|
-
|
|
6611
|
-
wrap._panCleanup = () => {
|
|
6612
|
-
svg.removeEventListener("wheel", onWheel);
|
|
6613
|
-
svg.removeEventListener("mousedown", onMouseDown);
|
|
6614
|
-
window.removeEventListener("mousemove", onMouseMove);
|
|
6615
|
-
window.removeEventListener("mouseup", onMouseUp);
|
|
6616
|
-
svg.removeEventListener("click", onClickCapture, true);
|
|
6617
|
-
svg.removeEventListener("click", onBgClick);
|
|
6618
|
-
};
|
|
6619
|
-
|
|
6620
|
-
// Fit button — resets zoom/pan to initial fit-to-view.
|
|
6621
|
-
const fitBtn = document.createElement("button");
|
|
6622
|
-
fitBtn.title = "Fit to view";
|
|
6623
|
-
fitBtn.textContent = "⊞";
|
|
6624
|
-
fitBtn.style.cssText =
|
|
6625
|
-
"position:absolute;bottom:10px;right:10px;z-index:10;" +
|
|
6626
|
-
"background:var(--surface);border:1px solid var(--border);color:var(--muted);" +
|
|
6627
|
-
"cursor:pointer;font-size:14px;padding:2px 6px;border-radius:4px;line-height:1;";
|
|
6628
|
-
fitBtn.addEventListener(
|
|
6629
|
-
"mouseenter",
|
|
6630
|
-
() => (fitBtn.style.color = "var(--text)"),
|
|
6631
|
-
);
|
|
6632
|
-
fitBtn.addEventListener(
|
|
6633
|
-
"mouseleave",
|
|
6634
|
-
() => (fitBtn.style.color = "var(--muted)"),
|
|
6635
|
-
);
|
|
6636
|
-
fitBtn.addEventListener("click", () => {
|
|
6637
|
-
vx = Math.max(8, (wrap.clientWidth - svgW * scaleToFit) / 2);
|
|
6638
|
-
vy = Math.max(8, (wrap.clientHeight - svgH * scaleToFit) / 2);
|
|
6639
|
-
vscale = scaleToFit;
|
|
6640
|
-
applyTransform();
|
|
6641
|
-
});
|
|
6642
|
-
wrap.appendChild(fitBtn);
|
|
6643
|
-
|
|
6644
|
-
// Edge legend — mirrors the CLI: hard (solid) vs soft (dashed) vs event
|
|
6645
|
-
// (dotted) vs missing. Makes the derived edge semantics self-explanatory.
|
|
6646
|
-
const legend = document.createElement("div");
|
|
6647
|
-
legend.style.cssText =
|
|
6648
|
-
"position:absolute;top:8px;left:8px;z-index:10;" +
|
|
6649
|
-
"background:var(--surface);border:1px solid var(--border);border-radius:6px;" +
|
|
6650
|
-
"padding:6px 9px;font-size:10px;font-family:var(--mono);color:var(--muted);" +
|
|
6651
|
-
"line-height:1.7;pointer-events:none;";
|
|
6652
|
-
const legRow = (color, dash, label) =>
|
|
6653
|
-
`<div><span style="display:inline-block;width:20px;border-top:2px ${dash} ${color};vertical-align:middle"></span> ${label}</div>`;
|
|
6654
|
-
legend.innerHTML =
|
|
6655
|
-
legRow("#4d9ee8", "solid", "hard") +
|
|
6656
|
-
legRow("#8b949e", "dashed", "soft") +
|
|
6657
|
-
legRow("#d2a8ff", "dotted", "event") +
|
|
6658
|
-
legRow("var(--yellow)", "dashed", "missing");
|
|
6659
|
-
wrap.appendChild(legend);
|
|
6660
|
-
}
|
|
6661
|
-
}
|
|
6662
|
-
|
|
6663
|
-
// ── Node actions popup ────────────────────────────────────────────────────
|
|
6664
|
-
function showActionsPopup(svc, anchorEl) {
|
|
6665
|
-
const existing = document.getElementById("node-actions-popup");
|
|
6666
|
-
if (existing) {
|
|
6667
|
-
const sameNode = existing.dataset.address === svc.address;
|
|
6668
|
-
existing.remove();
|
|
6669
|
-
if (sameNode) return;
|
|
6670
|
-
}
|
|
6671
|
-
|
|
6672
|
-
const popup = document.createElement("div");
|
|
6673
|
-
popup.id = "node-actions-popup";
|
|
6674
|
-
popup.dataset.address = svc.address;
|
|
6675
|
-
|
|
6676
|
-
const hdr = document.createElement("div");
|
|
6677
|
-
hdr.className = "popup-hdr";
|
|
6678
|
-
hdr.textContent = svc.address;
|
|
6679
|
-
popup.appendChild(hdr);
|
|
6680
|
-
|
|
6681
|
-
const addRow = (label, title, onClick) => {
|
|
6682
|
-
const btn = document.createElement("button");
|
|
6683
|
-
btn.className = "popup-cmd-btn";
|
|
6684
|
-
btn.textContent = label;
|
|
6685
|
-
if (title) btn.title = title;
|
|
6686
|
-
btn.addEventListener("click", (e) => {
|
|
6687
|
-
e.stopPropagation();
|
|
6688
|
-
popup.remove();
|
|
6689
|
-
onClick();
|
|
6690
|
-
});
|
|
6691
|
-
popup.appendChild(btn);
|
|
6692
|
-
return btn;
|
|
6693
|
-
};
|
|
6694
|
-
const addDivider = () => {
|
|
6695
|
-
const d = document.createElement("div");
|
|
6696
|
-
d.className = "popup-divider";
|
|
6697
|
-
popup.appendChild(d);
|
|
6698
|
-
};
|
|
6699
|
-
|
|
6700
|
-
// ── Toggles (top) ──────────────────────────────────────────────────────
|
|
6701
|
-
// Auto-start — shows the CURRENT state; click flips it (with a
|
|
6702
|
-
// hard-dependency confirm when turning off a system others need).
|
|
6703
|
-
const isOff = _noAutostartRepos.has(svc.repo);
|
|
6704
|
-
const uncloned = svc.cloned === false;
|
|
6705
|
-
addRow(
|
|
6706
|
-
isOff ? "⏸️ auto-start: off" : "✅ auto-start: on",
|
|
6707
|
-
"start / don't start with workspace",
|
|
6708
|
-
() => toggleAutostart(svc.repo, isOff /* off→turn on, on→turn off */),
|
|
4744
|
+
mountGraph(
|
|
4745
|
+
wrap,
|
|
4746
|
+
{
|
|
4747
|
+
services,
|
|
4748
|
+
health: _lastHealthResults,
|
|
4749
|
+
graph: _graphDerived,
|
|
4750
|
+
apps: _graphApps,
|
|
4751
|
+
orgs: _orgData,
|
|
4752
|
+
depsMap,
|
|
4753
|
+
workspace: document.getElementById("content")?.dataset.ws || "",
|
|
4754
|
+
},
|
|
4755
|
+
GRAPH_HOOKS,
|
|
6709
4756
|
);
|
|
6710
|
-
// Mode switch — action-labelled: shows the mode you'd switch TO.
|
|
6711
|
-
const toLocal = svc.mode === "managed";
|
|
6712
|
-
addRow(
|
|
6713
|
-
toLocal ? "💻 use: local" : "🪐 use: __DEN_CLI__",
|
|
6714
|
-
toLocal
|
|
6715
|
-
? "switch to your own local checkout (dev-managed)"
|
|
6716
|
-
: "switch to a managed clone",
|
|
6717
|
-
() => switchMode(svc.repo, toLocal ? "dev-managed" : "managed"),
|
|
6718
|
-
);
|
|
6719
|
-
|
|
6720
|
-
addDivider();
|
|
6721
|
-
|
|
6722
|
-
// ── Commands (canonical lifecycle order first, then the rest) ─────────
|
|
6723
|
-
// Full-color emoji throughout — text dingbats (▶ ⏹ ⚙) render small and
|
|
6724
|
-
// monochrome next to emoji rows.
|
|
6725
|
-
const CMD_ICONS = {
|
|
6726
|
-
up: "🚀",
|
|
6727
|
-
restart: "🔄",
|
|
6728
|
-
down: "🛑",
|
|
6729
|
-
setup: "🔧",
|
|
6730
|
-
preflight: "🧪",
|
|
6731
|
-
seed: "🌱",
|
|
6732
|
-
smoke: "💨",
|
|
6733
|
-
};
|
|
6734
|
-
const CANONICAL = ["up", "restart", "down", "setup"];
|
|
6735
|
-
const cmds = [...(svc.commands || [])].sort((a, b) => {
|
|
6736
|
-
const ia = CANONICAL.indexOf(a),
|
|
6737
|
-
ib = CANONICAL.indexOf(b);
|
|
6738
|
-
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
|
6739
|
-
});
|
|
6740
|
-
for (const cmd of cmds) {
|
|
6741
|
-
addRow(`${CMD_ICONS[cmd] ?? "⚡"} ${cmd}`, null, () =>
|
|
6742
|
-
runCmd(svc.address, svc.repo, svc.service, cmd),
|
|
6743
|
-
);
|
|
6744
|
-
// Plain `up` starts ONLY this system. Offer the closure alongside it:
|
|
6745
|
-
// the server derives what this system needs, skips anything already
|
|
6746
|
-
// healthy, and honours auto-start-off for everything but the seed.
|
|
6747
|
-
if (cmd === "up") {
|
|
6748
|
-
addRow(
|
|
6749
|
-
"🚀 up with deps",
|
|
6750
|
-
"Start this system and everything it depends on (already-healthy systems are left alone)",
|
|
6751
|
-
() => runUpWithDeps(svc.repo),
|
|
6752
|
-
);
|
|
6753
|
-
}
|
|
6754
|
-
// Symmetric with "up with deps" — the same closure, torn down in
|
|
6755
|
-
// reverse. Systems outside it that share those foundations are named
|
|
6756
|
-
// in the output rather than being knocked over silently.
|
|
6757
|
-
if (cmd === "down") {
|
|
6758
|
-
addRow(
|
|
6759
|
-
"🛑 down with deps",
|
|
6760
|
-
"Stop this system and the dependencies it was started with, seed first (other systems still using them are called out)",
|
|
6761
|
-
() => runDownWithDeps(svc.repo),
|
|
6762
|
-
);
|
|
6763
|
-
}
|
|
6764
|
-
}
|
|
6765
|
-
if (cmds.length === 0) {
|
|
6766
|
-
const em = document.createElement("div");
|
|
6767
|
-
em.className = "popup-empty";
|
|
6768
|
-
em.textContent = "no commands";
|
|
6769
|
-
popup.appendChild(em);
|
|
6770
|
-
}
|
|
6771
|
-
|
|
6772
|
-
// Logs — pivots to the Logs view with this system's stream opened.
|
|
6773
|
-
// Skipped for an uncloned repo: there is no service to tail, so the row
|
|
6774
|
-
// would open a permanently empty log tab.
|
|
6775
|
-
if (!uncloned) {
|
|
6776
|
-
addRow("📜 logs", null, () => {
|
|
6777
|
-
storeOpenLog(svc.repo, svc.service, "local");
|
|
6778
|
-
window.location.href =
|
|
6779
|
-
"/logs?open=" + encodeURIComponent(svc.repo + "/" + svc.service);
|
|
6780
|
-
});
|
|
6781
|
-
}
|
|
6782
|
-
|
|
6783
|
-
// Sync — managed repos ONLY. These are __DEN_CLI__'s own clones under
|
|
6784
|
-
// __DEN_SERVICES_DIR__/, so pulling them is safe. Deliberately absent (not
|
|
6785
|
-
// disabled) for dev-managed repos: those are the user's own checkouts and
|
|
6786
|
-
// a pull could clobber in-progress work, so offering it at all would be
|
|
6787
|
-
// the wrong affordance.
|
|
6788
|
-
if (svc.mode === "managed") {
|
|
6789
|
-
addDivider();
|
|
6790
|
-
addRow(
|
|
6791
|
-
uncloned ? "⬇ clone" : "⟳ sync",
|
|
6792
|
-
uncloned
|
|
6793
|
-
? "Clone this repo into __DEN_SERVICES_DIR__/ and run setup. Does NOT start it — use ▶ up"
|
|
6794
|
-
: 'Down, pull latest main, run setup (prompts auto-answered "n"). Does NOT start it — use ▶ up',
|
|
6795
|
-
() => runRepoSync(svc.repo),
|
|
6796
|
-
);
|
|
6797
|
-
}
|
|
6798
|
-
|
|
6799
|
-
// Open in editor — bottom, set off by a divider (dev-managed repos).
|
|
6800
|
-
if (svc.mode === "dev-managed" && svc.repoPath) {
|
|
6801
|
-
addDivider();
|
|
6802
|
-
addRow("📂 open in editor", null, () => openInEditor(svc.repoPath));
|
|
6803
|
-
}
|
|
6804
|
-
|
|
6805
|
-
document.body.appendChild(popup);
|
|
6806
|
-
|
|
6807
|
-
// Position to the right of the anchor; flip left if overflow
|
|
6808
|
-
const r = anchorEl.getBoundingClientRect();
|
|
6809
|
-
popup.style.left = r.right + 6 + "px";
|
|
6810
|
-
popup.style.top = r.top - 4 + "px";
|
|
6811
|
-
requestAnimationFrame(() => {
|
|
6812
|
-
const pr = popup.getBoundingClientRect();
|
|
6813
|
-
if (pr.right > window.innerWidth - 8) {
|
|
6814
|
-
popup.style.left = r.left - pr.width - 6 + "px";
|
|
6815
|
-
}
|
|
6816
|
-
});
|
|
6817
4757
|
}
|
|
6818
4758
|
|
|
6819
4759
|
// ── Auto-start toggle ─────────────────────────────────────────────────────
|
|
@@ -6917,7 +4857,6 @@
|
|
|
6917
4857
|
|
|
6918
4858
|
// Close popup on any outside click
|
|
6919
4859
|
document.addEventListener("click", (e) => {
|
|
6920
|
-
document.getElementById("node-actions-popup")?.remove();
|
|
6921
4860
|
// Close service action dropdowns unless the click was inside one
|
|
6922
4861
|
if (!e.target.closest(".action-menu-wrap")) {
|
|
6923
4862
|
document
|
|
@@ -6929,7 +4868,7 @@
|
|
|
6929
4868
|
// Keep graph health dots in sync with the header's poll / manual refresh
|
|
6930
4869
|
document.addEventListener("__DEN_CLI__:health-update", (e) => {
|
|
6931
4870
|
_lastHealthResults = e.detail || [];
|
|
6932
|
-
|
|
4871
|
+
graphHealth(_lastHealthResults);
|
|
6933
4872
|
applyHealthToApps(_lastHealthResults);
|
|
6934
4873
|
});
|
|
6935
4874
|
|