@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
@@ -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
  }
@@ -0,0 +1,131 @@
1
+ // Where a service runs, one row per Application the deployer lists for it.
2
+ //
3
+ // The environment is the row's identity: it is what a reader scans for -
4
+ // "is it in prod yet" - and what the snapshot groups by. The revision is the
5
+ // answer to the next question, "which commit", and is a link to the commit
6
+ // on the forge when it is one; a chart version is not, and is shown as it
7
+ // is. The application link opens the deployer's own page, which is where
8
+ // health and sync live: they move without a commit and are not kept here.
9
+
10
+ import { ExternalLink } from "lucide-react";
11
+ import type { Deployment } from "../catalog";
12
+ import { deploymentBasis } from "../catalog";
13
+ import { driftLines } from "../lib/deployment-drift";
14
+ import { Ident } from "./Ident";
15
+ import { RowActions } from "./RowActions";
16
+
17
+ /** The commit on the forge, when the revision is one and the repository is known. */
18
+ function commitHref(deployment: Deployment): string | null {
19
+ if (!deployment.repo || !/^[0-9a-f]{40}$/.test(deployment.revision)) {
20
+ return null;
21
+ }
22
+ const repo = `https://${deployment.repo}`;
23
+ const view = repo.toLowerCase().includes("gitlab") ? "/-/commit/" : "/commit/";
24
+ return `${repo}${view}${deployment.revision}`;
25
+ }
26
+
27
+ /** A commit shortened the way `git log --oneline` shortens it; anything else whole. */
28
+ function shortRevision(revision: string): string {
29
+ return /^[0-9a-f]{40}$/.test(revision) ? revision.slice(0, 7) : revision;
30
+ }
31
+
32
+ function DeploymentRow({ deployment }: { deployment: Deployment }) {
33
+ const href = commitHref(deployment);
34
+ const where = [deployment.cluster, deployment.namespace]
35
+ .filter(Boolean)
36
+ .join(" / ");
37
+ return (
38
+ <div className="row items-start px-2 py-1.5">
39
+ <span className="chip" title="the environment the application is placed in">
40
+ {deployment.environment}
41
+ </span>
42
+ <span className="mono text-muted" title="cluster / namespace">
43
+ {where}
44
+ </span>
45
+ <div className="min-w-0">
46
+ <span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
47
+ {deployment.revision ? (
48
+ href ? (
49
+ <a
50
+ href={href}
51
+ target="_blank"
52
+ rel="noreferrer"
53
+ className="mono text-ink hover:underline"
54
+ title={`${deployment.revision} on the forge`}
55
+ >
56
+ {shortRevision(deployment.revision)}
57
+ </a>
58
+ ) : (
59
+ <Ident value={deployment.revision}>
60
+ {shortRevision(deployment.revision)}
61
+ </Ident>
62
+ )
63
+ ) : (
64
+ <span className="text-muted">not synced yet</span>
65
+ )}
66
+ {deployment.targetRevision ? (
67
+ <span className="text-muted" title="what the application tracks">
68
+ tracks {deployment.targetRevision}
69
+ </span>
70
+ ) : null}
71
+ {deployment.tool ? (
72
+ <span className="chip mono">{deployment.tool}</span>
73
+ ) : null}
74
+ {/* Who said so, when only the tree has: what should run, with
75
+ nothing yet saying it does. A row both spoke for says nothing
76
+ here - agreement is the ordinary case and needs no chip. */}
77
+ {deploymentBasis(deployment) === "manifest" ? (
78
+ <span
79
+ className="chip status-declared"
80
+ title="declared in the GitOps tree; the deployer has not been read for it"
81
+ >
82
+ declared
83
+ </span>
84
+ ) : null}
85
+ {/* The tree and the deployer disagree. The chip carries the
86
+ difference in words, so a reader does not have to open both. */}
87
+ {driftLines(deployment).length > 0 ? (
88
+ <span
89
+ className="chip status-unresolved"
90
+ title={driftLines(deployment).join("\n")}
91
+ >
92
+ drift
93
+ </span>
94
+ ) : null}
95
+ </span>
96
+ {deployment.images?.length ? (
97
+ <ul className="mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5">
98
+ {deployment.images.map((image) => (
99
+ <li key={image}>
100
+ <Ident value={image} className="text-muted">
101
+ {image}
102
+ </Ident>
103
+ </li>
104
+ ))}
105
+ </ul>
106
+ ) : null}
107
+ </div>
108
+ <a
109
+ href={deployment.url}
110
+ target="_blank"
111
+ rel="noreferrer"
112
+ className="inline-flex items-center gap-1 rounded-control text-accent hover:underline"
113
+ title={`${deployment.name} in Argo CD, where its health and sync state are`}
114
+ >
115
+ {deployment.name}
116
+ <ExternalLink size={12} aria-hidden />
117
+ </a>
118
+ <RowActions copy={deployment.name} label={deployment.name} />
119
+ </div>
120
+ );
121
+ }
122
+
123
+ export function DeploymentRows({ deployments }: { deployments: Deployment[] }) {
124
+ return (
125
+ <div className="rows grid-cols-[auto_auto_1fr_auto_auto]">
126
+ {deployments.map((deployment) => (
127
+ <DeploymentRow key={deployment.id} deployment={deployment} />
128
+ ))}
129
+ </div>
130
+ );
131
+ }
@@ -0,0 +1,27 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { describe, expect, it } from "vitest";
3
+ import { DocsLinksContent } from "./DocsLinks";
4
+
5
+ describe("DocsLinks", () => {
6
+ it("searches Confluence by name and opens the Notion workspace", () => {
7
+ const markup = renderToStaticMarkup(
8
+ <DocsLinksContent
9
+ name="shop.oms"
10
+ confluence="https://acme.atlassian.net/wiki/spaces/ARCH"
11
+ notion="https://www.notion.so/acme"
12
+ />,
13
+ );
14
+ expect(markup).toContain("view in Confluence");
15
+ expect(markup).toContain(
16
+ 'href="https://acme.atlassian.net/wiki/search?text=shop.oms&amp;spaces=ARCH"',
17
+ );
18
+ expect(markup).toContain("open Notion");
19
+ expect(markup).toContain('href="https://www.notion.so/acme"');
20
+ });
21
+
22
+ it("renders nothing before either integration is configured", () => {
23
+ expect(
24
+ renderToStaticMarkup(<DocsLinksContent name="shop.oms" confluence="" notion="" />),
25
+ ).toBe("");
26
+ });
27
+ });
@@ -0,0 +1,56 @@
1
+ // The hand-off from a page to where its team writes: a Confluence search by
2
+ // name, and the Notion workspace. Nothing until the reader has configured
3
+ // either under Settings → Integrations; the values live in this browser.
4
+
5
+ import { ExternalLink } from "lucide-react";
6
+ import { confluenceSearchUrl, useConfluence } from "../lib/confluence";
7
+ import { useNotion } from "../lib/notion";
8
+
9
+ const LINK =
10
+ "inline-flex items-center gap-1 whitespace-nowrap rounded-control text-accent hover:underline";
11
+
12
+ export function DocsLinksContent({
13
+ name,
14
+ confluence,
15
+ notion,
16
+ }: {
17
+ /** What to search for: the service's or context's name. */
18
+ name: string;
19
+ confluence: string;
20
+ notion: string;
21
+ }) {
22
+ const search = confluenceSearchUrl(confluence, name);
23
+ if (!search && !notion) return null;
24
+ return (
25
+ <>
26
+ {search ? (
27
+ <a
28
+ href={search}
29
+ target="_blank"
30
+ rel="noreferrer"
31
+ className={LINK}
32
+ title={`Search Confluence for ${name}`}
33
+ >
34
+ view in Confluence <ExternalLink size={12} aria-hidden />
35
+ </a>
36
+ ) : null}
37
+ {notion ? (
38
+ <a
39
+ href={notion}
40
+ target="_blank"
41
+ rel="noreferrer"
42
+ className={LINK}
43
+ title="Open the Notion workspace where the documentation lives"
44
+ >
45
+ open Notion <ExternalLink size={12} aria-hidden />
46
+ </a>
47
+ ) : null}
48
+ </>
49
+ );
50
+ }
51
+
52
+ export function DocsLinks({ name }: { name: string }) {
53
+ const confluence = useConfluence((state) => state.url);
54
+ const notion = useNotion((state) => state.url);
55
+ return <DocsLinksContent name={name} confluence={confluence} notion={notion} />;
56
+ }
@@ -22,6 +22,7 @@ import type { Scope, Shape } from "../lib/shape";
22
22
  import { blockPath, enumPath } from "../routes";
