@shortlink-org/portolan 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
@@ -20,6 +20,7 @@ import {
20
20
  TABLE_ROLES,
21
21
  aggregateBlocks,
22
22
  allAggregates,
23
+ allDeployments,
23
24
  allExternals,
24
25
  allModules,
25
26
  allRepos,
@@ -165,6 +166,20 @@ export function validateCatalog(catalog: Catalog): Catalog {
165
166
  ),
166
167
  );
167
168
  const storeIds = new Set(allStores(catalog).map((store) => store.id));
169
+ // `<service>|<aggregate id>/<operation id>`: what a `call` step into a
170
+ // service may name - the use case it runs, when the flow crosses a
171
+ // boundary the transport does not draw (an in-process command bus).
172
+ const operationRefs = new Set(
173
+ catalog.contexts.flatMap((context) =>
174
+ context.services.flatMap((service) =>
175
+ service.aggregates.flatMap((aggregate) =>
176
+ aggregate.operations.map(
177
+ (operation) => `${service.id}|${aggregate.id}/${operation.id}`,
178
+ ),
179
+ ),
180
+ ),
181
+ ),
182
+ );
168
183
 
169
184
  assertUniqueSlugs(
170
185
  catalog.contexts.map((c) => c.id),
@@ -253,6 +268,27 @@ export function validateCatalog(catalog: Catalog): Catalog {
253
268
  }
254
269
  technologies.add(technology);
255
270
  }
271
+ // Hosts and dials are names, and a name is checked the way a handle
272
+ // is: not blank, not listed twice. Two services behind one Ingress
273
+ // host is one host on two pages, which is what the manifests say.
274
+ for (const field of ["hosts", "dials"] as const) {
275
+ const names = new Set<string>();
276
+ for (const name of service[field] ?? []) {
277
+ if (!name.trim()) {
278
+ fail(
279
+ `service "${service.id}" has a ${field.slice(0, -1)} with no name`,
280
+ `service ${service.id}`,
281
+ );
282
+ }
283
+ if (names.has(name)) {
284
+ fail(
285
+ `service "${service.id}" names ${field.slice(0, -1)} "${name}" twice`,
286
+ `service ${service.id}`,
287
+ );
288
+ }
289
+ names.add(name);
290
+ }
291
+ }
256
292
  for (const call of service.consumes) rpcIds.add(call.id);
257
293
  for (const provided of service.provides) {
258
294
  for (const method of provided.methods) {
@@ -366,6 +402,14 @@ export function validateCatalog(catalog: Catalog): Catalog {
366
402
  );
367
403
  }
368
404
  }
405
+ for (const field of operation.fields ?? []) {
406
+ if (field.ref !== undefined && !(field.ref in catalog.defs)) {
407
+ fail(
408
+ `field "${field.name}" of operation "${operation.id}" of aggregate "${aggregate.id}" references unknown def "${field.ref}"`,
409
+ `aggregate ${aggregate.id} / operation ${operation.id} / field ${field.name}`,
410
+ );
411
+ }
412
+ }
369
413
  }
370
414
  validateBlocks(catalog, aggregate);
371
415
  assertUniqueSlugs(
@@ -622,16 +666,47 @@ export function validateCatalog(catalog: Catalog): Catalog {
622
666
  eventIds.has(step.ref) ||
623
667
  rpcIds.has(step.ref) ||
624
668
  (step.kind === "rpc" &&
625
- providedRpcRefs.has(`${step.to}|${step.ref}`));
669
+ providedRpcRefs.has(`${step.to}|${step.ref}`)) ||
670
+ (step.kind === "call" &&
671
+ operationRefs.has(`${step.to}|${step.ref}`));
626
672
  if (!resolves) {
627
673
  fail(
628
- `flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall nor a method provided by "${step.to}", and status is "${step.status}" rather than "unresolved"`,
674
+ `flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall, a method provided by "${step.to}" nor an operation of "${step.to}", and status is "${step.status}" rather than "unresolved"`,
629
675
  `flow ${flow.id} / step ${step.id}`,
630
676
  );
631
677
  }
632
678
  }
633
679
  }
634
680
 
