@monster-orange/den 0.2.8 → 0.3.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 (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. package/themes/den/mark.svg +6 -0
@@ -0,0 +1,260 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <script>
7
+ // Mode before first paint: yours (the server fills it in below),
8
+ // else the system's, else the theme's default, else dark.
9
+ (function () {
10
+ var m = "@@DEN_MODE@@";
11
+ if (m !== "dark" && m !== "light") {
12
+ var q = function (s) {
13
+ return (
14
+ window.matchMedia &&
15
+ matchMedia("(prefers-color-scheme: " + s + ")").matches
16
+ );
17
+ };
18
+ m = q("light")
19
+ ? "light"
20
+ : q("dark")
21
+ ? "dark"
22
+ : "@@DEN_DEFAULT_MODE@@";
23
+ }
24
+ document.documentElement.dataset.mode =
25
+ m === "light" ? "light" : "dark";
26
+ })();
27
+ </script>
28
+ <link rel="stylesheet" href="/theme.css" id="theme-css" />
29
+ <title>__DEN_TITLE__ · Error</title>
30
+ <style>
31
+ /* The error page (R26a, 0.3.0-dev.38): the prototype's (den-shell.html)
32
+ page header (.ph), empty-state box (.empty) and buttons (.btn),
33
+ ported as they are; --r is --radius, --r-btn is --radius-button. */
34
+ *,
35
+ *::before,
36
+ *::after {
37
+ box-sizing: border-box;
38
+ }
39
+ html,
40
+ body {
41
+ height: 100%;
42
+ margin: 0;
43
+ background: var(--bg);
44
+ color: var(--text);
45
+ }
46
+ body {
47
+ display: flex;
48
+ flex-direction: column;
49
+ overflow: hidden;
50
+ }
51
+ /* .main */
52
+ html body.den-has-shell > #den-content {
53
+ overflow: auto;
54
+ min-width: 0;
55
+ padding: 20px 24px 32px 24px;
56
+ font: 14px/1.45 var(--sans);
57
+ color: var(--text);
58
+ display: block;
59
+ }
60
+ #den-content :focus-visible {
61
+ outline: 2px solid var(--accent);
62
+ outline-offset: 2px;
63
+ }
64
+ a {
65
+ color: var(--accent-text);
66
+ }
67
+ /* .ph */
68
+ .ph {
69
+ display: flex;
70
+ align-items: flex-end;
71
+ gap: 16px;
72
+ flex-wrap: wrap;
73
+ margin-bottom: 16px;
74
+ }
75
+ .ph h1 {
76
+ font: 600 22px/1.2 var(--display);
77
+ margin: 0;
78
+ text-wrap: balance;
79
+ }
80
+ .ph .sub {
81
+ color: var(--muted);
82
+ font-size: 13px;
83
+ margin-top: 4px;
84
+ overflow-wrap: anywhere;
85
+ }
86
+ .ph .k {
87
+ font: 11px var(--mono);
88
+ text-transform: uppercase;
89
+ letter-spacing: 0.07em;
90
+ color: var(--faint);
91
+ }
92
+ /* .empty */
93
+ .empty {
94
+ border: 1px dashed var(--border-strong);
95
+ border-radius: var(--radius);
96
+ padding: 28px;
97
+ color: var(--muted);
98
+ max-width: 640px;
99
+ }
100
+ .empty b {
101
+ color: var(--text);
102
+ }
103
+ .empty p {
104
+ margin: 0 0 12px;
105
+ }
106
+ .empty .lead {
107
+ color: var(--text);
108
+ }
109
+ /* .btn */
110
+ .btn {
111
+ border: 1px solid var(--border);
112
+ background: var(--surface);
113
+ color: var(--text);
114
+ border-radius: var(--radius-button);
115
+ padding: 6px 12px;
116
+ cursor: pointer;
117
+ font: 500 14px/1.45 var(--sans);
118
+ white-space: nowrap;
119
+ text-decoration: none;
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: 6px;
123
+ }
124
+ .btn:hover {
125
+ border-color: var(--border-strong);
126
+ }
127
+ .btn.primary {
128
+ background: var(--accent-fill);
129
+ border-color: var(--accent-fill);
130
+ color: var(--on-accent);
131
+ }
132
+ .btn kbd {
133
+ font: 11px var(--mono);
134
+ color: var(--faint);
135
+ }
136
+ .acts {
137
+ display: flex;
138
+ gap: 8px;
139
+ flex-wrap: wrap;
140
+ margin-top: 16px;
141
+ }
142
+ .mono {
143
+ font-family: var(--mono);
144
+ font-size: 12.5px;
145
+ color: var(--text);
146
+ overflow-wrap: anywhere;
147
+ }
148
+ dl.facts {
149
+ display: grid;
150
+ grid-template-columns: max-content 1fr;
151
+ gap: 6px 16px;
152
+ margin: 0;
153
+ }
154
+ dl.facts dt {
155
+ font: 11px var(--mono);
156
+ text-transform: uppercase;
157
+ letter-spacing: 0.07em;
158
+ color: var(--faint);
159
+ padding-top: 2px;
160
+ }
161
+ dl.facts dd {
162
+ margin: 0;
163
+ }
164
+ h2.sec {
165
+ font: 600 13px var(--mono);
166
+ text-transform: uppercase;
167
+ letter-spacing: 0.07em;
168
+ color: var(--muted);
169
+ margin: 22px 0 10px;
170
+ }
171
+ /* The 422 page's problems (0.3.0-dev.40): the prototype's .tablewrap
172
+ and table.svc, and its pre.code for the command. */
173
+ .tablewrap {
174
+ overflow-x: auto;
175
+ border: 1px solid var(--border);
176
+ border-radius: var(--radius);
177
+ background: var(--surface);
178
+ margin: 0 0 16px;
179
+ }
180
+ table.svc {
181
+ width: 100%;
182
+ border-collapse: collapse;
183
+ font-size: 13.5px;
184
+ }
185
+ table.svc th {
186
+ text-align: left;
187
+ font: 11px var(--mono);
188
+ text-transform: uppercase;
189
+ letter-spacing: 0.07em;
190
+ color: var(--faint);
191
+ padding: 8px 12px;
192
+ border-bottom: 1px solid var(--border);
193
+ font-weight: 400;
194
+ }
195
+ table.svc td {
196
+ padding: 7px 12px;
197
+ border-bottom: 1px solid var(--border);
198
+ color: var(--text);
199
+ vertical-align: top;
200
+ }
201
+ table.svc td.mono {
202
+ white-space: nowrap;
203
+ }
204
+ table.svc tr:last-child td {
205
+ border-bottom: 0;
206
+ }
207
+ pre.code {
208
+ background: var(--sunken);
209
+ border: 1px solid var(--border);
210
+ border-radius: var(--radius-button);
211
+ padding: 12px;
212
+ overflow-x: auto;
213
+ font: 12px/1.5 var(--mono);
214
+ margin: 0 0 12px;
215
+ color: var(--text);
216
+ }
217
+ .empty.wide {
218
+ max-width: 880px;
219
+ }
220
+ ul.pages {
221
+ list-style: none;
222
+ margin: 0;
223
+ padding: 0;
224
+ display: flex;
225
+ flex-wrap: wrap;
226
+ gap: 6px 16px;
227
+ }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <!-- The shell: top bar and rail, the same on every page (shell.js). -->
232
+ <div id="den-shell" data-page="error"></div>
233
+ <script src="/shell.js"></script>
234
+
235
+ <!-- The page's one main landmark: everything after the shell. -->
236
+ <main id="den-content">
237
+ <div id="den-error-page" aria-live="polite"></div>
238
+ <script type="application/json" id="den-error">
239
+ @@DEN_ERROR@@
240
+ </script>
241
+ <script type="module">
242
+ import {
243
+ applyDenConfig,
244
+ initChangelog,
245
+ initHealth,
246
+ initLogBadge,
247
+ initWorkspacePill,
248
+ } from "/header.js";
249
+ import { mountErrorPage } from "/error-page.js";
250
+ mountErrorPage(document.getElementById("den-error-page"));
251
+ void Promise.all([
252
+ initHealth(),
253
+ initWorkspacePill(),
254
+ applyDenConfig(),
255
+ initChangelog(),
256
+ ]).then(initLogBadge);
257
+ </script>
258
+ </main>
259
+ </body>
260
+ </html>
@@ -13,6 +13,8 @@
13
13
  */
