@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.
- package/README.md +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
1192
|
+
const found = resolveName(column.fk.table, store.id, store.owner);
|
|
1095
1193
|
if (!found) return column;
|
|
1096
1194
|
changed = true;
|
|
1097
1195
|
|
package/src/er/ErCanvas.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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) =>
|
|
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">
|
|
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
|
+
}
|