@monster-orange/den 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +1 -1
  3. package/bin/den +7 -0
  4. package/docs/distributions.md +255 -12
  5. package/package.json +4 -2
  6. package/src/pillars/journeys/sandbox.ts +2 -5
  7. package/src/pillars/security/discover.ts +17 -1
  8. package/src/pillars/security/run.ts +31 -3
  9. package/src/shared/agent-actor.ts +77 -0
  10. package/src/shared/den-config.ts +122 -0
  11. package/src/shared/edge/status.ts +74 -0
  12. package/src/shared/environment-manager/health.ts +54 -8
  13. package/src/shared/environment-manager/load-applications.ts +31 -1
  14. package/src/shared/environment-manager/log-tail.ts +5 -4
  15. package/src/shared/environment-manager/opctl.ts +2 -1
  16. package/src/shared/environment-manager/service-container.ts +47 -0
  17. package/src/shared/environment-manager/service-runner.ts +16 -1
  18. package/src/shared/environment-manager/sync-targets.ts +66 -0
  19. package/src/shared/environment-manager/up-plan.ts +25 -1
  20. package/src/shared/manifest/merge-override.ts +75 -0
  21. package/src/shared/manifest/placement.ts +321 -0
  22. package/src/shared/manifest/read-manifest.ts +156 -38
  23. package/src/shared/manifest/schema.ts +14 -0
  24. package/src/shared/manifest/validate-layers.ts +229 -0
  25. package/src/shared/manifest/validate-manifest.ts +66 -6
  26. package/src/shared/paths.ts +12 -0
  27. package/src/shared/redact.ts +107 -0
  28. package/src/shared/stacks/manifest-commands.ts +30 -0
  29. package/src/shared/stacks/status.ts +99 -0
  30. package/src/shared/types/index.ts +23 -0
  31. package/src/ui/cli/applications-command.ts +34 -2
  32. package/src/ui/cli/edge-command.ts +9 -19
  33. package/src/ui/cli/graph-command.ts +50 -4
  34. package/src/ui/cli/index.ts +131 -45
  35. package/src/ui/cli/mcp-main.ts +13 -0
  36. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  37. package/src/ui/cli/stack-command.ts +13 -40
  38. package/src/ui/cli/test-runner.ts +8 -1
  39. package/src/ui/mcp/api.ts +284 -0
  40. package/src/ui/mcp/guards.ts +141 -0
  41. package/src/ui/mcp/server.ts +147 -0
  42. package/src/ui/mcp/tools.ts +565 -0
  43. package/src/ui/server/audit.ts +71 -0
  44. package/src/ui/server/changelog-parser.ts +11 -0
  45. package/src/ui/server/graph-prefs.ts +89 -0
  46. package/src/ui/server/health-row.ts +15 -0
  47. package/src/ui/server/index.ts +175 -61
  48. package/src/ui/server/log-history.ts +4 -3
  49. package/src/ui/server/public/environments.html +4 -2
  50. package/src/ui/server/public/graph-model.js +416 -0
  51. package/src/ui/server/public/graph.css +976 -0
  52. package/src/ui/server/public/graph.js +986 -0
  53. package/src/ui/server/public/header.js +7 -5
  54. package/src/ui/server/public/local-dev.html +130 -2191
  55. package/src/ui/server/public/logs.html +11 -4
  56. package/src/ui/server/public/panel.js +787 -0
  57. package/src/ui/server/public/services.html +6 -1
  58. package/src/ui/server/readonly-routes.ts +200 -0
  59. package/src/ui/server/ui-assets.ts +73 -0
  60. 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
- // ── Dependency graph (SVG DAG) ────────────────────────────────────────────
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 subs = (a.services?.[r] || [])
4889
- .map((addr) => (addr.includes("/") ? addr.split("/")[1] : addr))
4890
- .filter((x) => x !== r);
4891
- return `<span class="app-repo" data-repo="${escHtml(r)}"><span class="app-dot"></span>${escHtml(r)}${subs.length ? ` <span class="app-subs">${escHtml(subs.join(", "))}</span>` : ""}</span>`;
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
- // ── Group services by repo ──────────────────────────────────────────────
4992
- const repoGroups = new Map(); // repoName → service[]
4993
- for (const s of services) {
4994
- if (!repoGroups.has(s.repo)) repoGroups.set(s.repo, []);
4995
- repoGroups.get(s.repo).push(s);
4996
- }
4997
-
4998
- // ── Build display nodes ─────────────────────────────────────────────────
4999
- // Multi-service repos expanded by default; collapsedRepos tracks explicit collapses.
5000
- const displayNodes = []; // { address, repo, service, type:'group'|'service', services?, mode, color }
5001
- const addrToDisplay = new Map(); // original addr → display node addr
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
- applyHealthToGraph(_lastHealthResults);
4871
+ graphHealth(_lastHealthResults);
6933
4872
  applyHealthToApps(_lastHealthResults);
6934
4873
  });
6935
4874