23
23
  import { Ident } from "./Ident";
24
24
  import { KindIcon } from "./kind";
25
+ import { RuleMarks } from "./RuleMarks";
25
26
 
26
27
  /** The words the type chip uses for what `parseType` found. */
27
28
  const CARDINALITY: Record<"many" | "map", string> = {
@@ -31,8 +32,9 @@ const CARDINALITY: Record<"many" | "map", string> = {
31
32
 
32
33
  /**
33
34
  * The type of a field, as a reader wants it: the raw spelling to copy, a
34
- * word for the wrapper it is in, and a way to the page of the shape it
35
- * names. Shared by the table cell and the tree row so both say it the same.
35
+ * word for the wrapper it is in, what the source says the value must
36
+ * satisfy, and a way to the page of the shape it names. Shared by the table
37
+ * cell and the tree row so both say it the same.
36
38
  */
37
39
  export function TypeCell({
38
40
  field,
@@ -70,6 +72,7 @@ export function TypeCell({
70
72
  optional
71
73
  </span>
72
74
  ) : null}
75
+ <RuleMarks field={field} />
73
76
  {page && shape ? (
74
77
  <Link
75
78
  to={page}
@@ -0,0 +1,23 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { expect, it } from "vitest";
3
+ import { HTTPDestinationEvidence } from "./HTTPDestinationEvidence";
4
+
5
+ it("shows the join and the config default behind a resolved destination", () => {
6
+ const html = renderToStaticMarkup(<HTTPDestinationEvidence destination={{
7
+ callSite: "client.go:10", endpointExpression: "c.baseURL + path", method: "POST",
8
+ localPath: "/get-admin-settings", fullPath: "/settings/get-admin-settings",
9
+ baseURL: { expression: "cfg.SettingAddr", configField: "Config.SettingAddr", environmentVariable: "SETTINGS_ADDR", kind: "config-default", value: "http://localhost:8000/settings", source: "config.go:84", optionSource: "modules_tree.go:212" },
10
+ join: { expression: "c.baseURL + path", source: "client.go:10" },
11
+ resolution: { basis: "full-path", provider: "avia.aviaadmin", route: "/settings/get-admin-settings" },
12
+ }} />);
13
+ for (const value of ["Recovered full path", "Config default (runtime may override)", "SETTINGS_ADDR", "modules_tree.go:212", "Config.SettingAddr", "/settings/get-admin-settings", "avia.aviaadmin"]) expect(html).toContain(value);
14
+ expect(html).not.toContain("heuristic");
15
+ });
16
+
17
+ it("labels suffix resolution as a heuristic", () => {
18
+ const html = renderToStaticMarkup(<HTTPDestinationEvidence destination={{
19
+ callSite: "client.go:10", endpointExpression: "/settings", method: "POST",
20
+ resolution: { basis: "unique-suffix", provider: "admin", route: "/admin/settings" },
21
+ }} />);
22
+ expect(html).toContain("Unique route suffix (heuristic)");
23
+ });
@@ -0,0 +1,31 @@
1
+ import type { HTTPDestination } from "../catalog";
2
+
3
+ /** Display the facts for this invocation, including the limits of defaults. */
4
+ export function HTTPDestinationEvidence({ destination: d }: { destination: HTTPDestination }) {
5
+ const basis = d.resolution?.basis;
6
+ const rows = [
7
+ ["Linked by", basis === "full-path" ? "Recovered full path" : basis === "exact-route" ? "Exact route" : basis === "unique-suffix" ? "Unique route suffix (heuristic)" : basis === "kubernetes-host" ? "Kubernetes Service host, from the manifests" : "Unresolved"],
8
+ ["Call site", d.callSite],
9
+ ["Endpoint expression", d.endpointExpression],
10
+ ["Local path", d.localPath],
11
+ ["Base URL expression", d.baseURL?.expression],
12
+ ["Config field", d.baseURL?.configField],
13
+ ["Environment variable", d.baseURL?.environmentVariable],
14
+ [d.baseURL?.kind === "config-default" ? "Config default (runtime may override)" : "Base URL", d.baseURL?.value],
15
+ ["Config source", d.baseURL?.source],
16
+ ["Option call site", d.baseURL?.optionSource],
17
+ ["Service discovery alias", d.serviceDiscoveryAlias],
18
+ ["Join expression", d.join?.expression],
19
+ ["Join source", d.join?.source],
20
+ ["Full path", d.fullPath],
21
+ ...(d.transforms ?? []).map((transform) => ["Runtime URL modifier (not evaluated)", `${transform.expression} · ${transform.source}`]),
22
+ ["Provider", d.resolution?.provider],
23
+ ["Provider route", d.resolution?.route],
24
+ ].filter(([, value]) => value);
25
+ return <section aria-label="HTTP destination evidence" className="border-t border-line px-3 py-3">
26
+ <h3 className="label mb-2">HTTP destination evidence</h3>
27
+ <dl className="grid min-w-0 grid-cols-1 gap-y-3">
28
+ {rows.map(([label, value], index) => <div key={`${label}-${index}`} className="min-w-0"><dt className="text-muted">{label}</dt><dd className="mono break-all text-ink">{value}</dd></div>)}
29
+ </dl>
30
+ </section>;
31
+ }
@@ -210,7 +210,7 @@ export function Integrations({ groups }: { groups: IntegrationGroup[] }) {
210
210
  <RowActions copy={call.id} label={call.id} />
211
211
  {method?.http ? (
212
212
  <p className="mono w-full text-muted">
213
- {method.http.method} {method.http.path}
213
+ {method.http.method ? `${method.http.method} ${method.http.path}` : method.http.path}
214
214
  </p>
215
215
  ) : null}
216
216
  {call.note ? (