@shortlink-org/portolan 0.3.0 → 0.4.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 (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
package/src/enrich.ts CHANGED
@@ -235,6 +235,8 @@ interface HTTPProvider {
235
235
  ref: string;
236
236
  method: string;
237
237
  path: string;
238
+ /** Set when the manifests, not the route alone, chose this provider. */
239
+ basis?: "kubernetes-host";
238
240
  }
239
241
 
240
242
  /**
@@ -255,8 +257,16 @@ interface HTTPProvider {
255
257
  */
256
258
  function resolveHTTPCalls(input: Catalog): Catalog {
257
259
  const providers: HTTPProvider[] = [];
260
+ // What the manifests said, where a tree of them was read: the names each
261
+ // service answers on, and the names each caller is configured to dial.
262
+ const hostsByService = new Map<string, Set<string>>();
263
+ const dialsByService = new Map<string, string[]>();
258
264
  for (const context of input.contexts) {
259
265
  for (const service of context.services) {
266
+ if (service.hosts?.length) {
267
+ hostsByService.set(service.id, new Set(service.hosts));
268
+ }
269
+ if (service.dials?.length) dialsByService.set(service.id, service.dials);
260
270
  for (const provided of service.provides) {
261
271
  for (const method of provided.methods) {
262
272
  // A route with an empty method is mounted but its verb is unknown
@@ -297,7 +307,23 @@ function resolveHTTPCalls(input: Catalog): Catalog {
297
307
  candidates.filter((provider) => sameHTTPShape(provider.path, route.path)),
298
308
  );
299
309
  const matches = exact.length > 0 ? exact : uniqueHTTPProviders(candidates);
300
- return matches.length === 1 ? matches[0] : undefined;
310
+ if (matches.length === 1) return matches[0];
311
+ if (matches.length < 2) return undefined;
312
+ // Ambiguous by route alone. The manifests may say which of them the
313
+ // caller reaches: the host the call names, or failing that the hosts the
314
+ // caller's workload is configured to dial. They only ever decide between
315
+ // providers of the route; a host never conjures a provider that does not
316
+ // have it.
317
+ const host = callHost(call);
318
+ const dials = dialsByService.get(caller) ?? [];
319
+ const reachable = matches.filter((provider) => {
320
+ const hosts = hostsByService.get(provider.service);
321
+ if (!hosts) return false;
322
+ return host ? hosts.has(host) : dials.some((dial) => hosts.has(dial));
323
+ });
324
+ return reachable.length === 1
325
+ ? { ...reachable[0]!, basis: "kubernetes-host" }
326
+ : undefined;
301
327
  };
302
328
 
303
329
  const evidence = (call: Pick<RpcCall, "id" | "source" | "destination">, provider: HTTPProvider): HTTPDestination => {
@@ -307,7 +333,7 @@ function resolveHTTPCalls(input: Catalog): Catalog {
307
333
  method: raw?.method ?? provider.method, localPath: raw?.path,
308
334
  };
309
335
  return { ...destination, resolution: {
310
- basis: destination.fullPath ? "full-path" : sameHTTPShape(provider.path, raw?.path ?? "") ? "exact-route" : "unique-suffix",
336
+ basis: provider.basis ?? (destination.fullPath ? "full-path" : sameHTTPShape(provider.path, raw?.path ?? "") ? "exact-route" : "unique-suffix"),
311
337
  provider: provider.service, route: provider.path,
312
338
  } };
313
339
  };
@@ -385,6 +411,40 @@ function rawHTTPRoute(
385
411
  return match ? { method: match[1]!, path: match[2]! } : undefined;
386
412
  }
387
413
 
414
+ /**
415
+ * The host a call names, when it names one: the service-discovery alias the
416
+ * extractor recorded, the hostname of a literal base URL, or the destination
417
+ * after " @ " in the id when that is a host rather than an expression. A port
418
+ * is dropped, because a Service's name has none.
419
+ */
420
+ function callHost(call: RpcCall): string | undefined {
421
+ const at = /^http-client\/[A-Z]+\s+\/\S*\s@\s(.+)$/.exec(call.id)?.[1];
422
+ for (const value of [
423
+ call.destination?.serviceDiscoveryAlias,
424
+ call.destination?.baseURL?.value,
425
+ at,
426
+ ]) {
427
+ const host = hostOf(value);
428
+ if (host) return host;
429
+ }
430
+ return undefined;
431
+ }
432
+
433
+ function hostOf(value: string | undefined): string | undefined {
434
+ if (!value) return undefined;
435
+ const trimmed = value.trim();
436
+ if (trimmed.includes("://")) {
437
+ try {
438
+ return new URL(trimmed).hostname || undefined;
439
+ } catch {
440
+ return undefined;
441
+ }
442
+ }
443
+ // Lower case by the cluster's own rule, so that `Config.SupplierURL` - an
444
+ // expression the extractor could not evaluate - is not taken for a host.
445
+ return /^([a-z0-9]([a-z0-9.-]*[a-z0-9])?)(:[0-9]+)?$/.exec(trimmed)?.[1];
446
+ }
447
+
388
448
  function uniqueHTTPProviders(providers: HTTPProvider[]): HTTPProvider[] {
389
449
  const unique = new Map<string, HTTPProvider>();
390
450
  for (const provider of providers) {
@@ -1069,21 +1129,59 @@ function resolveWireNames(catalog: Catalog): Catalog {
1069
1129
  * keeps the raw name, because no extractor sees two schemas. The merge sees
1070
1130
  * all of them, and a name exactly one table in the estate answers to is that
1071
1131
  * table - which is what turns a dangling key into the crossing it is, on the
1072
- * page and on Problems. Two tables of that name resolve to neither.
1132
+ * page and on Problems.
1133
+ *
1134
+ * Two tables of that name are told apart by who the referencing service
1135
+ * talks to: its own store first, then the stores of the services it shares a
1136
+ * context with or calls, or that call it. A shop's delivery service keying
1137
+ * on `orders` means the order service it fetches orders from, not the
1138
+ * `orders` of an unrelated shop that happens to be in the same estate. A
1139
+ * name that is still ambiguous after that resolves to neither.
1073
1140
  */
1074
1141
  function resolveForeignKeys(catalog: Catalog): Catalog {
1075
1142
  const stores = catalog.stores ?? [];
1076
1143
  if (stores.length === 0) return catalog;
1077
1144
 
1078
1145
  const known = new Set<string>();
1079
- const byName = new Map<string, string | null>();
1146
+ // name every table id answering to it, in store order.
1147
+ const byName = new Map<string, string[]>();
1148
+ const ownerOfTable = new Map<string, string>();
1080
1149
  for (const store of stores) {
1081
1150
  for (const table of store.tables) {
1082
1151
  known.add(table.id);
1083
- byName.set(table.name, byName.has(table.name) ? null : table.id);
1152
+ byName.set(table.name, [...(byName.get(table.name) ?? []), table.id]);
1153
+ ownerOfTable.set(table.id, store.owner);
1084
1154
  }
1085
1155
  }
1086
1156
 
1157
+ // Who a service is close to: the services of its context, the ones it
1158
+ // calls, and the ones that call it.
1159
+ const peers = new Map<string, Set<string>>();
1160
+ const near = (a: string, b: string) => {
1161
+ if (!peers.has(a)) peers.set(a, new Set());
1162
+ peers.get(a)!.add(b);
1163
+ };
1164
+ for (const ctx of catalog.contexts) {
1165
+ for (const svc of ctx.services) {
1166
+ for (const other of ctx.services) near(svc.id, other.id);
1167
+ for (const call of svc.consumes) {
1168
+ near(svc.id, call.peer);
1169
+ near(call.peer, svc.id);
1170
+ }
1171
+ }
1172
+ }
1173
+
1174
+ const resolveName = (name: string, from: string, owner: string): string | null => {
1175
+ const candidates = byName.get(name) ?? [];
1176
+ if (candidates.length === 0) return null;
1177
+ if (candidates.length === 1) return candidates[0]!;
1178
+ const own = candidates.filter((id) => id.startsWith(`${from}.`));
1179
+ if (own.length === 1) return own[0]!;
1180
+ const close = peers.get(owner) ?? new Set<string>();
1181
+ const nearby = candidates.filter((id) => close.has(ownerOfTable.get(id) ?? ""));
1182
+ return nearby.length === 1 ? nearby[0]! : null;
1183
+ };
1184
+
1087
1185
  let any = false;
1088
1186
  const resolved = stores.map((store) => {
1089
1187
  let touched = false;
@@ -1091,7 +1189,7 @@ function resolveForeignKeys(catalog: Catalog): Catalog {
1091
1189
  let changed = false;
1092
1190
  const columns = table.columns.map((column) => {
1093
1191
  if (!column.fk || known.has(column.fk.table)) return column;
1094
- const found = byName.get(column.fk.table);
1192
+ const found = resolveName(column.fk.table, store.id, store.owner);
1095
1193
  if (!found) return column;
1096
1194
  changed = true;
1097
1195
 
@@ -21,7 +21,7 @@ import {
21
21
  } from "@xyflow/react";
22
22
  import type { Edge } from "@xyflow/react";
23
23
  import "@xyflow/react/dist/style.css";
24
- import { Columns3, Eye, FileCode2, ImageDown, Maximize2, Search, Waypoints } from "lucide-react";
24
+ import { Columns3, Eye, FileCode2, ImageDown, LayoutGrid, ListFilter, Maximize2, Search, Waypoints, Workflow } from "lucide-react";
25
25
  import type { Store } from "../catalog";
26
26
  import { storeViews } from "../catalog";
27
27
  import { index } from "../data";
@@ -29,10 +29,14 @@ import { DiagramSkeleton } from "../components/DiagramSkeleton";
29
29
  import { useSelectionStore } from "../selection/store";
30
30
  import { TableNodeCard } from "./TableNode";
31
31
  import { ViewNodeCard } from "./ViewNode";
32
+ import { GroupNodeCard } from "./GroupNode";
33
+ import type { ErGroupNode } from "./GroupNode";
32
34
  import type { ErFlowNode } from "./RelationCard";
33
35
  import { EDGE_W, EDGE_W_LIT } from "../graph/theme";
34
36
  import { ErMarkers, MARKER_FLOW, MARKER_MANY, MARKER_ONE } from "./markers";
35
- import { layoutEr } from "./layout";
37
+ import { canGroup, groupsOf, hideGroups, layoutEr } from "./layout";
38
+ import type { ErGroupFrame, ErGrouping } from "./layout";
39
+ import { CTX_SLOTS } from "../lib/context-color";
36
40
  import { lineageChain } from "./lineage";
37
41
  import type { LineageMaps } from "./lineage";
38
42
  import { erSpec, matchingNodes } from "./spec";
@@ -42,10 +46,16 @@ import { saveCanvasImage, viewportOf } from "../lib/export-canvas";
42
46
  import type { ImageKind } from "../lib/export-canvas";
43
47
 
44
48
  /** Stable across renders: React Flow re-mounts every node when this changes. */
45
- const erNodeTypes = { erTable: TableNodeCard, erView: ViewNodeCard };
49
+ const erNodeTypes = { erTable: TableNodeCard, erView: ViewNodeCard, erGroup: GroupNodeCard };
50
+
51
+ /** One flow, roots first; or the cards by model group, the groups packed. */
52
+ type Arrangement = "flow" | "groups";
46
53
 
47
54
  const DIM = 0.25;
48
55
 
56
+ /** More hits than this is most of a schema; framing them is framing everything. */
57
+ const SEARCH_FRAME_LIMIT = 30;
58
+
49
59
  /** The catalog's lineage graph, walked on hover. Built once, with the index. */
50
60
  const LINEAGE: LineageMaps = {
51
61
  from: index.lineageFrom,
@@ -90,14 +100,18 @@ function Canvas({
90
100
  const [hoverEdge, setHoverEdge] = useState<string | null>(null);
91
101
  const [layout, setLayout] = useState<{
92
102
  positions: Record<string, { x: number; y: number }>;
103
+ groups: ErGroupFrame[];
93
104
  ready: boolean;
94
- }>({ positions: {}, ready: false });
105
+ }>({ positions: {}, groups: [], ready: false });
106
+ // Null until the reader chooses: a schema big enough is grouped, the rest
107
+ // flow, and the toggle only appears where the choice exists.
108
+ const [chosen, setChosen] = useState<Arrangement | null>(null);
95
109
 
96
110
  const select = useSelectionStore((s) => s.select);
97
111
  const clear = useSelectionStore((s) => s.clear);
98
112
  const selectionId = useSelectionStore((s) => s.selection?.id ?? null);
99
113
 
100
- const spec: ErSpec = useMemo(
114
+ const full: ErSpec = useMemo(
101
115
  () =>
102
116
  erSpec(index, store, {
103
117
  mode,
@@ -108,19 +122,53 @@ function Canvas({
108
122
  }),
109
123
  [store, mode, expanded, ghost, showViews, showLineage],
110
124
  );
125
+ // Groups the reader switched off. Everything below reads the spec with
126
+ // them gone, so a hidden group is hidden from the search and the layout
127
+ // too, not only from the eye.
128
+ const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
129
+ const spec: ErSpec = useMemo(() => hideGroups(full, hidden), [full, hidden]);
130
+ const nameOf = useCallback(
131
+ (aggregate: string) => index.aggregateById.get(aggregate)?.name ?? (aggregate.split(".").at(-1) ?? aggregate),
132
+ [],
133
+ );
134
+ const groupList = useMemo(() => groupsOf(full.nodes, nameOf), [full, nameOf]);
135
+ const onHide = useCallback((id: string, hide: boolean) => {
136
+ setHidden((prev) => {
137
+ const next = new Set(prev);
138
+ if (hide) next.add(id);
139
+ else next.delete(id);
140
+ return next;
141
+ });
142
+ }, []);
111
143
 
112
144
  const matched = useMemo(() => matchingNodes(spec, term), [spec, term]);
145
+ const matchedIds = useMemo(() => [...matched], [matched]);
146
+ // Which match the reader is standing on: -1 is "all of them", what typing
147
+ // gives; Enter steps through them one at a time.
148
+ const [cursor, setCursor] = useState(-1);
149
+ useEffect(() => setCursor(-1), [term]);
150
+
151
+ const groupable = useMemo(() => canGroup(full), [full]);
152
+ const arrangement: Arrangement = groupable ? (chosen ?? "groups") : "flow";
113
153
 
114
154
  useEffect(() => {
115
155
  let cancelled = false;
116
156
  setLayout((prev) => ({ ...prev, ready: false }));
117
- void layoutEr(spec).then((result) => {
118
- if (!cancelled) setLayout({ positions: result.positions, ready: true });
157
+ // The packing aims at the box the picture is drawn in; a box not yet on
158
+ // screen has no shape, and 2:1 is what the service page's canvas is.
159
+ const box = wrapper.current;
160
+ const aspectRatio = box && box.clientWidth > 0 && box.clientHeight > 0 ? box.clientWidth / box.clientHeight : 2;
161
+ void layoutEr(spec, {
162
+ grouped: arrangement === "groups",
163
+ aspectRatio,
164
+ nameOf,
165
+ }).then((result) => {
166
+ if (!cancelled) setLayout({ positions: result.positions, groups: result.groups, ready: true });
119
167
  });
120
168
  return () => {
121
169
  cancelled = true;
122
170
  };
123
- }, [spec]);
171
+ }, [spec, arrangement, nameOf]);
124
172
 
125
173
  // Which columns need an anchor, per table. A column with no relationship
126
174
  // gets no handle: React Flow measures every handle it is given, and a wide
@@ -198,6 +246,34 @@ function Canvas({
198
246
  return () => observer.disconnect();
199
247
  }, [flow, layout.ready]);
200
248
 
249
+ // A search moves the camera, not only the lights. One hit is brought up
250
+ // close; a handful are framed together; more than that is most of the
251
+ // schema, so the view stays and the dimming does the work. Clearing the box
252
+ // fits everything again, so the reader is not left zoomed into a corner.
253
+ const hadTerm = useRef(false);
254
+ useEffect(() => {
255
+ if (!layout.ready) return;
256
+ if (term.trim() === "") {
257
+ if (hadTerm.current) flow.fitView({ padding: 0.1, duration: 250 });
258
+ hadTerm.current = false;
259
+ return;
260
+ }
261
+ hadTerm.current = true;
262
+ const target = cursor >= 0 ? matchedIds.slice(cursor, cursor + 1) : matchedIds;
263
+ if (target.length === 0 || target.length > SEARCH_FRAME_LIMIT) return;
264
+ void flow.fitView({
265
+ nodes: target.map((id) => ({ id })),
266
+ padding: target.length === 1 ? 0.35 : 0.2,
267
+ maxZoom: 1.25,
268
+ duration: 250,
269
+ });
270
+ }, [flow, layout.ready, term, matchedIds, cursor]);
271
+
272
+ const onJump = useCallback(() => {
273
+ if (matchedIds.length === 0) return;
274
+ setCursor((prev) => (prev + 1) % matchedIds.length);
275
+ }, [matchedIds]);
276
+
201
277
  const onToggle = useCallback((tableId: string) => {
202
278
  setExpanded((prev) => {
203
279
  const next = new Set(prev);
@@ -219,7 +295,35 @@ function Canvas({
219
295
  : null;
220
296
  }, [selectionId]);
221
297
 
222
- const nodes: ErFlowNode[] = useMemo(
298
+ // The frames go first so that they are drawn under the cards; nothing
299
+ // about them is interactive but the label, so a click on one reaches the
300
+ // pane and clears the selection like empty canvas would.
301
+ const frames: ErGroupNode[] = useMemo(
302
+ () =>
303
+ layout.groups.map((group) => ({
304
+ id: `group:${group.id}`,
305
+ type: "erGroup" as const,
306
+ position: { x: group.x, y: group.y },
307
+ width: group.width,
308
+ height: group.height,
309
+ draggable: false,
310
+ selectable: false,
311
+ connectable: false,
312
+ focusable: false,
313
+ zIndex: -1,
314
+ data: {
315
+ name: group.name,
316
+ aggregate: group.aggregate,
317
+ count: group.count,
318
+ // By the group's place in the full list, so that hiding a
319
+ // neighbour does not recolour the rest.
320
+ tint: `var(--ctx-${Math.max(0, groupList.findIndex((g) => g.id === group.id)) % CTX_SLOTS})`,
321
+ },
322
+ })),
323
+ [layout.groups, groupList],
324
+ );
325
+
326
+ const cards: ErFlowNode[] = useMemo(
223
327
  () =>
224
328
  spec.nodes.map((node) => ({
225
329
  id: node.id,
@@ -256,6 +360,7 @@ function Canvas({
256
360
  onColumnClick,
257
361
  ],
258
362
  );
363
+ const nodes: (ErFlowNode | ErGroupNode)[] = useMemo(() => [...frames, ...cards], [frames, cards]);
259
364
 
260
365
  const edges: Edge[] = useMemo(
261
366
  () =>
@@ -315,7 +420,7 @@ function Canvas({
315
420
  [spec, layout.positions, litEdges],
316
421
  );
317
422
 
318
- const fitKey = layout.ready ? `fit-${nodes.length}-${mode}` : "pending";
423
+ const fitKey = layout.ready ? `fit-${nodes.length}-${mode}-${arrangement}` : "pending";
319
424
  const views = storeViews(store).length;
320
425
 
321
426
  return (
@@ -334,6 +439,13 @@ function Canvas({
334
439
  mode={mode}
335
440
  onMode={setMode}
336
441
  hits={matched.size}
442
+ cursor={cursor}
443
+ onJump={onJump}
444
+ arrangement={groupable ? arrangement : null}
445
+ onArrangement={setChosen}
446
+ groups={groupable ? groupList : []}
447
+ hidden={hidden}
448
+ onHide={onHide}
337
449
  views={views}
338
450
  showViews={showViews}
339
451
  onShowViews={setShowViews}
@@ -348,7 +460,9 @@ function Canvas({
348
460
  nodes={nodes}
349
461
  edges={edges}
350
462
  nodeTypes={erNodeTypes}
351
- onNodeClick={(_, node) => select(node.id, "diagram")}
463
+ onNodeClick={(_, node) => {
464
+ if (node.type !== "erGroup") select(node.id, "diagram");
465
+ }}
352
466
  onPaneClick={() => clear("diagram")}
353
467
  onEdgeMouseEnter={(_, edge) => setHoverEdge(edge.id)}
354
468
  onEdgeMouseLeave={() => setHoverEdge(null)}
@@ -377,6 +491,13 @@ function Toolbar({
377
491
  mode,
378
492
  onMode,
379
493
  hits,
494
+ cursor,
495
+ onJump,
496
+ arrangement,
497
+ onArrangement,
498
+ groups,
499
+ hidden,
500
+ onHide,
380
501
  views,
381
502
  showViews,
382
503
  onShowViews,
@@ -390,6 +511,16 @@ function Toolbar({
390
511
  mode: ColumnMode;
391
512
  onMode: (value: ColumnMode) => void;
392
513
  hits: number;
514
+ /** The hit the reader stepped to with Enter, -1 for none yet. */
515
+ cursor: number;
516
+ onJump: () => void;
517
+ /** How the cards are laid out; null when the schema is too small for the choice to matter. */
518
+ arrangement: Arrangement | null;
519
+ onArrangement: (value: Arrangement) => void;
520
+ /** The model groups a reader can switch off; empty when the schema is too small to bother. */
521
+ groups: ErGrouping[];
522
+ hidden: ReadonlySet<string>;
523
+ onHide: (id: string, hide: boolean) => void;
393
524
  /** How many views this store has; with none, the toggle is not a choice. */
394
525
  views: number;
395
526
  showViews: boolean;
@@ -428,13 +559,27 @@ function Toolbar({
428
559
  <input
429
560
  value={term}
430
561
  onChange={(e) => onTerm(e.target.value)}
562
+ onKeyDown={(e) => {
563
+ // Enter walks the hits, best first; Escape lets go of the search
564
+ // and the camera with it.
565
+ if (e.key === "Enter") {
566
+ e.preventDefault();
567
+ onJump();
568
+ } else if (e.key === "Escape") {
569
+ e.preventDefault();
570
+ onTerm("");
571
+ }
572
+ }}
431
573
  placeholder="find a table or column"
432
574
  spellCheck={false}
433
575
  aria-label="Find a table or column"
576
+ title="Enter steps through the hits, Escape clears"
434
577
  className="mono w-44 bg-transparent outline-none placeholder:text-muted"
435
578
  />
436
579
  {term ? (
437
- <span className="mono tnum shrink-0 text-muted">{hits}</span>
580
+ <span className="mono tnum shrink-0 text-muted" aria-live="polite">
581
+ {cursor >= 0 ? `${cursor + 1}/${hits}` : hits}
582
+ </span>
438
583
  ) : null}
439
584
  </label>
440
585
 
@@ -459,6 +604,67 @@ function Toolbar({
459
604
  </button>
460
605
  </div>
461
606
 
607
+ {/* Only a schema big enough to come out as a column offers the choice;
608
+ on a small one the flow is the picture and a frame would be noise. */}
609
+ {arrangement ? (
610
+ <div className="seg bg-canvas" role="group" aria-label="Arrangement">
611
+ <button
612
+ type="button"
613
+ onClick={() => onArrangement("flow")}
614
+ aria-pressed={arrangement === "flow"}
615
+ className={arrangement === "flow" ? "is-on" : ""}
616
+ title="One flow, roots on the left"
617
+ >
618
+ <Workflow size={11} aria-hidden className="inline" /> flow
619
+ </button>
620
+ <button
621
+ type="button"
622
+ onClick={() => onArrangement("groups")}
623
+ aria-pressed={arrangement === "groups"}
624
+ className={arrangement === "groups" ? "is-on" : ""}
625
+ title="Tables by the model group they persist, groups packed to the canvas"
626
+ >
627
+ <LayoutGrid size={11} aria-hidden className="inline" /> groups
628
+ </button>
629
+ </div>
630
+ ) : null}
631
+
632
+ {/* Which groups are on the canvas. A big schema is read one module at a
633
+ time, and a module is easier to read with its neighbours gone. */}
634
+ {groups.length > 0 ? (
635
+ <details className="relative">
636
+ <summary
637
+ className="seg mono cursor-pointer list-none bg-canvas px-2 py-1 text-muted hover:bg-surface"
638
+ title="Show or hide model groups"
639
+ >
640
+ <ListFilter size={11} aria-hidden className="inline" /> {groups.length - hidden.size}/{groups.length}
641
+ </summary>
642
+ <div className="absolute left-0 top-full z-20 mt-1 max-h-72 w-64 overflow-y-auto rounded-card border border-line bg-surface p-1 shadow-card">
643
+ <div className="flex gap-2 px-2 py-1">
644
+ <button type="button" className="mono text-accent hover:underline" onClick={() => groups.forEach((g) => onHide(g.id, false))}>
645
+ all
646
+ </button>
647
+ <button type="button" className="mono text-accent hover:underline" onClick={() => groups.forEach((g) => onHide(g.id, true))}>
648
+ none
649
+ </button>
650
+ </div>
651
+ {groups.map((group, i) => (
652
+ <label key={group.id} className="flex cursor-pointer items-center gap-2 rounded-control px-2 py-1 hover:bg-canvas">
653
+ <input
654
+ type="checkbox"
655
+ checked={!hidden.has(group.id)}
656
+ onChange={(e) => onHide(group.id, !e.target.checked)}
657
+ aria-label={`Show ${group.name}`}
658
+ />
659
+ <span className="dot" style={{ color: `var(--ctx-${i % CTX_SLOTS})` }} />
660
+ <span className="mono min-w-0 flex-1 truncate">{group.name}</span>
661
+ <span className="mono tnum text-muted">{group.nodes.length}</span>
662
+ </label>
663
+ ))}
664
+ </div>
665
+ </details>
666
+ ) : null}
667
+
462
668
  {/* Two toggles rather than one: a reader who wants the tables alone and a
463
669
  reader who wants the views without the web of lines that joins them
464
670
  are asking different questions, and answering both with one switch
@@ -0,0 +1,57 @@
1
+ // The frame around one model group's tables, when a schema is laid out by
2
+ // group. It is a background, not a card: it takes no clicks, so the pane
3
+ // behind it still clears the selection, and the cards inside it are drawn on
4
+ // top. Only its label is live, and leads to the group's own page.
5
+ //
6
+ // Each frame has a tint of its own, cycled through the same six hues the
7
+ // contexts use, so that two neighbouring groups read as two at a glance and
8
+ // the label is not the only thing telling them apart.
9
+
10
+ import type { Node, NodeProps } from "@xyflow/react";
11
+ import { Link } from "react-router";
12
+ import { aggregatePath } from "../routes";
13
+
14
+ export interface GroupNodeData extends Record<string, unknown> {
15
+ name: string;
16
+ aggregate: string | null;
17
+ count: number;
18
+ /** A CSS colour the frame is washed with. */
19
+ tint: string;
20
+ }
21
+
22
+ export type ErGroupNode = Node<GroupNodeData, "erGroup">;
23
+
24
+ export function GroupNodeCard({ data, width, height }: NodeProps<ErGroupNode>) {
25
+ const to = data.aggregate ? aggregatePath(data.aggregate) : null;
26
+ const label = `${data.name} · ${data.count}`;
27
+ return (
28
+ <div
29
+ className="rounded-card border"
30
+ style={{
31
+ width,
32
+ height,
33
+ background: `color-mix(in srgb, ${data.tint} 9%, transparent)`,
34
+ borderColor: `color-mix(in srgb, ${data.tint} 45%, transparent)`,
35
+ pointerEvents: "none",
36
+ }}
37
+ data-er-group={data.aggregate ?? "other"}
38
+ >
39
+ <div
40
+ className="mono inline-block rounded-br-card px-2 py-0.5"
41
+ style={{
42
+ pointerEvents: "auto",
43
+ color: data.tint,
44
+ background: `color-mix(in srgb, ${data.tint} 14%, transparent)`,
45
+ }}
46
+ >
47
+ {to ? (
48
+ <Link to={to} className="hover:underline" title={`Open ${data.name}`}>
49
+ {label}
50
+ </Link>
51
+ ) : (
52
+ <span title="tables that persist no model">{label}</span>
53
+ )}
54
+ </div>
55
+ </div>
56
+ );
57
+ }
@@ -31,6 +31,7 @@ export const STORE_KIND_LABEL: Record<StoreKind, string> = {
31
31
  mongodb: "MongoDB",
32
32
  clickhouse: "ClickHouse",
33
33
  s3: "S3",
34
+ dynamodb: "DynamoDB",
34
35
  other: "store",
35
36
  };
36
37