@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/merge.test.ts CHANGED
@@ -858,6 +858,88 @@ describe("a second source that has seen the flow run", () => {
858
858
  expect(first?.type === "step" ? first.status : "").toBe("declared");
859
859
  });
860
860
 
861
+ it("takes a hop a recording showed that the code does not declare, where the recording put it", () => {
862
+ const seen = {
863
+ type: "step" as const,
864
+ id: "seen1",
865
+ from: "auth.auth",
866
+ to: "profile",
867
+ kind: "rpc" as const,
868
+ label: "GET /v1/profiles/42",
869
+ status: "unresolved" as const,
870
+ seen: { traces: 2 },
871
+ };
872
+ const lane = { id: "profile", kind: "unknown" as const, context: null, label: "profile" };
873
+ const example = { id: "telemetry/traces.jsonl#t1", recording: "telemetry/traces.jsonl", traceId: "t1", durationMs: 2.5, steps: [{ step: "s1", durationMs: 2.5 }] };
874
+ const merged = mergeCatalogs([
875
+ source("a.json", { flows: [flow([step("s1", "declared"), step("s2", "declared")])] }),
876
+ source("b.json", {
877
+ flows: [{
878
+ ...flow([{ ...step("s1", "verified"), seen: { traces: 2 } }, seen, step("s2", "declared")]),
879
+ participants: [...flow([]).participants, lane],
880
+ examples: [example],
881
+ }],
882
+ }),
883
+ ]);
884
+
885
+ expect(merged.conflicts).toEqual([]);
886
+ const out = merged.catalog.flows[0]!;
887
+ expect(out.steps.map((s) => (s.type === "step" ? `${s.id}:${s.status}` : s.type))).toEqual(["s1:verified", "seen1:unresolved", "s2:declared"]);
888
+ expect(out.steps[0]?.type === "step" ? out.steps[0].seen : undefined).toEqual({ traces: 2 });
889
+ expect(out.participants.map((p) => p.id)).toEqual(["client", "auth.auth", "profile"]);
890
+ expect(out.examples).toEqual([example]);
891
+ });
892
+
893
+ it("takes a frame where the recordings parted, and not a frame the code did not write", () => {
894
+ const seenStep = (id: string, label: string) => ({ ...step(id, "verified" as const), label, seen: { traces: 1 } });
895
+ const frame = {
896
+ type: "alt" as const,
897
+ id: "seen-alt1",
898
+ branches: [
899
+ { title: "getUser", steps: [seenStep("seen1", "getUser")], seen: { traces: 1 } },
900
+ { title: "otherwise", steps: [], seen: { traces: 1 } },
901
+ ],
902
+ };
903
+ const merged = mergeCatalogs([
904
+ source("a.json", { flows: [flow([step("s1", "declared"), step("s2", "declared")])] }),
905
+ source("b.json", { flows: [flow([step("s1", "verified"), frame, step("s2", "declared")])] }),
906
+ ]);
907
+ expect(merged.conflicts).toEqual([]);
908
+ expect(merged.catalog.flows[0]?.steps.map((s) => s.id)).toEqual(["s1", "seen-alt1", "s2"]);
909
+
910
+ const unseen = { ...frame, branches: [{ title: "x", steps: [step("x1", "verified")] }] };
911
+ const refused = mergeCatalogs([
912
+ source("a.json", { flows: [flow([step("s1", "declared")])] }),
913
+ source("b.json", { flows: [flow([step("s1", "verified"), unseen])] }),
914
+ ]);
915
+ expect(refused.conflicts).toHaveLength(1);
916
+ });
917
+
918
+ it("is still a conflict when a lane the code declared is missing or a declared step is", () => {
919
+ const fewerLanes = mergeCatalogs([
920
+ source("a.json", { flows: [flow([step("s1", "declared")])] }),
921
+ source("b.json", { flows: [{ ...flow([step("s1", "verified")]), participants: [{ id: "client", kind: "actor" as const, context: null }] }] }),
922
+ ]);
923
+ expect(fewerLanes.conflicts).toHaveLength(1);
924
+
925
+ const fewerSteps = mergeCatalogs([
926
+ source("a.json", { flows: [flow([step("s1", "declared"), step("s2", "declared")])] }),
927
+ source("b.json", { flows: [flow([step("s1", "verified")])] }),
928
+ ]);
929
+ expect(fewerSteps.conflicts).toHaveLength(1);
930
+ });
931
+
932
+ it("keeps an example it already has when a second recording says it again", () => {
933
+ const example = (id: string) => ({ id, recording: "t.jsonl", traceId: id, durationMs: 1, steps: [] });
934
+ const merged = mergeCatalogs([
935
+ source("a.json", { flows: [{ ...flow([step("s1", "declared")]), examples: [example("a")] }] }),
936
+ source("b.json", { flows: [{ ...flow([step("s1", "verified")]), examples: [example("a"), example("b")] }] }),
937
+ ]);
938
+
939
+ expect(merged.conflicts).toEqual([]);
940
+ expect(merged.catalog.flows[0]?.examples?.map((e) => e.id)).toEqual(["a", "b"]);
941
+ });
942
+
861
943
  it("lets verified win for a consumer and a call declared twice", () => {
862
944
  const withEdges = (status: "declared" | "verified") => {
863
945
  const ctx = context("shop", ["shop.oms"]);
package/src/merge.ts CHANGED
@@ -15,8 +15,12 @@
15
15
  // context's name is a fact about the estate that belongs on the Problems
16
16
  // page, next to the other things that are true and unfortunate.
17
17
 
18
+ import { deploymentBasis } from "./catalog-model.ts";
18
19
  import type {
19
20
  Aggregate,
21
+ Deployment,
22
+ DeploymentBasis,
23
+ DeploymentDrift,
20
24
  Alt,
21
25
  AltBranch,
22
26
  Block,
@@ -42,13 +46,14 @@ import type {
42
46
  * A catalog as a FILE may carry it, which is not quite a catalog as a reader
43
47
  * gets one: the two stamps are optional here and required downstream.
44
48
  *
45
- * The host stamps what it generates, from the last commit that touched the
46
- * input the fragment was read from. A file nobody generates - the estate's
47
- * hand-written facts - is stamped by nobody, and a provenance typed in by hand
48
- * is a claim no process keeps true: it stays where it was typed while the
49
- * estate moves on, and being the oldest stamp in the corpus it then dates the
50
- * whole merged catalog. So a source is allowed to carry no stamp at all, and
51
- * saying nothing is treated as what it is - an absence of evidence, which
49
+ * A fragment carries no stamp of its own (portolan.0010). When it last
50
+ * changed, and in which commit, is what the history of the checkout says,
51
+ * and whoever reads the file asks the history and hands the answer over
52
+ * beside it. A file may still say something - a fragment written before
53
+ * portolan.0010 does - and that is taken only when nobody read the history.
54
+ * A provenance typed in by hand is a claim no process keeps true, so a source
55
+ * that says nothing and was read outside any history is stamped by nobody,
56
+ * and saying nothing is treated as what it is - an absence of evidence, which
52
57
  * drops out of the merge rather than voting in it.
53
58
  */
54
59
  export type SourceCatalog = Omit<Catalog, "generatedAt" | "commit"> &
@@ -59,6 +64,12 @@ export interface CatalogSource {
59
64
  /** Where it was read from, as a reader would type it. */
60
65
  path: string;
61
66
  catalog: SourceCatalog;
67
+ /**
68
+ * When the file last changed and in which commit, as the reader found out
69
+ * from the history of the checkout (portolan.0010). Left out when there
70
+ * was no history to ask, and the file then speaks for itself.
71
+ */
72
+ stamp?: Pick<SourceStamp, "generatedAt" | "commit">;
62
73
  }
63
74
 
64
75
  /**
@@ -114,14 +125,16 @@ export function mergeCatalogs(sources: CatalogSource[]): MergeResult {
114
125
  const terms: NonNullable<Catalog["terms"]> = [];
115
126
  const repos: NonNullable<Catalog["repos"]> = [];
116
127
  const repoOrigin = new Map<string, string>();
128
+ const deployments: NonNullable<Catalog["deployments"]> = [];
129
+ const deploymentOrigin = new Map<string, Map<DeploymentBasis, string>>(); // id -> basis -> source path
117
130
  const externals = new Map<string, External>();
118
131
  const externalOrigin = new Map<string, string>();
119
132
  const seen = new Map<string, string>(); // flow/adr/store/module/term id -> source path
120
133
 
121
134
  const stamps: SourceStamp[] = ordered.map((source) => ({
122
135
  path: source.path,
123
- generatedAt: source.catalog.generatedAt ?? "",
124
- commit: source.catalog.commit ?? "",
136
+ generatedAt: source.stamp?.generatedAt ?? source.catalog.generatedAt ?? "",
137
+ commit: source.stamp?.commit ?? source.catalog.commit ?? "",
125
138
  }));
126
139
 
127
140
  for (const { path, catalog } of ordered) {
@@ -242,6 +255,15 @@ export function mergeCatalogs(sources: CatalogSource[]): MergeResult {
242
255
  for (const store of catalog.stores ?? []) {
243
256
  if (claim(seen, store.id, path, conflicts, "store")) stores.push(store);
244
257
  }
258
+ // Two sources may speak about one Application, and are meant to: the
259
+ // GitOps tree says what should run, the deployer says what does, and
260
+ // the row a reader sees is the two laid over each other with the
261
+ // difference kept as drift (portolan.0013). Two rows of the SAME basis
262
+ // for one id is the collision - two snapshots of one server, two trees
263
+ // declaring one name - and is reported like any other.
264
+ for (const deployment of catalog.deployments ?? []) {
265
+ foldDeployment(deployments, deploymentOrigin, deployment, path, conflicts);
266
+ }
245
267
  // Claimed by id like everything else at this level, and that is exactly why
246
268
  // a module's id is its registry-global name rather than one derived from an
247
269
  // owner: the producer and each consumer describe the same module from
@@ -317,6 +339,7 @@ export function mergeCatalogs(sources: CatalogSource[]): MergeResult {
317
339
  if (modules.length > 0) merged.modules = modules;
318
340
  if (terms.length > 0) merged.terms = terms;
319
341
  if (repos.length > 0) merged.repos = repos;
342
+ if (deployments.length > 0) merged.deployments = deployments;
320
343
  if (externals.size > 0) merged.externals = [...externals.values()];
321
344
 
322
345
  return { catalog: merged, sources: stamps, conflicts };
@@ -492,6 +515,8 @@ function mergeService(
492
515
  ...(incoming.commands
493
516
  ? { commands: incoming.commands.map((c) => ({ ...c })) }
494
517
  : {}),
518
+ ...(incoming.hosts ? { hosts: [...incoming.hosts] } : {}),
519
+ ...(incoming.dials ? { dials: [...incoming.dials] } : {}),
495
520
  });
496
521
  origin.set(incoming.id, path);
497
522
 
@@ -585,6 +610,18 @@ function mergeService(
585
610
  existing.commands = commands;
586
611
  }
587
612
 
613
+ // Unioned like owners: two manifest trees naming where a service answers,
614
+ // or what it dials, are two deployments of it, and the page shows both.
615
+ for (const field of ["hosts", "dials"] as const) {
616
+ const theirs = incoming[field];
617
+ if (!theirs?.length) continue;
618
+ const names = existing[field] ?? [];
619
+ for (const name of theirs) {
620
+ if (!names.includes(name)) names.push(name);
621
+ }
622
+ existing[field] = names;
623
+ }
624
+
588
625
  if (incoming.channels?.length) {
589
626
  // Keyed by address, because that is the whole of a channel's identity. Two
590
627
  // documents describing one channel is not a conflict worth reporting - a
@@ -923,26 +960,84 @@ function raise<T extends { status: Status; note?: string }>(
923
960
  *
924
961
  * Only `declared` is raised. `unresolved` means the far end is not in the
925
962
  * catalog, and a trace showing the hop does not put it there.
963
+ *
964
+ * The second declaration may also say more than the first: a hop it saw
965
+ * that the code does not declare, carried as a step with `seen` on it and
966
+ * an id the code never gave, sitting where the recording put it; a lane
967
+ * such a hop needed, added after the declared ones; and the recordings
968
+ * themselves, as examples. Each is taken as it comes - a recording is the
969
+ * one witness for it - and nothing already declared moves.
926
970
  */
927
971
  export function overlayFlow(existing: Flow, incoming: Flow): Flow | undefined {
928
- const lanes = (flow: Flow) => flow.participants.map((p) => p.id).join(" ");
929
- if (lanes(existing) !== lanes(incoming)) return undefined;
972
+ const mine = existing.participants.map((p) => p.id);
973
+ const theirs = incoming.participants.map((p) => p.id);
974
+ if (theirs.length < mine.length || mine.some((id, i) => theirs[i] !== id))
975
+ return undefined;
930
976
 
931
977
  const steps = overlayNodes(existing.steps, incoming.steps);
932
978
  if (!steps) return undefined;
933
979
 
934
- return { ...existing, steps };
980
+ const out: Flow = { ...existing, steps };
981
+ if (theirs.length > mine.length) {
982
+ out.participants = [
983
+ ...existing.participants,
984
+ ...incoming.participants.slice(mine.length),
985
+ ];
986
+ }
987
+ const examples = [...(existing.examples ?? [])];
988
+ const held = new Set(examples.map((example) => example.id));
989
+ for (const example of incoming.examples ?? []) {
990
+ if (!held.has(example.id)) {
991
+ held.add(example.id);
992
+ examples.push(example);
993
+ }
994
+ }
995
+ if (examples.length) out.examples = examples;
996
+
997
+ return out;
998
+ }
999
+
1000
+ /**
1001
+ * A node the second declaration added: a step seen in a recording and
1002
+ * unknown to the code, or a frame where the recordings parted - an `alt`
1003
+ * whose every step carries `seen` and whose every branch is counted. Its
1004
+ * id is not one the first declaration has, which is how it is told from a
1005
+ * declared node that moved.
1006
+ */
1007
+ function isSeenAddition(node: FlowNode, against: FlowNode | undefined): boolean {
1008
+ if (against !== undefined && against.id === node.id) return false;
1009
+ if (node.type === "step") return node.seen !== undefined;
1010
+ if (node.type !== "alt") return false;
1011
+ let steps = 0;
1012
+ const allSeen = (nodes: FlowNode[]): boolean =>
1013
+ nodes.every((inner) => {
1014
+ if (inner.type === "step") {
1015
+ steps += 1;
1016
+
1017
+ return inner.seen !== undefined;
1018
+ }
1019
+
1020
+ return inner.type === "alt" && inner.branches.every((b) => b.seen !== undefined && allSeen(b.steps));
1021
+ });
1022
+
1023
+ return node.branches.every((b) => b.seen !== undefined && allSeen(b.steps)) && steps > 0;
935
1024
  }
936
1025
 
937
1026
  function overlayNodes(
938
1027
  existing: FlowNode[],
939
1028
  incoming: FlowNode[],
940
1029
  ): FlowNode[] | undefined {
941
- if (existing.length !== incoming.length) return undefined;
942
1030
  const out: FlowNode[] = [];
943
- for (let i = 0; i < existing.length; i++) {
944
- const a = existing[i]!;
945
- const b = incoming[i]!;
1031
+ let i = 0;
1032
+ for (const b of incoming) {
1033
+ const a = existing[i];
1034
+ if (isSeenAddition(b, a)) {
1035
+ out.push(b);
1036
+
1037
+ continue;
1038
+ }
1039
+ if (a === undefined) return undefined;
1040
+ i++;
946
1041
  if (a.type !== b.type || a.id !== b.id) return undefined;
947
1042
  switch (a.type) {
948
1043
  case "step": {
@@ -954,10 +1049,11 @@ function overlayNodes(
954
1049
  (a.ref ?? "") !== (step.ref ?? "")
955
1050
  )
956
1051
  return undefined;
1052
+ const seen = step.seen ? { seen: step.seen } : {};
957
1053
  if (step.status === "verified" && a.status === "declared") {
958
- out.push({ ...a, status: "verified", note: a.note || step.note });
1054
+ out.push({ ...a, ...seen, status: "verified", note: a.note || step.note });
959
1055
  } else {
960
- out.push(a);
1056
+ out.push({ ...a, ...seen });
961
1057
  }
962
1058
  break;
963
1059
  }
@@ -999,6 +1095,8 @@ function overlayNodes(
999
1095
  }
1000
1096
  }
1001
1097
 
1098
+ if (i !== existing.length) return undefined;
1099
+
1002
1100
  return out;
1003
1101
  }
1004
1102
 
@@ -1006,6 +1104,79 @@ function overlayNodes(
1006
1104
  * Records who owns an id. Returns false when somebody already did, which is
1007
1105
  * how a duplicate is skipped rather than appended twice.
1008
1106
  */
1107
+ /**
1108
+ * Lays one deployment over what is already held for its id.
1109
+ *
1110
+ * A manifest row and an api row fold into one: the api row's shape - it is
1111
+ * what runs, and carries the revision, the images and the link - with the
1112
+ * manifest filling what the deployer did not say (a service the labels
1113
+ * name, images the overlay pins when nothing runs yet), and the fields the
1114
+ * two disagree on kept as the tree's word under `drift`. Whichever arrives
1115
+ * first is held; the second folds onto it. Two rows of one basis are a
1116
+ * collision and the first is kept, said out loud.
1117
+ */
1118
+ function foldDeployment(
1119
+ held: Deployment[],
1120
+ origins: Map<string, Map<DeploymentBasis, string>>,
1121
+ incoming: Deployment,
1122
+ path: string,
1123
+ conflicts: MergeConflict[],
1124
+ ): void {
1125
+ const basis = deploymentBasis(incoming);
1126
+ const bases = origins.get(incoming.id) ?? new Map<DeploymentBasis, string>();
1127
+ origins.set(incoming.id, bases);
1128
+ const index = held.findIndex((d) => d.id === incoming.id);
1129
+ if (index < 0) {
1130
+ bases.set(basis, path);
1131
+ held.push(incoming);
1132
+ return;
1133
+ }
1134
+ const already = bases.get(basis);
1135
+ if (already !== undefined || basis === "both") {
1136
+ conflicts.push({
1137
+ path,
1138
+ where: incoming.id,
1139
+ message: `deployment "${incoming.id}" is ${basis === "manifest" ? "declared" : "listed"} here and in ${already ?? "another source"}; the first one is used`,
1140
+ });
1141
+ return;
1142
+ }
1143
+ bases.set(basis, path);
1144
+ const current = held[index]!;
1145
+ const [manifest, api] = basis === "manifest" ? [incoming, current] : [current, incoming];
1146
+ held[index] = laidOver(manifest, api);
1147
+ }
1148
+
1149
+ /** The api row with the manifest laid under it, and their differences as drift. */
1150
+ function laidOver(manifest: Deployment, api: Deployment): Deployment {
1151
+ const drift: DeploymentDrift = {};
1152
+ if (manifest.project && manifest.project !== api.project) drift.project = manifest.project;
1153
+ if (manifest.cluster && manifest.cluster !== api.cluster) drift.cluster = manifest.cluster;
1154
+ if (manifest.namespace && manifest.namespace !== api.namespace) drift.namespace = manifest.namespace;
1155
+ if (manifest.path && manifest.path !== api.path) drift.path = manifest.path;
1156
+ if (manifest.targetRevision && manifest.targetRevision !== api.targetRevision) {
1157
+ drift.targetRevision = manifest.targetRevision;
1158
+ }
1159
+ // The overlay pins some images; the cluster runs those and the base's.
1160
+ // Drift is a pinned image that is not running, not a running image the
1161
+ // overlay never mentioned.
1162
+ const running = new Set(api.images ?? []);
1163
+ const pinnedElsewhere = (manifest.images ?? []).filter((image) => !running.has(image));
1164
+ if (pinnedElsewhere.length > 0 && (api.images?.length ?? 0) > 0) drift.images = pinnedElsewhere;
1165
+
1166
+ const folded: Deployment = {
1167
+ ...api,
1168
+ service: api.service ?? manifest.service,
1169
+ tool: api.tool || manifest.tool,
1170
+ images: api.images?.length ? api.images : manifest.images,
1171
+ basis: "both",
1172
+ };
1173
+ if (folded.service === undefined) delete folded.service;
1174
+ if (!folded.images?.length) delete folded.images;
1175
+ if (Object.keys(drift).length > 0) folded.drift = drift;
1176
+ else delete folded.drift;
1177
+ return folded;
1178
+ }
1179
+
1009
1180
  function claim(
1010
1181
  owners: Map<string, string>,
1011
1182
  id: string,
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import { Link, useParams } from "react-router";
3
3
  import { catalog, index } from "../data";
4
- import { blockCounts, blockFields, enumsOf, rootEntity } from "../catalog";
4
+ import { allRepos, blockCounts, blockFields, enumsOf, rootEntity } from "../catalog";
5
5
  import { isStatusEnum } from "../lib/shape";
6
6
  import type {
7
7
  Aggregate,
@@ -11,7 +11,7 @@ import type {
11
11
  Service,
12
12
  Enum,
13
13
  } from "../catalog";
14
- import { markdownOutline } from "../lib/derive";
14
+ import { flowsRunning, markdownOutline } from "../lib/derive";
15
15
  import {
16
16
  redisKeyspacesPersisting,
17
17
  tablesPersisting,
@@ -33,6 +33,8 @@ import {
33
33
  } from "../routes";
34
34
  import { methodId } from "../lib/api";
35
35
  import { Markdown } from "../components/Markdown";
36
+ import { SourcePreviewLink } from "../components/SourcePreview";
37
+ import { sourceLocation, splitLine } from "../lib/source-link";
36
38
  import { Empty, PageHeader, SectionTitle } from "../components/PageHeader";
37
39
  import { Ident } from "../components/Ident";
38
40
  import { RowActions } from "../components/RowActions";
@@ -77,14 +79,14 @@ function BuildingBlocks({
77
79
  </div>
78
80
  <div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
79
81
  <span className="mono flex items-center gap-1.5 text-muted">
80
- root
82
+ {aggregate.kind === "model-group" ? "aggregate boundary" : "root"}
81
83
  <KindIcon kind="entity" />
82
84
  {rootTo ? (
83
85
  <Link to={rootTo} className="text-ink hover:underline">
84
86
  {aggregate.root}
85
87
  </Link>
86
88
  ) : (
87
- <span className="text-ink">{aggregate.root}</span>
89
+ <span className="text-ink">{aggregate.kind === "model-group" ? "not specified" : aggregate.root}</span>
88
90
  )}
89
91
  </span>
90
92
  <span aria-hidden className="h-4 w-px bg-line-strong" />
@@ -265,10 +267,12 @@ function OperationList({
265
267
  kind,
266
268
  operations,
267
269
  service,
270
+ aggregate,
268
271
  }: {
269
272
  kind: "command" | "query";
270
273
  operations: Operation[];
271
274
  service: Service;
275
+ aggregate: Aggregate;
272
276
  }) {
273
277
  // Whether this service records what exposes an operation at all. A catalog
274
278
  // written before anything read a transport layer says nothing either way,
@@ -277,10 +281,14 @@ function OperationList({
277
281
  aggregate.operations.some((operation) => operation.exposedBy?.length),
278
282
  );
279
283
  const to = servicePath(service.id);
284
+ const pins = allRepos(catalog);
280
285
 
281
286
  return (
282
287
  <ul className="flex flex-col gap-1">
283
- {operations.map((op) => (
288
+ {operations.map((op) => {
289
+ const runs = flowsRunning(catalog, service, aggregate, op);
290
+ const location = op.source ? sourceLocation(op.source, service, pins) : null;
291
+ return (
284
292
  <li
285
293
  key={op.id}
286
294
  className={`flex items-start gap-2 border-l-2 px-2 py-1.5 bg-surface ${
@@ -290,7 +298,7 @@ function OperationList({
290
298
  <span className="mt-px shrink-0">
291
299
  <KindIcon kind={kind} />
292
300
  </span>
293
- <div className="min-w-0">
301
+ <div className="min-w-0 flex-1">
294
302
  <span className="flex flex-wrap items-center gap-x-2">
295
303
  <Ident block value={op.id} className={op.deprecated ? "line-through" : undefined} />
296
304
  {op.deprecated ? (
@@ -298,8 +306,49 @@ function OperationList({
298
306
  deprecated
299
307
  </span>
300
308
  ) : null}
309
+ {op.source ? (
310
+ <SourcePreviewLink location={location} className="mono ml-auto text-muted hover:text-ink">
311
+ {splitLine(op.source).path.split("/").pop()}
312
+ {splitLine(op.source).line ? `:${splitLine(op.source).line}` : ""}
313
+ </SourcePreviewLink>
314
+ ) : null}
301
315
  </span>
302
316
  {op.doc ? <p className="mt-0.5 text-muted">{op.doc}</p> : null}
317
+ {/* What the caller hands in: the message's own shape. An empty
318
+ list is said out loud - a query that takes nothing is a fact
319
+ about the query, not a gap in the reading. */}
320
+ {op.fields ? (
321
+ op.fields.length ? (
322
+ <dl className="mono mt-1 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5 text-muted">
323
+ {op.fields.map((field) => (
324
+ <div key={field.name} className="contents">
325
+ <dt className={field.deprecated ? "line-through" : undefined}>{field.name}</dt>
326
+ <dd className="min-w-0 truncate">
327
+ <span className="text-ink">{field.type}</span>
328
+ {field.doc ? <span className="ml-2">{field.doc}</span> : null}
329
+ </dd>
330
+ </div>
331
+ ))}
332
+ </dl>
333
+ ) : (
334
+ <p className="mono mt-1 text-muted">takes nothing</p>
335
+ )
336
+ ) : null}
337
+ {runs.length ? (
338
+ <p className="mono mt-1 flex flex-wrap items-center gap-x-2 text-muted">
339
+ <span>runs in</span>
340
+ {runs.map(({ flow, stepId }) => (
341
+ <Link
342
+ key={flow.slug}
343
+ to={paths.flowStep(flow.slug, stepId)}
344
+ className="rounded-control hover:text-ink"
345
+ title={flow.name}
346
+ >
347
+ {flow.slug}
348
+ </Link>
349
+ ))}
350
+ </p>
351
+ ) : null}
303
352
  {op.exposedBy?.length ? (
304
353
  <p className="mono mt-1 flex flex-wrap items-center gap-x-2 text-muted">
305
354
  <span>exposed by</span>
@@ -328,7 +377,8 @@ function OperationList({
328
377
  ) : null}
329
378
  </div>
330
379
  </li>
331
- ))}
380
+ );
381
+ })}
332
382
  </ul>
333
383
  );
334
384
  }
@@ -385,7 +435,7 @@ export function AggregatePage() {
385
435
  <PageHeader
386
436
  kind={
387
437
  <>
388
- aggregate ·{" "}
438
+ {aggregate.kind === "model-group" ? "model group" : "aggregate"} ·{" "}
389
439
  <Link
390
440
  to={paths.service(context.id, service.slug)}
391
441
  className="rounded-control hover:text-ink hover:underline"
@@ -410,6 +460,7 @@ export function AggregatePage() {
410
460
  />
411
461
 
412
462
  <Markdown mermaid>{aggregate.readme}</Markdown>
463
+ {aggregate.kind === "model-group" ? <p className="mb-section text-muted">Models discovered in this application. Their relationships are extracted from the source; aggregate boundaries have not been specified.</p> : null}
413
464
 
414
465
  <div
415
466
  className="mt-section max-w-prose"
@@ -583,16 +634,16 @@ export function AggregatePage() {
583
634
  anchor={AGGREGATE_ANCHOR.commands}
584
635
  right={
585
636
  <span>
586
- they change the aggregate — one row lock each
637
+ {aggregate.kind === "model-group" ? "write operations discovered in this application" : "they change the aggregate — one row lock each"}
587
638
  </span>
588
639
  }
589
640
  >
590
641
  Commands
591
642
  </SectionTitle>
592
643
  {commands.length === 0 ? (
593
- <Empty>nothing changes this aggregate from outside</Empty>
644
+ <Empty>{aggregate.kind === "model-group" ? "no application commands discovered" : "nothing changes this aggregate from outside"}</Empty>
594
645
  ) : null}
595
- <OperationList kind="command" operations={commands} service={service} />
646
+ <OperationList kind="command" operations={commands} service={service} aggregate={aggregate} />
596
647
  </div>
597
648
  <div id={AGGREGATE_ANCHOR.queries}>
598
649
  <SectionTitle
@@ -606,9 +657,9 @@ export function AggregatePage() {
606
657
  Queries
607
658
  </SectionTitle>
608
659
  {queries.length === 0 ? (
609
- <Empty>nothing reads this aggregate by name</Empty>
660
+ <Empty>{aggregate.kind === "model-group" ? "no application queries discovered" : "nothing reads this aggregate by name"}</Empty>
610
661
  ) : null}
611
- <OperationList kind="query" operations={queries} service={service} />
662
+ <OperationList kind="query" operations={queries} service={service} aggregate={aggregate} />
612
663
  </div>
613
664
  </div>
614
665
 
@@ -750,7 +801,7 @@ export function AggregatePage() {
750
801
  />
751
802
  </div>
752
803
 
753
- <Toc items={toc} label="Sections of this aggregate" title="Outline" />
804
+ <Toc items={toc} label={aggregate.kind === "model-group" ? "Sections of this model group" : "Sections of this aggregate"} title="Outline" />
754
805
  </div>
755
806
  </div>
756
807
  );
@@ -17,6 +17,8 @@ import { Link, useSearchParams } from "react-router";
17
17
  import { ArrowRight, ArrowLeftRight, Boxes, Minus } from "lucide-react";
18
18
  import { catalog } from "../data";
19
19
  import { plural } from "../lib/format";
20
+ import { narrowToEnvironments } from "../lib/environments";
21
+ import { environmentsOf } from "../likec4/ids";
20
22
  import { PATTERN_LABEL, PATTERN_MEANING, contextMap } from "../lib/context-map";
21
23
  import type {
22
24
  ContextDependency,
@@ -439,7 +441,22 @@ export function ContextMap() {
439
441
  useDocumentTitle("Context map");
440
442
  const [search, setSearch] = useSearchParams();
441
443
  const [tour, setTour] = useState(() => search.get("tour") === "1");
442
- const relations = useMemo(() => contextMap(catalog), []);
444
+ // The map of one environment is the same map over the services deployed
445
+ // there: a pair joined only through a service that is not in staging is
446
+ // not joined in staging. Empty means the whole estate, as every filter here.
447
+ const environmentChips = useMemo(() => environmentsOf(catalog), []);
448
+ const [environments, setEnvironments] = useState<Set<string>>(new Set());
449
+ const narrowed = useMemo(
450
+ () => narrowToEnvironments(catalog, environments),
451
+ [environments],
452
+ );
453
+ const relations = useMemo(() => contextMap(narrowed), [narrowed]);
454
+ const toggleEnvironment = (env: string) =>
455
+ setEnvironments((prev) => {
456
+ const next = new Set(prev);
457
+ if (!next.delete(env)) next.add(env);
458
+ return next;
459
+ });
443
460
  const joined = relations.filter(
444
461
  (r) => r.dependencies.length > 0 || r.shared.length > 0,
445
462
  );
@@ -457,9 +474,34 @@ export function ContextMap() {
457
474
  <div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b px-gutter py-3 border-line">
458
475
  <h1 className="text-lg font-semibold">Context map</h1>
459
476
  <span className="mono text-muted">
460
- {catalog.contexts.length} {plural(catalog.contexts.length, "domain")} ·{" "}
477
+ {narrowed.contexts.length} {plural(narrowed.contexts.length, "domain")} ·{" "}
461
478
  {wired} of {relations.length} {plural(relations.length, "pair")} joined
462
479
  </span>
480
+ {environmentChips.length > 0 ? (
481
+ <div className="seg" role="group" aria-label="Filter by environment">
482
+ {environmentChips.map((env) => {
483
+ const on = environments.has(env);
484
+ return (
485
+ <button
486
+ key={env}
487
+ type="button"
488
+ onClick={() => toggleEnvironment(env)}
489
+ aria-pressed={on}
490
+ title={`only the services the deployer places in ${env}`}
491
+ className="flex items-center gap-1.5"
492
+ style={{
493
+ color: on ? "var(--accent)" : "var(--fg-muted)",
494
+ background: on
495
+ ? "color-mix(in srgb, var(--accent) 12%, transparent)"
496
+ : undefined,
497
+ }}
498
+ >
499
+ {env}
500
+ </button>
501
+ );
502
+ })}
503
+ </div>
504
+ ) : null}
463
505
 
464
506
  {/* Two legends, and they answer different questions: what the arrow
465
507
  means, and how far a word on a chip can be trusted. */}
@@ -505,7 +547,7 @@ export function ContextMap() {
505
547
  </SectionTitle>
506
548
  <div className="h-[340px] overflow-hidden rounded-card border border-line">
507
549
  <ContextMapPane
508
- catalog={catalog}
550
+ catalog={narrowed}
509
551
  relations={relations}
510
552
  zoomOnScroll={false}
511
553
  />