14
14
 
15
15
  /** Real card sizes; there is no shrink-to-fit. */
16
+ import { describeRollup, rollupRepo } from "./health-rollup.js";
17
+
16
18
  export const SIZE = {
17
19
  detailed: { w: 236, h: 104 },
18
20
  compact: { w: 176, h: 52 },
@@ -31,15 +33,17 @@ export function placementOf(r) {
31
33
  return { runs, target };
32
34
  }
33
35
 
36
+ // An org's color when the org config names none. Org labels are text, so
37
+ // only text tokens (each 4.5:1 on every ground) and mixes of two of them.
34
38
  const PALETTE = [
35
- "#58a6ff",
36
- "#3fb950",
37
- "#a371f7",
38
- "#d29922",
39
- "#f0883e",
40
- "#ff7b72",
41
- "#79c0ff",
42
- "#56d364",
39
+ "var(--accent-text)",
40
+ "var(--ok)",
41
+ "color-mix(in oklab, var(--remote) 50%, var(--bad))",
42
+ "var(--warn)",
43
+ "var(--remote)",
44
+ "color-mix(in oklab, var(--warn) 50%, var(--bad))",
45
+ "color-mix(in oklab, var(--accent-text) 50%, var(--remote))",
46
+ "var(--muted)",
43
47
  ];
44
48
  function colorFor(name) {
45
49
  let h = 5381;
@@ -51,7 +55,7 @@ function colorFor(name) {
51
55
  /** Build the model the graph and panel render from. */
52
56
  export function buildModel({
53
57
  services = [],
54
- health = [],
58
+ health = null,
55
59
  graph = {},
56
60
  apps = [],
57
61
  orgs = null,
@@ -74,7 +78,8 @@ export function buildModel({
74
78
  org: orgs?.repos?.[r.repo] || "",
75
79
  app: a ? a.title || a.name : r.repo,
76
80
  ...placementOf(r),
77
- health: { ok: null },
81
+ // No health round has reached it yet (0.3.0-dev.40): "checking…".
82
+ health: { ok: null, checked: false },
78
83
  open: null,
79
84
  entry: !!r.isEntrypoint,
80
85
  infra: !!r.infra,
@@ -88,6 +93,7 @@ export function buildModel({
88
93
  cloned: r.cloned !== false,
89
94
  manifestLayers: r.manifestLayers || [],
90
95
  owners: r.owners || [],
96
+ inWorkspaces: r.inWorkspaces || [],
91
97
  missing: depsMap[r.address] || [],
92
98
  hasPlacement: "runs" in r,
93
99
  };
@@ -124,7 +130,8 @@ export function buildModel({
124
130
  apps: apps || [],
125
131
  mixed: false,
126
132
  };
127
- applyHealth(m, health);
133
+ // null: no health round yet, so every card says "checking…".
134
+ if (health) applyHealth(m, health);
128
135
  return m;
129
136
  }
130
137
 
@@ -136,8 +143,13 @@ export function applyHealth(m, results) {
136
143
  for (const s of m.services) {
137
144
  // Health rows name repo + service; a single-service repo's address is the bare repo.
138
145
  const h = byKey.get(`${s.repo}/${s.name}`) || byKey.get(s.id);
139
- if (!h) continue;
146
+ // A round without a row for it still counts as its first check.
147
+ if (!h) {
148
+ s.health = { ...s.health, checked: true };
149
+ continue;
150
+ }
140
151
  s.health = {
152
+ checked: true,
141
153
  ok: h.ok === true ? true : h.ok === false ? false : null,
142
154
  label: h.label,
143
155
  url: h.url ?? null,
@@ -162,7 +174,7 @@ export const orgOf = (m, id) =>
162
174
  m.orgs.get(id) || {
163
175
  id,
164
176
  label: id || m.workspace || "Workspace",
165
- color: "#8b949e",
177
+ color: "var(--muted)",
166
178
  };
167
179
  export const isLocal = (s) => s.runs !== "remote";
168
180
  export const status = (s) =>
@@ -193,6 +205,8 @@ export const fmtMs = (ms) =>
193
205
  ms == null ? "" : ms >= 1000 ? (ms / 1000).toFixed(1) + " s" : ms + " ms";
194
206
 
195
207
  export function healthLine(s) {
208
+ // Before the first health round, as the Workspaces table says (dev.37).
209
+ if (s.health.checked === false) return "checking…";
196
210
  const ms = s.health.ms != null ? " · " + fmtMs(s.health.ms) : "";
197
211
  if (s.runs === "remote")
198
212
  return (
@@ -206,12 +220,45 @@ export function healthLine(s) {
206
220
  return (s.health.ok ? "healthy" : "down") + ms;
207
221
  }
208
222
 
223
+ /**
224
+ * A repo's health from its services: health-rollup.js's rule, the one the
225
+ * header row and `<cli> status` use, so a repo card never disagrees with
226
+ * them. Unchecked services don't count.
227
+ */
228
+ export function repoRollup(m, repo) {
229
+ return rollupRepo(
230
+ repo,
231
+ (m.byRepo.get(repo) || []).map((s) => ({
232
+ service: s.name,
233
+ ok: s.health.ok,
234
+ isEntrypoint: s.entry,
235
+ })),
236
+ );
237
+ }
238
+
239
+ /** The rollup as a .dg-dot class, and in words. */
240
+ export const repoStatus = (r) =>
241
+ ({ down: "bad", degraded: "warn", ok: "ok", unknown: "unk" })[r.state];
242
+ export const repoStatusWord = (r) => describeRollup(r);
243
+
209
244
  /** A repo's representative service: entrypoint, root-named, else first. */
210
245
  export function entryOf(m, repo) {
211
246
  const l = m.byRepo.get(repo) || [];
212
247
  return l.find((s) => s.entry) || l.find((s) => s.name === repo) || l[0];
213
248
  }
214
249
 
250
+ /**
251
+ * Find (the Graph page's, and the Workspaces table's, R24d): does `key` (a
252
+ * service address, "repo:<name>", or a repo name) match the query? Case
253
+ * apart, a substring; an empty query matches nothing (nothing is a hit).
254
+ */
255
+ export function findHit(key, query) {
256
+ const q = String(query ?? "")
257
+ .trim()
258
+ .toLowerCase();
259
+ return !!q && String(key).toLowerCase().includes(q);
260
+ }
261
+
215
262
  export const isCollapsed = (m, collapsed, repo) =>
216
263
  collapsed.has(repo) && (m.byRepo.get(repo)?.length || 0) > 1;
217
264
  export const nodeFor = (m, collapsed, id) => {
@@ -242,6 +289,19 @@ export function edgeModel(m, collapsed) {
242
289
  }
243
290
 
244
291
  /** The ELK graph: outer group (org or application) › repo › service. */
292
+ /**
293
+ * A compound node's minimum size, as elk.nodeSize.minimum. ELK's layered
294
+ * layout with INCLUDE_CHILDREN reads a compound node's minimum in its own
295
+ * layout axes: top-down, the first value bounds the height and the second
296
+ * the width. Passed as (width, height) there, a repo box's minimum width
297
+ * (386px) became its height, and a box with one row of cards drew about
298
+ * twice the height it needs (a consumer's dev.32 finding). So top-down
299
+ * swaps the pair; left-right it is (width, height) as written.
300
+ */
301
+ export function groupMinimum(w, h, direction = "down") {
302
+ return direction === "right" ? `(${w},${h})` : `(${h},${w})`;
303
+ }
304
+
245
305
  export function buildElkGraph(
246
306
  m,
247
307
  {
@@ -279,7 +339,7 @@ export function buildElkGraph(
279
339
  layoutOptions: {
280
340
  "elk.padding": "[top=40,left=14,bottom=14,right=14]",
281
341
  "elk.nodeSize.constraints": "MINIMUM_SIZE",
282
- "elk.nodeSize.minimum": "(" + (sz.w + 150) + ",40)",
342
+ "elk.nodeSize.minimum": groupMinimum(sz.w + 150, 40, direction),
283
343
  },
284
344
  children: svcs.map((s) => {
285
345
  parent[s.id] = rid;
@@ -349,6 +409,198 @@ export function buildElkGraph(
349
409
  return root;
350
410
  }
351
411
 
412
+ // ── Wrapping the top-level groups (0.3.0-dev.34) ─────────────────────────
413
+ // A consumer's distribution with 31 services in 10 org groups: one layered
414
+ // layout put every top-level group in one row (top-down), so the whole
415
+ // graph fitted only at 15–24%, where cards are hard to read. ELK's own
416
+ // options did not help: with INCLUDE_CHILDREN the root is one layered
417
+ // graph, and elk.aspectRatio (its component packing) left the row as it
418
+ // was. So each top-level group is laid out on its own, by the same layered
419
+ // layout with the same options (a group's inside is as before), the groups
420
+ // are wrapped into rows (top-down) or columns (left-right) sized for the
421
+ // stage, and the few edges between groups are routed here, orthogonally,
422
+ // through the gaps between rows (or columns).
423
+
424
+ /** Gap between wrapped groups: elk.spacing.componentComponent. */
425
+ export const GROUP_GAP = 48;
426
+
427
+ /**
428
+ * Split buildElkGraph's root into one ELK graph per top-level group (the
429
+ * root's options, the group, the edges inside it) and the edges between
430
+ * groups (the root's own). Groups are ordered so that an edge's source
431
+ * comes first where the edges allow (stable otherwise): in reading order
432
+ * a dependency then sits after, below or to the right of, its user, as the
433
+ * layered layout puts it.
434
+ */
435
+ export function splitTopLevel(root) {
436
+ const groups = root.children || [];
437
+ const cross = root.edges || [];
438
+ const groupOf = new Map();
439
+ (function index(n, top) {
440
+ for (const c of n.children || []) {
441
+ const t = top ?? c.id;
442
+ groupOf.set(c.id, t);
443
+ index(c, t);
444
+ }
445
+ })(root, null);
446
+ const idx = new Map(groups.map((g, i) => [g.id, i]));
447
+ const after = groups.map(() => new Set());
448
+ const indeg = groups.map(() => 0);
449
+ for (const e of cross) {
450
+ const a = idx.get(groupOf.get(e.sources[0]));
451
+ const b = idx.get(groupOf.get(e.targets[0]));
452
+ if (a == null || b == null || a === b || after[a].has(b)) continue;
453
+ after[a].add(b);
454
+ indeg[b]++;
455
+ }
456
+ const order = [];
457
+ const done = new Set();
458
+ while (order.length < groups.length) {
459
+ // The first group with nothing before it; on a cycle, the first left.
460
+ let i = groups.findIndex((_, k) => !done.has(k) && indeg[k] === 0);
461
+ if (i < 0) i = groups.findIndex((_, k) => !done.has(k));
462
+ done.add(i);
463
+ order.push(i);
464
+ for (const b of after[i]) indeg[b]--;
465
+ }
466
+ return {
467
+ parts: order.map((i) => ({
468
+ id: "root",
469
+ layoutOptions: root.layoutOptions,
470
+ children: [groups[i]],
471
+ edges: [],
472
+ })),
473
+ cross,
474
+ groupOf,
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Wrap groups of the given sizes, in order, into rows (direction "down")
480
+ * or columns ("right"), choosing the wrap that lets the whole fit the
481
+ * area (width × height) at the highest zoom. Returns each group's place
482
+ * ({ x, y, band }) and the size of the whole ({ w, h }), from 0,0.
483
+ * Without an area, a 16:10 one.
484
+ */
485
+ export function packGroups(
486
+ sizes,
487
+ { direction = "down", width = 1600, height = 1000, gap = GROUP_GAP } = {},
488
+ ) {
489
+ const down = direction !== "right";
490
+ // Along: the axis a row runs along (x top-down); across: the other.
491
+ const along = sizes.map((s) => (down ? s.w : s.h));
492
+ const across = sizes.map((s) => (down ? s.h : s.w));
493
+ const lay = (limit) => {
494
+ const pos = [];
495
+ let a = 0,
496
+ c = 0,
497
+ band = 0,
498
+ depth = 0,
499
+ maxA = 0;
500
+ along.forEach((len, i) => {
501
+ if (a > 0 && a + gap + len > limit) {
502
+ c += depth + gap;
503
+ a = 0;
504
+ depth = 0;
505
+ band++;
506
+ }
507
+ const at = a > 0 ? a + gap : 0;
508
+ pos.push({ a: at, c, band });
509
+ a = at + len;
510
+ depth = Math.max(depth, across[i]);
511
+ maxA = Math.max(maxA, a);
512
+ });
513
+ return { pos, A: maxA, C: c + depth };
514
+ };
515
+ // Every row width a greedy wrap can end on: a run of consecutive groups.
516
+ const limits = new Set();
517
+ for (let i = 0; i < along.length; i++) {
518
+ let w = 0;
519
+ for (let j = i; j < along.length; j++) {
520
+ w += (j > i ? gap : 0) + along[j];
521
+ limits.add(w);
522
+ }
523
+ }
524
+ // Widest first, so a tie keeps fewer rows.
525
+ let best = null;
526
+ for (const limit of [...limits].sort((x, y) => y - x)) {
527
+ const l = lay(limit);
528
+ const W = down ? l.A : l.C,
529
+ H = down ? l.C : l.A;
530
+ const z = Math.min(width / Math.max(W, 1), height / Math.max(H, 1));
531
+ if (!best || z > best.z + 1e-9) best = { z, l, W, H };
532
+ }
533
+ if (!best) return { pos: [], w: 0, h: 0 };
534
+ return {
535
+ pos: best.l.pos.map((p) => ({
536
+ x: down ? p.a : p.c,
537
+ y: down ? p.c : p.a,
538
+ band: p.band,
539
+ })),
540
+ w: best.W,
541
+ h: best.H,
542
+ };
543
+ }
544
+
545
+ /**
546
+ * An edge between two wrapped groups, as orthogonal points. s and t are
547
+ * the endpoint cards' boxes ({ x, y, w, h }); sBand and tBand their
548
+ * groups' row (or column) spans across the wrap ({ from, to, band }).
549
+ * A target in a later row: down out of the source, along the gap above
550
+ * the target's row, down into the target. In an earlier row: up, along
551
+ * the gap above the source's row, up into the target's foot. In the same
552
+ * row: out of the facing side, across the gap between them. Left-right
553
+ * is the same with the axes swapped.
554
+ */
555
+ export function routeCross(
556
+ s,
557
+ t,
558
+ sBand,
559
+ tBand,
560
+ direction = "down",
561
+ gap = GROUP_GAP,
562
+ ) {
563
+ const down = direction !== "right";
564
+ // Work top-down; swap axes for left-right and swap back at the end.
565
+ const sw = (b) => (down ? b : { x: b.y, y: b.x, w: b.h, h: b.w });
566
+ const S = sw(s),
567
+ T = sw(t);
568
+ const scx = S.x + S.w / 2,
569
+ tcx = T.x + T.w / 2;
570
+ let pts;
571
+ if (tBand.band > sBand.band) {
572
+ const mid = tBand.from - gap / 2;
573
+ pts = [
574
+ [scx, S.y + S.h],
575
+ [scx, mid],
576
+ [tcx, mid],
577
+ [tcx, T.y],
578
+ ];
579
+ } else if (tBand.band < sBand.band) {
580
+ const mid = sBand.from - gap / 2;
581
+ pts = [
582
+ [scx, S.y],
583
+ [scx, mid],
584
+ [tcx, mid],
585
+ [tcx, T.y + T.h],
586
+ ];
587
+ } else {
588
+ const right = T.x >= S.x + S.w;
589
+ const sx = right ? S.x + S.w : S.x,
590
+ tx = right ? T.x : T.x + T.w;
591
+ const sy = S.y + S.h / 2,
592
+ ty = T.y + T.h / 2;
593
+ const mid = (sx + tx) / 2;
594
+ pts = [
595
+ [sx, sy],
596
+ [mid, sy],
597
+ [mid, ty],
598
+ [tx, ty],
599
+ ];
600
+ }
601
+ return down ? pts : pts.map(([x, y]) => [y, x]);
602
+ }
603
+
352
604
  /** Manifest commands that destroy state confirm inline, in Careful. */
353
605
  export const DANGER_CMD = /reset|drop|wipe|clean|destroy|purge/;
354
606
  export const commandTier = (cmd) =>
@@ -414,3 +666,69 @@ export function connections(m, x) {
414
666
  users: [...new Set([...usedBy, ...repoIn])].map((id) => ({ id })),
415
667
  };
416
668
  }
669
+
670
+ /**
671
+ * The Graph page with the top bar's "All active" (0.3.0-dev.33): every
672
+ * workspace in view, as the Services page shows them (one /api/services
673
+ * list, a service several define listed once with `inWorkspaces`). The
674
+ * per-workspace /api/graph, /api/applications and deps-check payloads
675
+ * are merged here, in view order (focus first):
676
+ *
677
+ * graph nodes and `order` once each, in first-seen order; an edge
678
+ * once per from → to and kind (the first workspace's)
679
+ * apps one per name, its repos the union
680
+ * depsMap an address's missing deps, the union
681
+ */
682
+ export function mergeWorkspaceViews({
683
+ graphs = [],
684
+ apps = [],
685
+ deps = [],
686
+ } = {}) {
687
+ const nodes = [];
688
+ const order = [];
689
+ const edges = [];
690
+ const seenEdge = new Set();
691
+ const once = (arr, v) => {
692
+ if (!arr.includes(v)) arr.push(v);
693
+ };
694
+ for (const g of graphs) {
695
+ for (const n of g?.nodes || []) once(nodes, n);
696
+ for (const n of g?.order || []) once(order, n);
697
+ for (const e of g?.edges || []) {
698
+ const k = `${e.from}\u0000${e.to}\u0000${e.kind}`;
699
+ if (seenEdge.has(k)) continue;
700
+ seenEdge.add(k);
701
+ edges.push(e);
702
+ }
703
+ }
704
+ const byName = new Map();
705
+ for (const list of apps)
706
+ for (const a of list || []) {
707
+ const had = byName.get(a.name);
708
+ if (!had) {
709
+ byName.set(a.name, { ...a, repos: [...(a.repos || [])] });
710
+ continue;
711
+ }
712
+ for (const r of a.repos || []) once(had.repos, r);
713
+ }
714
+ const depsMap = {};
715
+ for (const list of deps)
716
+ for (const { address, missingDeps } of list || []) {
717
+ const had = (depsMap[address] ||= []);
718
+ for (const d of missingDeps || []) once(had, d);
719
+ }
720
+ return {
721
+ graph: { nodes, edges, ...(order.length ? { order } : {}) },
722
+ apps: [...byName.values()],
723
+ depsMap,
724
+ };
725
+ }
726
+
727
+ /** Each repo's owning workspace (the first in view that defines it). */
728
+ export function ownerOfRepos(services = []) {
729
+ const out = new Map();
730
+ for (const s of services)
731
+ if (!out.has(s.repo) && s.inWorkspaces?.length)
732
+ out.set(s.repo, s.inWorkspaces[0]);
733
+ return out;
734
+ }