681
+ // An example names the steps it showed by id; one that names a step
682
+ // the flow does not have is a recording of some other flow, or of this
683
+ // one before it changed, and either way it would be shown next to the
684
+ // wrong step.
685
+ const exampleIds = new Set<string>();
686
+ for (const example of flow.examples ?? []) {
687
+ if (!example.id || !example.recording || !example.traceId) {
688
+ fail(
689
+ `flow "${flow.slug}" has an example with no id, recording or trace id`,
690
+ `flow ${flow.id}`,
691
+ );
692
+ }
693
+ if (exampleIds.has(example.id)) {
694
+ fail(
695
+ `flow "${flow.slug}" carries example "${example.id}" twice`,
696
+ `flow ${flow.id}`,
697
+ );
698
+ }
699
+ exampleIds.add(example.id);
700
+ for (const shown of example.steps) {
701
+ if (!stepById.has(shown.step)) {
702
+ fail(
703
+ `flow "${flow.slug}" example "${example.id}" shows unknown step "${shown.step}"`,
704
+ `flow ${flow.id}`,
705
+ );
706
+ }
707
+ }
708
+ }
709
+
635
710
  for (const step of steps) {
636
711
  if (step.kind !== "response") {
637
712
  if (step.replyTo !== undefined) {
@@ -670,10 +745,37 @@ export function validateCatalog(catalog: Catalog): Catalog {
670
745
  validateAdrs(catalog, eventIds);
671
746
  validateTerms(catalog);
672
747
  validateRepos(catalog);
748
+ validateDeployments(catalog);
673
749
 
674
750
  return catalog;
675
751
  }
676
752
 
753
+ /**
754
+ * A deployment names an Application, and names it once.
755
+ *
756
+ * What is not checked is deliberate, and the same omission `validateRepos`
757
+ * makes: a deployment of a repository no service claims is NOT an error. The
758
+ * snapshot lists what the control plane manages, and an Application for a
759
+ * service nobody has read yet, or for something that is not a service at
760
+ * all - a monitoring stack, an ingress controller - is the ordinary case.
761
+ * It simply matches nothing on a service page. Nor is a row with an empty
762
+ * environment or repository: the snapshot says what the deployer said, and
763
+ * a build must not go red because one Application was written oddly.
764
+ */
765
+ function validateDeployments(catalog: Catalog): void {
766
+ const seen = new Set<string>();
767
+
768
+ for (const deployment of allDeployments(catalog)) {
769
+ const where = `deployment ${deployment.id || "?"}`;
770
+ if (!deployment.id) fail("a deployment has no id", where);
771
+ if (!deployment.name) fail(`deployment "${deployment.id}" names no application`, where);
772
+ if (seen.has(deployment.id)) {
773
+ fail(`deployment "${deployment.id}" is listed twice in one catalog`, where);
774
+ }
775
+ seen.add(deployment.id);
776
+ }
777
+ }
778
+
677
779
  /**
678
780
  * A module reference may only point at a module that exists.
679
781
  *
@@ -86,6 +86,22 @@ describe("validateCatalog", () => {
86
86
  expect(() => validateCatalog(bad)).toThrow(/expected 1.1 or 1.2/);
87
87
  });
88
88
 
89
+ it("rejects an example that shows a step the flow does not have, and one carried twice", () => {
90
+ const bad = clone();
91
+ const checkout = bad.flows.find((f) => f.slug === "checkout") as Flow;
92
+ const first = walkSteps(checkout.steps)[0];
93
+ if (!first) throw new Error("fixture has no steps");
94
+ const example = { id: "t.jsonl#1", recording: "t.jsonl", traceId: "1", durationMs: 1, steps: [{ step: first.id, durationMs: 1 }] };
95
+ checkout.examples = [example];
96
+ expect(() => validateCatalog(bad)).not.toThrow();
97
+
98
+ checkout.examples = [{ ...example, steps: [{ step: "nope", durationMs: 1 }] }];
99
+ expect(() => validateCatalog(bad)).toThrowError(/flow "checkout" example "t.jsonl#1" shows unknown step "nope"/);
100
+
101
+ checkout.examples = [example, example];
102
+ expect(() => validateCatalog(bad)).toThrowError(/carries example "t.jsonl#1" twice/);
103
+ });
104
+
89
105
  it("rejects a step whose participant was never declared, naming flow and step", () => {
90
106
  const bad = clone();
91
107
  const checkout = bad.flows.find((f) => f.slug === "checkout") as Flow;
@@ -105,12 +121,35 @@ describe("validateCatalog", () => {
105
121
  if (!step) throw new Error("fixture has no steps");
106
122
  step.ref = "shop.oms.order.NoSuchEvent";
107
123
  expect(() => validateCatalog(bad)).toThrowError(
108
- /resolves to neither an Event, an RpcCall nor a method/,
124
+ /resolves to neither an Event, an RpcCall, a method/,
109
125
  );
110
126
  step.status = "unresolved";
111
127
  expect(() => validateCatalog(bad)).not.toThrow();
112
128
  });
113
129
 
130
+ // A flow that crosses a boundary the transport does not draw - a command
131
+ // handed to another context over an in-process bus - names the use case
132
+ // it runs, and only a `call` step into that use case's service may.
133
+ it("accepts a call step that references an operation of the service it enters", () => {
134
+ const good = clone();
135
+ const service = good.contexts[0]?.services[0];
136
+ const aggregate = service?.aggregates[0];
137
+ const operation = aggregate?.operations[0];
138
+ const flow = good.flows[0] as Flow;
139
+ const step = walkSteps(flow.steps)[0];
140
+ if (!service || !aggregate || !operation || !step) throw new Error("nothing to reference");
141
+ if (!flow.participants.some((p) => p.id === service.id)) {
142
+ flow.participants.push({ id: service.id, kind: "service", context: good.contexts[0]!.id });
143
+ }
144
+ step.kind = "call";
145
+ step.to = service.id;
146
+ step.ref = `${aggregate.id}/${operation.id}`;
147
+ expect(() => validateCatalog(good)).not.toThrow();
148
+
149
+ step.kind = "rpc";
150
+ expect(() => validateCatalog(good)).toThrowError(/nor an operation of/);
151
+ });
152
+
114
153
  it("rejects a field ref that is not a defs key", () => {
115
154
  const bad = clone();
116
155
  const field =
@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
2
2
  import { MemoryRouter } from "react-router";
3
3
  import { describe, expect, it } from "vitest";
4
4
  import type { Channel } from "../catalog";
5
- import { ChannelRowsContent } from "./ChannelRows";
5
+ import { ChannelRowsContent, countDirections, rowsOf } from "./ChannelRows";
6
6
 
7
7
  const kafka: Channel = {
8
8
  address: "orders/created",
@@ -18,6 +18,18 @@ const nats: Channel = {
18
18
  messages: [],
19
19
  };
20
20
 
21
+ /** A work queue the way extract-laravel and extract-celery write one: a job put on and worked. */
22
+ const queue: Channel = {
23
+ address: "default",
24
+ kind: "job",
25
+ title: "Queue · default",
26
+ messages: [
27
+ { name: "App\\Jobs\\IndexOrder", title: "IndexOrder", doc: "Puts the order in the index.", direction: "send" },
28
+ { name: "App\\Jobs\\IndexOrder", title: "IndexOrder", doc: "Worked by `IndexOrder::handle`.", direction: "receive" },
29
+ { name: "App\\Jobs\\ImportBatch", title: "ImportBatch", doc: "Worked by `ImportBatch::handle`.", direction: "receive" },
30
+ ],
31
+ };
32
+
21
33
  describe("ChannelRows Kafka UI integration", () => {
22
34
  it("links only Kafka cards to their exact topic when configured", () => {
23
35
  const markup = renderToStaticMarkup(
@@ -50,5 +62,68 @@ describe("ChannelRows Kafka UI integration", () => {
50
62
 
51
63
  expect(markup).not.toContain("view in Kafka UI");
52
64
  });
65
+ });
66
+
67
+ describe("ChannelRows direction", () => {
68
+ it("draws each direction as its own chip", () => {
69
+ const markup = renderToStaticMarkup(
70
+ <MemoryRouter>
71
+ <ChannelRowsContent channels={[queue]} service="shop.app" kafkaUi="" />
72
+ </MemoryRouter>,
73
+ );
74
+ expect(markup.match(/data-direction="send"/g)).toHaveLength(1);
75
+ expect(markup.match(/data-direction="receive"/g)).toHaveLength(2);
76
+ expect(markup).toContain('class="chip dir-send"');
77
+ expect(markup).toContain('class="chip dir-receive"');
78
+ });
79
+
80
+ it("keeps one direction when filtered, and says so when a channel has none of it", () => {
81
+ const rows = rowsOf(queue.messages, "send", false);
82
+ expect(rows.map((r) => `${r.directions[0]} ${r.message.title}`)).toEqual(["send IndexOrder"]);
83
+
84
+ const markup = renderToStaticMarkup(
85
+ <MemoryRouter>
86
+ <ChannelRowsContent channels={[queue]} service="shop.app" kafkaUi="" filter="send" />
87
+ </MemoryRouter>,
88
+ );
89
+ expect(markup).not.toContain("ImportBatch");
90
+ expect(markup).not.toContain("nothing sent");
53
91
 
92
+ const empty = renderToStaticMarkup(
93
+ <MemoryRouter>
94
+ <ChannelRowsContent
95
+ channels={[{ ...queue, messages: queue.messages.filter((m) => m.direction === "receive") }]}
96
+ service="shop.app"
97
+ kafkaUi=""
98
+ filter="send"
99
+ />
100
+ </MemoryRouter>,
101
+ );
102
+ expect(empty).toContain("nothing sent");
103
+ });
104
+
105
+ it("folds the send and receive of one message into a row wearing both chips", () => {
106
+ const rows = rowsOf(queue.messages, "all", true);
107
+ expect(rows.map((r) => `${r.message.title}: ${r.directions.join("+")}`)).toEqual([
108
+ "IndexOrder: send+receive",
109
+ "ImportBatch: receive",
110
+ ]);
111
+ // The sent message carries the sender's doc, whichever came first.
112
+ const reversed = rowsOf([...queue.messages].reverse(), "all", true);
113
+ expect(reversed[1]?.message.doc).toBe("Puts the order in the index.");
114
+ expect(reversed[1]?.directions).toEqual(["send", "receive"]);
115
+
116
+ const markup = renderToStaticMarkup(
117
+ <MemoryRouter>
118
+ <ChannelRowsContent channels={[queue]} service="shop.app" kafkaUi="" grouped />
119
+ </MemoryRouter>,
120
+ );
121
+ // Two rows for three messages, three chips between them.
122
+ expect(markup.match(/<li /g)).toHaveLength(2);
123
+ expect(markup.match(/data-direction=/g)).toHaveLength(3);
124
+ });
125
+
126
+ it("counts what travels each way across every channel", () => {
127
+ expect(countDirections([queue, kafka])).toEqual({ send: 1, receive: 2 });
128
+ });
54
129
  });
@@ -12,10 +12,15 @@
12
12
  // is either a publisher outside the estate or a name that has drifted, and the
13
13
  // Problems page says which; a row that quietly dropped it would be the site
14
14
  // hiding the interesting case.
15
+ //
16
+ // Direction is the first thing a reader asks of a channel - what goes out, what
17
+ // comes in - so it is drawn, not only written: an arrow and a colour per
18
+ // direction, a filter to keep one, and a grouping that folds the two rows a
19
+ // message has on a work queue (put on, worked) into one.
15
20
 
16
- import { ExternalLink } from "lucide-react";
17
- import { Link } from "react-router";
18
- import type { Channel, ChannelMessage } from "../catalog";
21
+ import { ArrowDownLeft, ArrowUpRight, ExternalLink, Layers } from "lucide-react";
22
+ import { Link, useSearchParams } from "react-router";
23
+ import type { Channel, ChannelDirection, ChannelMessage } from "../catalog";
19
24
  import { catalog, index } from "../data";
20
25
  import {
21
26
  isKafkaChannel,
@@ -29,6 +34,8 @@ import { RowActions } from "./RowActions";
29
34
 
30
35
  const KAFKA_HANDOFF_CHANNELS = kafkaHandoffChannels(catalog);
31
36
 
37
+ export type DirectionFilter = "all" | ChannelDirection;
38
+
32
39
  /** The event that goes out under a wire name, when the catalog knows one. */
33
40
  function publisherOf(name: string) {
34
41
  const event = index.eventByWireName.get(name);
@@ -40,11 +47,41 @@ function publisherOf(name: string) {
40
47
  return { event, owner, path };
41
48
  }
42
49
 
50
+ const DIRECTION = {
51
+ send: {
52
+ Icon: ArrowUpRight,
53
+ className: "chip dir-send",
54
+ title: "this service puts it on the channel",
55
+ },
56
+ receive: {
57
+ Icon: ArrowDownLeft,
58
+ className: "chip dir-receive",
59
+ title: "this service listens for it",
60
+ },
61
+ } as const;
62
+
63
+ function DirectionChip({ direction }: { direction: ChannelDirection }) {
64
+ const { Icon, className, title } = DIRECTION[direction];
65
+ return (
66
+ <span className={className} title={title} data-direction={direction}>
67
+ <Icon size={11} aria-hidden />
68
+ {direction}
69
+ </span>
70
+ );
71
+ }
72
+
73
+ /**
74
+ * One row: a message and the directions it travels. Ungrouped, a message that
75
+ * is both sent and received is two rows with one direction each; grouped, it
76
+ * is one row wearing both chips.
77
+ */
43
78
  function MessageRow({
44
79
  message,
80
+ directions,
45
81
  mine,
46
82
  }: {
47
83
  message: ChannelMessage;
84
+ directions: readonly ChannelDirection[];
48
85
  /** The service this channel belongs to, so a row can say when the publisher is someone else. */
49
86
  mine: string;
50
87
  }) {
@@ -58,16 +95,9 @@ function MessageRow({
58
95
  <li className="row rounded-none border-x-0 border-t-0 last:border-b-0">
59
96
  <div className="min-w-0 flex-1">
60
97
  <div className="flex flex-wrap items-center gap-x-2">
61
- <span
62
- className="chip mono"
63
- title={
64
- message.direction === "send"
65
- ? "this service puts it on the channel"
66
- : "this service listens for it"
67
- }
68
- >
69
- {message.direction}
70
- </span>
98
+ {directions.map((direction) => (
99
+ <DirectionChip key={direction} direction={direction} />
100
+ ))}
71
101
  {published ? (
72
102
  <Link
73
103
  to={published.path}
@@ -99,6 +129,64 @@ function MessageRow({
99
129
  );
100
130
  }
101
131
 
132
+ /** A message with every direction it travels on the channel, in send-then-receive order. */
133
+ interface MessageGroup {
134
+ message: ChannelMessage;
135
+ directions: ChannelDirection[];
136
+ }
137
+
138
+ /**
139
+ * The rows a channel shows: one per message and direction, or - grouped - one
140
+ * per message with both directions, the sent one supplying the doc because it
141
+ * is what the sender wrote down. A filter keeps one direction; a grouped row
142
+ * that loses its other half keeps the chip it still has.
143
+ */
144
+ export function rowsOf(
145
+ messages: readonly ChannelMessage[],
146
+ filter: DirectionFilter,
147
+ grouped: boolean,
148
+ ): MessageGroup[] {
149
+ const kept = messages.filter(
150
+ (message) => filter === "all" || message.direction === filter,
151
+ );
152
+ if (!grouped) {
153
+ return kept.map((message) => ({ message, directions: [message.direction] }));
154
+ }
155
+ const groups: MessageGroup[] = [];
156
+ for (const message of kept) {
157
+ const group = groups.find((g) => g.message.name === message.name);
158
+ if (!group) {
159
+ groups.push({ message, directions: [message.direction] });
160
+ } else {
161
+ if (!group.directions.includes(message.direction)) {
162
+ group.directions.push(message.direction);
163
+ }
164
+ // The sent message carries the sender's doc; a receive found first is
165
+ // replaced by it, so that a grouped row reads the same either way.
166
+ if (message.direction === "send" && group.message.direction === "receive") {
167
+ group.message = message;
168
+ }
169
+ }
170
+ }
171
+ for (const group of groups) {
172
+ group.directions.sort((a, b) => (a === b ? 0 : a === "send" ? -1 : 1));
173
+ }
174
+ return groups;
175
+ }
176
+
177
+ /** How many messages travel each way, across every channel. */
178
+ export function countDirections(channels: readonly Channel[]) {
179
+ let send = 0;
180
+ let receive = 0;
181
+ for (const channel of channels) {
182
+ for (const message of channel.messages) {
183
+ if (message.direction === "send") send += 1;
184
+ else receive += 1;
185
+ }
186
+ }
187
+ return { send, receive };
188
+ }
189
+
102
190
  export function ChannelRows({
103
191
  channels,
104
192
  service,
@@ -107,12 +195,75 @@ export function ChannelRows({
107
195
  service: string;
108
196
  }) {
109
197
  const kafkaUi = useKafkaUi((state) => state.url);
198
+ // The filter and the grouping live in the URL beside the tab, so that the
199
+ // view a reader ends up with is the one their link opens.
200
+ const [params, setParams] = useSearchParams();
201
+ const dir = params.get("dir");
202
+ const filter: DirectionFilter =
203
+ dir === "send" || dir === "receive" ? dir : "all";
204
+ const grouped = params.get("group") === "message";
205
+ const set = (key: string, value: string | null) => {
206
+ const next = new URLSearchParams(params);
207
+ if (value === null) next.delete(key);
208
+ else next.set(key, value);
209
+ setParams(next, { replace: true });
210
+ };
211
+ const counts = countDirections(channels);
212
+
110
213
  return (
111
- <ChannelRowsContent
112
- channels={channels}
113
- service={service}
114
- kafkaUi={kafkaUi}
115
- />
214
+ <div className="flex flex-col gap-3">
215
+ <div className="flex flex-wrap items-center gap-2">
216
+ <div className="seg bg-canvas" role="group" aria-label="Direction shown">
217
+ <button
218
+ type="button"
219
+ onClick={() => set("dir", null)}
220
+ aria-pressed={filter === "all"}
221
+ className={filter === "all" ? "is-on" : ""}
222
+ title="Every message, both ways"
223
+ >
224
+ all <span className="tnum text-muted">{counts.send + counts.receive}</span>
225
+ </button>
226
+ <button
227
+ type="button"
228
+ onClick={() => set("dir", "send")}
229
+ aria-pressed={filter === "send"}
230
+ className={filter === "send" ? "is-on" : ""}
231
+ title="Only what this service puts on a channel"
232
+ >
233
+ <ArrowUpRight size={11} aria-hidden className="inline" /> send{" "}
234
+ <span className="tnum text-muted">{counts.send}</span>
235
+ </button>
236
+ <button
237
+ type="button"
238
+ onClick={() => set("dir", "receive")}
239
+ aria-pressed={filter === "receive"}
240
+ className={filter === "receive" ? "is-on" : ""}
241
+ title="Only what this service listens for"
242
+ >
243
+ <ArrowDownLeft size={11} aria-hidden className="inline" /> receive{" "}
244
+ <span className="tnum text-muted">{counts.receive}</span>
245
+ </button>
246
+ </div>
247
+ <div className="seg bg-canvas" role="group" aria-label="Grouping">
248
+ <button
249
+ type="button"
250
+ onClick={() => set("group", grouped ? null : "message")}
251
+ aria-pressed={grouped}
252
+ className={grouped ? "is-on" : ""}
253
+ title="One row per message, with every direction it travels"
254
+ >
255
+ <Layers size={11} aria-hidden className="inline" /> by message
256
+ </button>
257
+ </div>
258
+ </div>
259
+ <ChannelRowsContent
260
+ channels={channels}
261
+ service={service}
262
+ kafkaUi={kafkaUi}
263
+ filter={filter}
264
+ grouped={grouped}
265
+ />
266
+ </div>
116
267
  );
117
268
  }
118
269
 
@@ -121,59 +272,71 @@ export function ChannelRowsContent({
121
272
  channels,
122
273
  service,
123
274
  kafkaUi,
275
+ filter = "all",
276
+ grouped = false,
124
277
  }: {
125
278
  channels: Channel[];
126
279
  service: string;
127
280
  kafkaUi: string;
281
+ filter?: DirectionFilter;
282
+ grouped?: boolean;
128
283
  }) {
129
284
  return (
130
285
  <div className="flex flex-col gap-section" data-nav-list>
131
- {channels.map((channel) => (
132
- <div key={channel.address} className="rounded-card border border-line">
133
- <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line px-3 py-2">
134
- <div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2">
135
- <Ident value={channel.address} />
136
- {channel.kind === "job" ? (
137
- <span className="chip">work queue</span>
138
- ) : channel.kind === "message" ? (
139
- <span className="chip">message stream</span>
140
- ) : null}
141
- {channel.title ? (
142
- <span className="text-muted">{channel.title}</span>
286
+ {channels.map((channel) => {
287
+ const rows = rowsOf(channel.messages, filter, grouped);
288
+ return (
289
+ <div key={channel.address} className="rounded-card border border-line">
290
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line px-3 py-2">
291
+ <div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2">
292
+ <Ident value={channel.address} />
293
+ {channel.kind === "job" ? (
294
+ <span className="chip">work queue</span>
295
+ ) : channel.kind === "message" ? (
296
+ <span className="chip">message stream</span>
297
+ ) : null}
298
+ {channel.title ? (
299
+ <span className="text-muted">{channel.title}</span>
300
+ ) : null}
301
+ </div>
302
+ {kafkaUi && isKafkaChannel(channel, KAFKA_HANDOFF_CHANNELS) ? (
303
+ <a
304
+ href={kafkaUiTopicUrl(kafkaUi, channel.address) ?? kafkaUi}
305
+ target="_blank"
306
+ rel="noreferrer"
307
+ className="mono inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-control text-accent hover:underline"
308
+ title={`Open ${channel.address} in Kafka UI`}
309
+ >
310
+ view in Kafka UI <ExternalLink size={12} aria-hidden />
311
+ </a>
143
312
  ) : null}
144
313
  </div>
145
- {kafkaUi && isKafkaChannel(channel, KAFKA_HANDOFF_CHANNELS) ? (
146
- <a
147
- href={kafkaUiTopicUrl(kafkaUi, channel.address) ?? kafkaUi}
148
- target="_blank"
149
- rel="noreferrer"
150
- className="mono inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-control text-accent hover:underline"
151
- title={`Open ${channel.address} in Kafka UI`}
152
- >
153
- view in Kafka UI <ExternalLink size={12} aria-hidden />
154
- </a>
314
+ {channel.doc ? (
315
+ <p className="px-3 py-2 text-muted">{channel.doc}</p>
155
316
  ) : null}
317
+ {channel.messages.length === 0 ? (
318
+ <p className="px-3 py-2 text-muted">
319
+ declared, and no operation says which way it travels
320
+ </p>
321
+ ) : rows.length === 0 ? (
322
+ <p className="px-3 py-2 text-muted">
323
+ {filter === "send" ? "nothing sent" : "nothing received"}
324
+ </p>
325
+ ) : (
326
+ <ul>
327
+ {rows.map(({ message, directions }) => (
328
+ <MessageRow
329
+ key={`${directions.join("+")} ${message.name}`}
330
+ message={message}
331
+ directions={directions}
332
+ mine={service}
333
+ />
334
+ ))}
335
+ </ul>
336
+ )}
156
337
  </div>
157
- {channel.doc ? (
158
- <p className="px-3 py-2 text-muted">{channel.doc}</p>
159
- ) : null}
160
- {channel.messages.length === 0 ? (
161
- <p className="px-3 py-2 text-muted">
162
- declared, and no operation says which way it travels
163
- </p>
164
- ) : (
165
- <ul>
166
- {channel.messages.map((message) => (
167
- <MessageRow
168
- key={`${message.direction} ${message.name}`}
169
- message={message}
170
- mine={service}
171
- />
172
- ))}
173
- </ul>
174
- )}
175
- </div>
176
- ))}
338
+ );
339
+ })}
177
340
  </div>
178
341
  );
179
342
  }