@shortlink-org/portolan 0.2.4 → 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 (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
package/src/er/layout.ts CHANGED
@@ -11,14 +11,38 @@
11
11
  // reads: source on the left, the view computed from it on the right. Reversing
12
12
  // it too would put every view before the tables it reads, which is the one
13
13
  // arrangement that makes a lineage picture unreadable.
14
+ //
15
+ // A big schema is laid out in two steps rather than one. A single layered
16
+ // pass over a hundred-odd tables comes out as a column: the graph is shallow
17
+ // and wide, one layer holds fifty cards, and no direction fixes that -
18
+ // measured on a 138-table schema, left-to-right, top-down and every wrapping
19
+ // option elk has all fit the canvas at a tenth of scale. What the tables do
20
+ // have is the model group each one persists, so the cards of one group are
21
+ // laid out among themselves and the groups are packed as rectangles to the
22
+ // canvas's own proportions, which fits the same schema at nearly three times
23
+ // the scale and reads by module rather than as one sheet.
24
+
25
+ import { layoutGroupsWithElk, layoutWithElk } from "../graph/elk";
26
+ import type { ErNode, ErSpec } from "./spec";
14
27
 
15
- import { layoutWithElk } from "../graph/elk";
16
- import type { ErSpec } from "./spec";
28
+ export interface ErGroupFrame {
29
+ id: string;
30
+ name: string;
31
+ /** The aggregate the group is, or null for the tables that persist none. */
32
+ aggregate: string | null;
33
+ count: number;
34
+ x: number;
35
+ y: number;
36
+ width: number;
37
+ height: number;
38
+ }
17
39
 
18
40
  export interface ErLayout {
19
41
  positions: Record<string, { x: number; y: number }>;
20
42
  width: number;
21
43
  height: number;
44
+ /** Empty when the layout is one flow. */
45
+ groups: ErGroupFrame[];
22
46
  }
23
47
 
24
48
  export interface ErLayoutInput {
@@ -57,11 +81,122 @@ export function layoutInput(spec: ErSpec): ErLayoutInput {
57
81
  };
58
82
  }
59
83
 
84
+ /** Fewer tables than this is a schema one layered pass draws fine. */
85
+ export const GROUP_MIN_TABLES = 40;
86
+ /** Fewer model groups than this and grouping would draw one big frame. */
87
+ export const GROUP_MIN_GROUPS = 3;
88
+
89
+ /** What every table that persists no model is grouped under. */
90
+ export const UNGROUPED = "other";
91
+
92
+ export interface ErGrouping {
93
+ id: string;
94
+ name: string;
95
+ aggregate: string | null;
96
+ nodes: string[];
97
+ }
98
+
99
+ /**
100
+ * The cards by the model group they persist, biggest group first and the
101
+ * ungrouped rest last - which is the order the packing takes them in, and
102
+ * big-first is what packs tightest.
103
+ */
104
+ export function groupsOf(
105
+ nodes: readonly Pick<ErNode, "id" | "aggregate">[],
106
+ nameOf: (aggregate: string) => string,
107
+ ): ErGrouping[] {
108
+ const byAggregate = new Map<string | null, string[]>();
109
+ for (const node of nodes) {
110
+ const key = node.aggregate;
111
+ byAggregate.set(key, [...(byAggregate.get(key) ?? []), node.id]);
112
+ }
113
+ const groups: ErGrouping[] = [];
114
+ for (const [aggregate, ids] of byAggregate) {
115
+ groups.push({
116
+ id: aggregate ?? UNGROUPED,
117
+ name: aggregate ? nameOf(aggregate) : UNGROUPED,
118
+ aggregate,
119
+ nodes: ids,
120
+ });
121
+ }
122
+ groups.sort((a, b) => {
123
+ if (a.aggregate === null) return 1;
124
+ if (b.aggregate === null) return -1;
125
+ return b.nodes.length - a.nodes.length || a.id.localeCompare(b.id);
126
+ });
127
+ return groups;
128
+ }
129
+
130
+ /** The group a card belongs to: its aggregate, or the ungrouped rest. */
131
+ export function groupKey(node: Pick<ErNode, "aggregate">): string {
132
+ return node.aggregate ?? UNGROUPED;
133
+ }
134
+
135
+ /**
136
+ * The spec without the groups a reader hid: their cards gone, and with them
137
+ * every edge that touched one. What is left lays out and searches like a
138
+ * smaller schema, which is the point of hiding.
139
+ */
140
+ export function hideGroups(spec: ErSpec, hidden: ReadonlySet<string>): ErSpec {
141
+ if (hidden.size === 0) return spec;
142
+ const nodes = spec.nodes.filter((n) => !hidden.has(groupKey(n)));
143
+ const kept = new Set(nodes.map((n) => n.id));
144
+ return {
145
+ nodes,
146
+ edges: spec.edges.filter((e) => kept.has(e.from) && kept.has(e.to)),
147
+ };
148
+ }
149
+
150
+ /** Whether a schema is big enough, and grouped enough, for the two-step layout to help. */
151
+ export function canGroup(spec: ErSpec): boolean {
152
+ if (spec.nodes.length < GROUP_MIN_TABLES) return false;
153
+ const aggregates = new Set(spec.nodes.map((n) => n.aggregate).filter((a): a is string => a !== null));
154
+ return aggregates.size >= GROUP_MIN_GROUPS;
155
+ }
156
+
157
+ export interface ErLayoutOptions {
158
+ /** Lay the cards out by model group and pack the groups. */
159
+ grouped?: boolean;
160
+ /** Width over height of the box the picture is for; the packing aims at it. */
161
+ aspectRatio?: number;
162
+ /** What a group is called on its frame, by the aggregate it is. */
163
+ nameOf?: (aggregate: string) => string;
164
+ }
165
+
60
166
  /** Lays a spec out left to right, roots first. Falls back to a column on failure. */
61
- export async function layoutEr(spec: ErSpec): Promise<ErLayout> {
167
+ export async function layoutEr(spec: ErSpec, options: ErLayoutOptions = {}): Promise<ErLayout> {
62
168
  const input = layoutInput(spec);
63
169
  if (input.nodes.length === 0) {
64
- return { positions: {}, width: 0, height: 0 };
170
+ return { positions: {}, width: 0, height: 0, groups: [] };
171
+ }
172
+
173
+ if (options.grouped) {
174
+ const nameOf = options.nameOf ?? ((aggregate: string) => aggregate.split(".").at(-1) ?? aggregate);
175
+ const groups = groupsOf(spec.nodes, nameOf);
176
+ const groupOf = new Map<string, string>();
177
+ for (const group of groups) {
178
+ for (const id of group.nodes) groupOf.set(id, group.id);
179
+ }
180
+ const sizes = new Map(input.nodes.map((n) => [n.id, n]));
181
+ const result = await layoutGroupsWithElk({
182
+ groups: groups.map((group) => ({
183
+ id: group.id,
184
+ nodes: group.nodes.map((id) => sizes.get(id)).filter((n): n is NonNullable<typeof n> => n !== undefined),
185
+ edges: input.edges.filter((e) => groupOf.get(e.source) === group.id && groupOf.get(e.target) === group.id),
186
+ })),
187
+ aspectRatio: options.aspectRatio ?? 2,
188
+ layerSpacing: 120,
189
+ nodeSpacing: 24,
190
+ });
191
+ return {
192
+ positions: result.positions,
193
+ width: result.width,
194
+ height: result.height,
195
+ groups: groups.map((group) => {
196
+ const frame = result.frames[group.id] ?? { x: 0, y: 0, width: 0, height: 0 };
197
+ return { id: group.id, name: group.name, aggregate: group.aggregate, count: group.nodes.length, ...frame };
198
+ }),
199
+ };
65
200
  }
66
201
 
67
202
  const { positions, width, height } = await layoutWithElk({
@@ -73,5 +208,5 @@ export async function layoutEr(spec: ErSpec): Promise<ErLayout> {
73
208
  layerSpacing: 170,
74
209
  nodeSpacing: 28,
75
210
  });
76
- return { positions, width, height };
211
+ return { positions, width, height, groups: [] };
77
212
  }
@@ -294,6 +294,23 @@ describe("matchingNodes", () => {
294
294
  it("matches nothing on an empty term", () => {
295
295
  expect(matchingNodes(spec, " ").size).toBe(0);
296
296
  });
297
+
298
+ it("puts the best hit first: the table named so, then names, then columns and SQL", () => {
299
+ // `orders` is the table's own name; the two views contain it in theirs
300
+ // and come after, in the order they are drawn.
301
+ expect([...matchingNodes(spec, "orders")]).toEqual([
302
+ "shop.oms.pg.orders",
303
+ "shop.oms.pg.v_open_orders",
304
+ "shop.oms.pg.mv_orders_daily",
305
+ ]);
306
+ // A hit by name outranks a hit by column: `order_items` is named for
307
+ // orders, `outbox` only keys on one.
308
+ const byOrder = [...matchingNodes(spec, "order")];
309
+ expect(byOrder.indexOf("shop.oms.pg.order_items")).toBeLessThan(
310
+ byOrder.indexOf("shop.oms.pg.outbox") < 0 ? Infinity : byOrder.indexOf("shop.oms.pg.outbox"),
311
+ );
312
+ expect(byOrder[0]).toBe("shop.oms.pg.orders");
313
+ });
297
314
  });
298
315
 
299
316
  describe("the pathological store", () => {
package/src/er/spec.ts CHANGED
@@ -407,20 +407,33 @@ export function outboundLineage(
407
407
  /**
408
408
  * Cards matching a search term, by name, column name, or the aggregate they
409
409
  * hold. Returns ids so the canvas can light them without re-deriving anything.
410
+ *
411
+ * The set is ordered, best match first, because the canvas jumps to the first
412
+ * one and cycles from there: a card whose name IS the term, then cards whose
413
+ * name contains it, then the aggregate, then a column, the doc or the SQL.
414
+ * Within a rank, the order the spec lists them in - which is the order they
415
+ * are drawn in.
410
416
  */
411
417
  export function matchingNodes(spec: ErSpec, term: string): Set<string> {
412
418
  const needle = term.trim().toLowerCase();
413
419
  if (!needle) return new Set();
414
- const out = new Set<string>();
420
+ const ranked: { id: string; rank: number }[] = [];
415
421
  for (const node of spec.nodes) {
416
- const hay = [
417
- node.name,
418
- node.aggregate ?? "",
419
- node.doc ?? "",
420
- node.view?.definition ?? "",
421
- ...node.columns.map((c) => c.name),
422
- ];
423
- if (hay.some((h) => h.toLowerCase().includes(needle))) out.add(node.id);
422
+ const name = node.name.toLowerCase();
423
+ const rank =
424
+ name === needle
425
+ ? 0
426
+ : name.includes(needle)
427
+ ? 1
428
+ : (node.aggregate ?? "").toLowerCase().includes(needle)
429
+ ? 2
430
+ : [node.doc ?? "", node.view?.definition ?? "", ...node.columns.map((c) => c.name)].some(
431
+ (h) => h.toLowerCase().includes(needle),
432
+ )
433
+ ? 3
434
+ : -1;
435
+ if (rank >= 0) ranked.push({ id: node.id, rank });
424
436
  }
425
- return out;
437
+ ranked.sort((a, b) => a.rank - b.rank);
438
+ return new Set(ranked.map((r) => r.id));
426
439
  }
@@ -0,0 +1,52 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { MemoryRouter } from "react-router";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import { rawCatalog as raw } from "../test-catalog";
7
+ import type { Catalog, Flow } from "../catalog";
8
+ import { RecordingsChip } from "./Recordings";
9
+
10
+ const catalog = raw as unknown as Catalog;
11
+ const checkout = catalog.flows.find((f) => f.slug === "checkout") as Flow;
12
+
13
+ /**
14
+ * Rendered once, statically: no local server answers here, so the chip is
15
+ * what a published site shows - the recordings the catalog carries, or
16
+ * nothing.
17
+ */
18
+ function render(flow: Flow, exampleId: string | null = null) {
19
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
20
+ return renderToStaticMarkup(
21
+ <QueryClientProvider client={client}>
22
+ <MemoryRouter>
23
+ <RecordingsChip flow={flow} exampleId={exampleId} onExample={() => {}} />
24
+ </MemoryRouter>
25
+ </QueryClientProvider>,
26
+ );
27
+ }
28
+
29
+ const example = (traceId: string) => ({
30
+ id: `examples/shop/telemetry/traces.jsonl#${traceId}`,
31
+ recording: "examples/shop/telemetry/traces.jsonl",
32
+ traceId,
33
+ durationMs: 12.5,
34
+ steps: [{ step: "s1", durationMs: 12.5 }],
35
+ });
36
+
37
+ describe("the recordings chip", () => {
38
+ it("counts the flow's recordings and says which one is lit", () => {
39
+ const flow = { ...checkout, examples: [example("aaaaaaaa1"), example("bbbbbbbb2")] };
40
+ const markup = render(flow);
41
+ expect(markup).toContain("recordings · 2");
42
+ expect(markup).toContain("Recordings — 2 recordings");
43
+
44
+ const lit = render(flow, flow.examples[1]!.id);
45
+ expect(lit).toContain("lighting #bbbbbbbb");
46
+ expect(lit).toContain("border-accent");
47
+ });
48
+
49
+ it("is not on the page at all when there is nothing to show and nowhere to add", () => {
50
+ expect(render({ ...checkout, examples: undefined })).toBe("");
51
+ });
52
+ });
@@ -0,0 +1,236 @@
1
+ // The recordings a flow has been seen running in, and the way to add one.
2
+ //
3
+ // A recording is an example of the flow: which steps ran, how long each took,
4
+ // what the spans were called. It is evidence, so it sits in the evidence row
5
+ // as a chip - "recordings · 2" - and opens behind it, the way the catalog
6
+ // stamp does: the rail is for the steps, and a list that is read once and
7
+ // then left alone should not take a row of it for good. Choosing a recording
8
+ // lights the steps it showed on the picture; the step's own panel says what
9
+ // the span carried. In local mode the same panel takes a new recording, runs
10
+ // the generator over a copy with it in place, and shows what changed before
11
+ // anything is written.
12
+
13
+ import { useRef, useState } from "react";
14
+ import { useQuery } from "@tanstack/react-query";
15
+ import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
16
+ import { Disc3, LoaderCircle, Upload } from "lucide-react";
17
+ import { Link } from "react-router";
18
+ import type { Flow, FlowExample } from "../catalog";
19
+ import { Modal } from "../components/Overlay";
20
+ import { useToastStore } from "../app/toast";
21
+ import { plural, relativeTime } from "../lib/format";
22
+ import { startTraceTrial } from "../lib/local-api";
23
+ import { localStatusQuery } from "../lib/queries";
24
+ import { projectForFlow, projectsForFlow } from "../lib/trace-project";
25
+ import { paths } from "../routes";
26
+ import { formatMs, stepsShownBy } from "./examples";
27
+ import { TraceTrialPanel } from "./TraceTrial";
28
+ import { forgetTraceTrial, recallTraceTrial, rememberTraceTrial, sessionStore } from "./trace-trial-resume";
29
+
30
+ function shortRecording(recording: string): string {
31
+ const parts = recording.split("/");
32
+ return parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : recording;
33
+ }
34
+
35
+ export function RecordingsChip({
36
+ flow,
37
+ exampleId,
38
+ onExample,
39
+ }: {
40
+ flow: Flow;
41
+ /** The example lit on the picture, or null. */
42
+ exampleId: string | null;
43
+ onExample: (id: string | null) => void;
44
+ }) {
45
+ const status = useQuery(localStatusQuery());
46
+ const local = status.isSuccess;
47
+ const projects = status.data?.setup.projects ?? [];
48
+ /**
49
+ * A run the page did not start, or lost: keeping a recording writes
50
+ * portolan.json, which restarts the dev server and reloads the page
51
+ * under the dialog. The run goes on; the page says so.
52
+ */
53
+ const activeRun = status.data?.activeRun ?? null;
54
+ const say = useToastStore((s) => s.say);
55
+ const input = useRef<HTMLInputElement | null>(null);
56
+ // Picked back up from before a reload, when this page was watching one:
57
+ // keeping a recording reloads the page twice, and the dialog outlives it.
58
+ const [resumed] = useState(() => recallTraceTrial(sessionStore(), flow.slug));
59
+ const [runId, setRunId] = useState<string | null>(resumed?.runId ?? null);
60
+ const [busy, setBusy] = useState(false);
61
+ const watch = (id: string) => {
62
+ rememberTraceTrial(sessionStore(), flow.slug, { runId: id, writeRunId: null });
63
+ setRunId(id);
64
+ };
65
+ const done = () => {
66
+ forgetTraceTrial(sessionStore());
67
+ setRunId(null);
68
+ };
69
+ const examples = flow.examples ?? [];
70
+ const project = local ? projectForFlow(projects, flow) : null;
71
+ const candidates = local ? projectsForFlow(projects, flow) : [];
72
+ const [chosenProject, setChosenProject] = useState<string>("");
73
+ const target = project?.id ?? chosenProject;
74
+ const lit = exampleId ? examples.find((e) => e.id === exampleId) : undefined;
75
+
76
+ async function upload(file: File) {
77
+ if (!target) return;
78
+ setBusy(true);
79
+ try {
80
+ const started = await startTraceTrial(file, target);
81
+ watch(started.runId);
82
+ } catch (cause) {
83
+ say(cause instanceof Error ? cause.message : String(cause));
84
+ } finally {
85
+ setBusy(false);
86
+ if (input.current) input.current.value = "";
87
+ }
88
+ }
89
+
90
+ if (examples.length === 0 && !local) return null;
91
+
92
+ const summary = examples.length
93
+ ? `${examples.length} ${plural(examples.length, "recording")}${lit ? ` · lighting #${lit.traceId.slice(0, 8)}` : ""}`
94
+ : "no recordings yet";
95
+
96
+ return (
97
+ <>
98
+ <Popover className="relative">
99
+ <PopoverButton
100
+ title={`Recordings this flow was seen running in — ${summary}`}
101
+ aria-label={`Recordings — ${summary}`}
102
+ className={({ open }) =>
103
+ `chip transition-colors focus:outline-none ${open || lit ? "border-accent text-accent" : "border-line text-muted hover:text-ink"}`
104
+ }
105
+ >
106
+ <Disc3 size={11} aria-hidden />
107
+ {examples.length ? `recordings · ${examples.length}` : "add recording"}
108
+ {activeRun && runId === null ? <LoaderCircle size={11} className="animate-spin text-accent" aria-hidden /> : null}
109
+ </PopoverButton>
110
+ <PopoverPanel
111
+ anchor={{ to: "bottom start", gap: 4, padding: 8 }}
112
+ className="palette-in z-50 w-[30rem] max-w-[92vw] rounded-control border bg-canvas p-2 border-line-strong shadow-md focus:outline-none"
113
+ >
114
+ <div className="flex items-center gap-2 px-1">
115
+ <span className="label text-faint">recordings</span>
116
+ <span className="mono text-muted">{examples.length}</span>
117
+ {activeRun && runId === null ? (
118
+ <Link to={paths.settings()} className="mono ml-auto flex items-center gap-1 text-muted hover:text-ink" title="A generator run is in progress; the page reloads when it has written">
119
+ <LoaderCircle size={12} className="animate-spin text-accent" aria-hidden /> {activeRun.mode === "write" ? "regenerating…" : "run in progress…"}
120
+ </Link>
121
+ ) : local ? (
122
+ <span className="ml-auto flex items-center gap-1.5">
123
+ {candidates.length > 1 && !project ? (
124
+ <select
125
+ className="mono rounded-control border border-line bg-canvas px-1 py-0.5 text-ink"
126
+ value={chosenProject}
127
+ onChange={(e) => setChosenProject(e.target.value)}
128
+ aria-label="Project to keep the recording under"
129
+ >
130
+ <option value="">project…</option>
131
+ {candidates.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
132
+ </select>
133
+ ) : null}
134
+ <button
135
+ type="button"
136
+ className="tbtn"
137
+ disabled={busy || !target}
138
+ title={target ? `Upload an OTLP JSON recording; it is kept under ${target} after you review what it shows` : candidates.length ? "Choose the project the recording belongs to" : "No project in this flow's context to keep a recording under"}
139
+ onClick={() => input.current?.click()}
140
+ >
141
+ <Upload size={13} aria-hidden /> add
142
+ </button>
143
+ </span>
144
+ ) : null}
145
+ </div>
146
+
147
+ {examples.length ? (
148
+ <ul className="mt-1.5 flex max-h-72 flex-col gap-0.5 overflow-y-auto">
149
+ {examples.map((example) => (
150
+ <ExampleRow
151
+ key={example.id}
152
+ example={example}
153
+ on={exampleId === example.id}
154
+ onToggle={() => onExample(exampleId === example.id ? null : example.id)}
155
+ />
156
+ ))}
157
+ </ul>
158
+ ) : local ? (
159
+ <p className="mt-1.5 px-1 text-muted">
160
+ No recording shows this flow yet. Add an OTLP JSON export of a trace that runs through it, or see <Link to={paths.settingsRecordings()} className="text-accent hover:underline">recordings</Link> in settings.
161
+ </p>
162
+ ) : null}
163
+ {examples.length ? (
164
+ <p className="mt-1.5 px-1 text-faint">Choose one to light the steps it showed on the picture; a step's panel says what its span carried.</p>
165
+ ) : null}
166
+ </PopoverPanel>
167
+ </Popover>
168
+
169
+ {/* Outside the panel, which is gone the moment it closes: the file
170
+ input the button clicks and the dialog the upload opens must outlive
171
+ the popover. */}
172
+ {local ? (
173
+ <input
174
+ ref={input}
175
+ type="file"
176
+ accept=".jsonl,.json,.ndjson,application/json"
177
+ className="hidden"
178
+ onChange={(e) => { const file = e.target.files?.[0]; if (file) void upload(file); }}
179
+ />
180
+ ) : null}
181
+
182
+ <Modal open={runId !== null} onClose={() => {}} label="Recording" width="min(760px,94vw)">
183
+ <div className="flex items-center gap-3 border-b border-line px-5 py-4">
184
+ <div className="flex-1">
185
+ <div className="font-semibold text-ink">Recording of {flow.name}</div>
186
+ <div className="mono mt-0.5 text-muted">read against the catalog, nothing written yet</div>
187
+ </div>
188
+ </div>
189
+ <div className="min-h-0 flex-1 overflow-y-auto p-5">
190
+ {runId ? (
191
+ /* Keyed by the run: a dialog closing on one run and opening on
192
+ the next within the exit animation must not hand the next run
193
+ the state of the one before. */
194
+ <TraceTrialPanel
195
+ key={runId}
196
+ runId={runId}
197
+ initialWriteRunId={resumed?.runId === runId ? resumed.writeRunId : null}
198
+ onWriteStarted={(writeRunId) => rememberTraceTrial(sessionStore(), flow.slug, { runId, writeRunId })}
199
+ onDone={done}
200
+ />
201
+ ) : null}
202
+ </div>
203
+ </Modal>
204
+ </>
205
+ );
206
+ }
207
+
208
+ function ExampleRow({
209
+ example,
210
+ on,
211
+ onToggle,
212
+ }: {
213
+ example: FlowExample;
214
+ on: boolean;
215
+ onToggle: () => void;
216
+ }) {
217
+ return (
218
+ <li>
219
+ <button
220
+ type="button"
221
+ aria-pressed={on}
222
+ onClick={onToggle}
223
+ className={`flex w-full flex-wrap items-baseline gap-x-2 rounded-control px-1.5 py-1 text-left hover:bg-surface ${on ? "bg-surface" : ""}`}
224
+ title={`${example.recording} · trace ${example.traceId}`}
225
+ >
226
+ <span className="mono truncate text-ink">{shortRecording(example.recording)}</span>
227
+ <span className="mono text-faint">#{example.traceId.slice(0, 8)}</span>
228
+ <span className="mono ml-auto text-muted">
229
+ {stepsShownBy(example).length} {plural(stepsShownBy(example).length, "step")}
230
+ {example.durationMs ? ` · ${formatMs(example.durationMs)}` : ""}
231
+ {example.recordedAt ? ` · ${relativeTime(example.recordedAt)}` : ""}
232
+ </span>
233
+ </button>
234
+ </li>
235
+ );
236
+ }
@@ -1,3 +1,7 @@
1
+ import { RelationEvidencePanel } from "../components/RelationEvidence";
2
+ import { stepRelationEvidence } from "./evidence";
3
+ import { exampleRowsFor, formatMs } from "./examples";
4
+ import { HTTPDestinationEvidence } from "../components/HTTPDestinationEvidence";
1
5
  import { useMemo } from "react";
2
6
  import { Link } from "react-router";
3
7
  import { AlertTriangle, FileCode2 } from "lucide-react";
@@ -190,6 +194,8 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
190
194
  </div>
191
195
  </header>
192
196
 
197
+ {step.destination ? <HTTPDestinationEvidence destination={step.destination} /> : null}
198
+
193
199
  <DetailSection title="Contract">
194
200
  <dl className="grid grid-cols-[minmax(7rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-2">
195
201
  {answer ? (
@@ -712,12 +718,65 @@ function StoreCallDetail({ step, flow }: { step: Step; flow: Flow }) {
712
718
  );
713
719
  }
714
720
 
721
+ /**
722
+ * What the recordings said about this step: each trace that showed it, how
723
+ * long the span took, and the names it carried. Examples of the step running,
724
+ * next to the step - the recording itself is on the flow page.
725
+ */
726
+ function StepExamples({ step, flow }: { step: Step; flow: Flow }) {
727
+ const rows = exampleRowsFor(flow, step);
728
+ if (!flow.examples?.length) return null;
729
+
730
+ return (
731
+ <>
732
+ <Label>
733
+ Recorded{step.seen ? ` · seen in ${step.seen.traces} ${step.seen.traces === 1 ? "trace" : "traces"}` : ""}
734
+ </Label>
735
+ {rows.length === 0 ? (
736
+ <div className="mono text-muted">not shown in any recording kept as an example</div>
737
+ ) : (
738
+ <div className="flex flex-col gap-1.5">
739
+ {rows.map(({ example, shown }) => {
740
+ const attrs = Object.entries(shown.attributes ?? {}).sort(([a], [b]) => a.localeCompare(b));
741
+ return (
742
+ <div key={example.id} className="border px-2 py-1.5 border-line">
743
+ <div className="mono flex flex-wrap items-baseline gap-x-2 text-muted">
744
+ <span className="text-ink" title={example.recording}>{example.traceId}</span>
745
+ {shown.durationMs ? <span>{formatMs(shown.durationMs)}</span> : null}
746
+ {example.recordedAt ? <span>{example.recordedAt.slice(0, 19).replace("T", " ")}Z</span> : null}
747
+ </div>
748
+ {shown.label && shown.label !== step.label ? <div className="mono mt-0.5 text-muted">{shown.label}</div> : null}
749
+ {attrs.length ? (
750
+ /* One attribute per line, the key above its value, both
751
+ allowed to wrap: the panel is narrow and a route or a
752
+ destination is long, and a value cut off with an
753
+ ellipsis is a value the reader has to hover for. */
754
+ <dl className="mono mt-1 flex flex-col gap-y-1">
755
+ {attrs.map(([key, value]) => (
756
+ <div key={key} className="min-w-0">
757
+ <dt className="text-faint">{key}</dt>
758
+ <dd className="break-all whitespace-normal text-ink">{value}</dd>
759
+ </div>
760
+ ))}
761
+ </dl>
762
+ ) : null}
763
+ </div>
764
+ );
765
+ })}
766
+ </div>
767
+ )}
768
+ </>
769
+ );
770
+ }
771
+
715
772
  export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
716
773
  const decisions = step.ref ? (index.adrsByEvent.get(step.ref) ?? []) : [];
717
774
 
718
775
  return (
719
776
  <>
720
777
  <ExecutionContext step={step} flow={flow} />
778
+ <RelationEvidencePanel items={stepRelationEvidence(index, step)} renderSource={(where) => <SourceWhere where={where} flow={flow} structured />} />
779
+ <StepExamples step={step} flow={flow} />
721
780
 
722
781
  {/* A decision that names this step's event is the reason the step
723
782
  looks the way it does. It belongs next to the step, not three