@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
@@ -1,13 +1,29 @@
1
- // One problem, as a row: what the near end is, where both ends live, and
2
- // the words for what is wrong with the edge between them. The problems page
3
- // lists every one of these; the overview shows the first few.
1
+ // One problem, as a row: the edge, the words for what is wrong with it,
2
+ // where it is met in the flows, and where the code is.
3
+ //
4
+ // The row is light on purpose: under a rule's heading on the Problems page
5
+ // the rule's words are said once, above, and the row is the edge and its
6
+ // note. On its own - the overview shows the first few - it wears the rule's
7
+ // words too, as a link to the rule. The rule's subject says where the near
8
+ // end leads: an event has a page, a table has a canvas, a channel, a call
9
+ // and a deployment are shown on their service's page.
4
10
 
5
11
  import { Link } from "react-router";
12
+ import { ExternalLink } from "lucide-react";
13
+ import { catalog, index } from "../data";
6
14
  import type { Problem } from "../lib/derive";
7
15
  import { ctxStyle } from "../lib/context-color";
16
+ import type { Kind } from "../lib/kinds";
8
17
  import { staggerStyle } from "../lib/motion";
18
+ import { flowsOfProblem } from "../lib/problem-flows";
19
+ import { useProblemRules } from "../lib/problem-rules";
20
+ import type { RuleSubject } from "../lib/problem-rules";
21
+ import { sourceHref } from "../lib/source-link";
9
22
  import {
23
+ aggregatePath,
10
24
  eventPath,
25
+ flowPath,
26
+ paths,
11
27
  servicePath,
12
28
  storePath,
13
29
  tablePath,
@@ -17,171 +33,175 @@ import { KindIcon } from "./kind";
17
33
  import { Ident } from "./Ident";
18
34
 
19
35
  /** The icon a problem row carries: what the near end of the edge IS. */
20
- const KIND_OF: Record<Problem["kind"], "service" | "event" | "table"> = {
21
- rpc: "service",
36
+ export const ICON_OF: Record<RuleSubject, Kind> = {
37
+ service: "service",
38
+ call: "service",
39
+ copy: "service",
40
+ event: "event",
22
41
  consumer: "event",
23
- "cross-service-fk": "table",
24
- "cross-service-lineage": "table",
25
- "shared-store": "table",
26
- "persistence-drift": "table",
27
- "column-type": "table",
28
- "outbox-payload": "table",
29
- "proto-missing": "service",
30
- "proto-drift": "service",
31
- "shared-channel": "event",
32
- "channel-undeclared": "event",
33
- "channel-unpublished": "service",
34
- "message-encoding": "service",
35
- "subscription-unresolved": "service",
42
+ channel: "event",
43
+ subscription: "service",
44
+ table: "table",
45
+ column: "table",
46
+ deployment: "service",
47
+ flow: "flow",
48
+ aggregate: "aggregate",
36
49
  };
37
50
 
38
- const KIND_NOTE: Record<Problem["kind"], string> = {
39
- rpc: "the provider of this call is not in the catalog",
40
- consumer: "this consumer of the event is not in the catalog",
41
- "cross-service-fk": "foreign key across a service boundary",
42
- "cross-service-lineage": "a value copied from another service's schema",
43
- "shared-store": "a second service writes this database",
44
- "persistence-drift": "this table no longer carries the aggregate it claims",
45
- "column-type": "column type and domain type disagree",
46
- "outbox-payload": "an outbox with no payload column",
47
- "proto-missing":
48
- "the provider is in the catalog but answers on no such method",
49
- "proto-drift": "the vendored proto and provider schema disagree",
50
- "shared-channel": "a second service publishes on this channel",
51
- "channel-undeclared":
52
- "this event goes out on a channel the service does not declare",
53
- "channel-unpublished": "a declared channel no event of this service names",
54
- "message-encoding": "publisher and subscriber use different payload encodings",
55
- "subscription-unresolved":
56
- "nothing in the catalog publishes what this service listens for",
57
- };
51
+ /** A table or a view, whichever the id turns out to name. */
52
+ function relationPath(id: string): string | null {
53
+ return tablePath(id) ?? viewPath(id);
54
+ }
55
+
56
+ /** A column's relation: its id minus the column, which is what the canvas can show. */
57
+ function columnPath(id: string): string | null {
58
+ return relationPath(id.split(".").slice(0, -1).join("."));
59
+ }
58
60
 
59
- /** Where the near end of a problem lives, by what kind of edge it is. */
60
- function nearPath(problem: Problem): string | null {
61
- switch (problem.kind) {
62
- case "rpc":
63
- // The near end is the CALLING service either way. `rpc` is a call whose
64
- // peer is nobody; this is a call whose peer is known and whose method is
65
- // not - and in both the thing to go and look at is the caller.
66
- case "proto-missing":
67
- case "proto-drift":
68
- return servicePath(problem.service);
61
+ /** Where the near end of a problem lives, by what the rule is about. */
62
+ export function nearPath(over: RuleSubject, problem: Problem): string | null {
63
+ switch (over) {
64
+ case "event":
69
65
  case "consumer":
70
- case "channel-undeclared":
71
66
  return eventPath(problem.id);
72
- // The near end is an event when the service has one on the channel, and
73
- // the service itself when the claim comes from its document alone.
74
- case "shared-channel":
75
- return eventPath(problem.id) ?? servicePath(problem.service);
76
- case "channel-unpublished":
77
- case "message-encoding":
78
- case "subscription-unresolved":
79
- return servicePath(problem.service);
80
- case "shared-store":
81
- case "persistence-drift":
82
- case "outbox-payload":
83
- return tablePath(problem.id);
84
- // The id is "<relation>.<column>", so the relation is its own id minus the
85
- // last segment — and the relation is what the canvas can actually show.
86
- case "cross-service-fk":
87
- case "column-type":
88
- return relationPath(problem.id.split(".").slice(0, -1).join("."));
89
- // Lineage is stated on a column, except when a view states it whole, so
90
- // the id is one or the other and both are looked up the same way.
91
- case "cross-service-lineage":
92
- return (
93
- relationPath(problem.id) ??
94
- relationPath(problem.id.split(".").slice(0, -1).join("."))
95
- );
67
+ // A channel row is an event of the service when it has one on the
68
+ // address, and the service itself when the claim comes from its document.
69
+ case "channel":
70
+ return eventPath(problem.id) ?? (problem.service ? servicePath(problem.service) : null);
71
+ case "table":
72
+ return relationPath(problem.id);
73
+ case "column":
74
+ return columnPath(problem.id);
75
+ case "flow":
76
+ return flowPath(problem.id);
77
+ case "aggregate":
78
+ return aggregatePath(problem.id);
79
+ case "service":
80
+ case "call":
81
+ case "copy":
82
+ case "subscription":
83
+ case "deployment":
84
+ return problem.service ? servicePath(problem.service) : null;
96
85
  }
97
86
  }
98
87
 
99
- /** A table or a view, whichever the id turns out to name. */
100
- function relationPath(id: string): string | null {
101
- return tablePath(id) ?? viewPath(id);
88
+ /** Where the far end lives, when it is the id of something the catalog has. */
89
+ function peerPath(peer: string): string | null {
90
+ if (!peer) return null;
91
+ return servicePath(peer) ?? eventPath(peer) ?? relationPath(peer) ?? columnPath(peer) ?? storePath(peer);
102
92
  }
103
93
 
104
- /** Where the far end lives, when the catalog knows it. */
105
- function peerPath(problem: Problem): string | null {
106
- switch (problem.kind) {
107
- case "cross-service-fk":
108
- return tablePath(problem.peer);
109
- case "cross-service-lineage":
110
- return (
111
- relationPath(problem.peer) ??
112
- relationPath(problem.peer.split(".").slice(0, -1).join("."))
113
- );
114
- case "shared-store":
115
- case "shared-channel":
116
- case "message-encoding":
117
- return servicePath(problem.peer);
118
- case "outbox-payload":
119
- return storePath(problem.peer);
120
- default:
121
- return null;
122
- }
94
+ /** An http(s) URL the note ends with, for a row whose far end is a page elsewhere - the deployer's. */
95
+ function urlIn(note: string | undefined): string | null {
96
+ const match = note?.match(/https?:\/\/\S+$/);
97
+ return match ? match[0] : null;
123
98
  }
124
99
 
125
- export function ProblemRow({ problem, index }: { problem: Problem; index: number }) {
126
- const near = nearPath(problem);
127
- const peerTo = peerPath(problem);
128
- const tone =
129
- problem.severity === "error"
130
- ? "var(--status-unresolved)"
131
- : "var(--status-declared)";
100
+ const MAX_FLOWS = 4;
101
+
102
+ export function ProblemRow({
103
+ problem,
104
+ index: at,
105
+ showRule = true,
106
+ }: {
107
+ problem: Problem;
108
+ index: number;
109
+ /** Say which rule made the row. Off under a heading that already says so. */
110
+ showRule?: boolean;
111
+ }) {
112
+ const rules = useProblemRules();
113
+ const rule = rules.find((candidate) => candidate.id === problem.rule);
114
+ const over = rule?.over ?? "service";
115
+ const near = nearPath(over, problem);
116
+ const peerTo = peerPath(problem.peer);
117
+ const note = rule?.note ?? problem.rule;
118
+ const service = problem.service ? index.serviceById.get(problem.service) : undefined;
119
+ const source = problem.source ? sourceHref(problem.source, service) : null;
120
+ const url = over === "deployment" ? urlIn(problem.note) : null;
121
+ const text = url && problem.note ? problem.note.slice(0, problem.note.length - url.length).trim() : problem.note;
122
+ const flows = flowsOfProblem(catalog, index, over, problem);
123
+ const tone = problem.severity === "error" ? "text-unresolved" : "text-declared";
132
124
 
133
125
  return (
134
- <div
135
- className="stagger-in flex flex-wrap items-baseline gap-x-3 gap-y-1 rounded-control border px-3 py-2"
136
- style={{ ...staggerStyle(index), borderColor: tone }}
137
- >
138
- <KindIcon kind={KIND_OF[problem.kind]} />
139
- {near ? (
140
- <Link
141
- to={near}
142
- data-nav-item
143
- className="mono rounded-control text-accent hover:underline"
144
- title={problem.id}
145
- >
146
- {problem.id}
147
- </Link>
148
- ) : (
149
- <Ident value={problem.id} />
150
- )}
151
- <span aria-hidden className="text-muted">
152
-
153
- </span>
154
- {peerTo ? (
155
- <Link
156
- to={peerTo}
157
- className="mono rounded-control hover:underline"
158
- style={{ color: tone }}
159
- title={problem.peer}
160
- >
161
- {problem.peer}
162
- </Link>
163
- ) : (
164
- <Ident
165
- value={problem.peer}
166
- className={
167
- problem.severity === "error" ? "text-unresolved" : "text-declared"
168
- }
169
- title={`${problem.peer} — ${KIND_NOTE[problem.kind]}. Click to copy.`}
170
- />
171
- )}
172
- <span className="chip ctx" style={ctxStyle(problem.context)}>
173
- <span aria-hidden className="dot" />
174
- {problem.context}
175
- </span>
176
- <span className="mono ml-auto text-muted">{KIND_NOTE[problem.kind]}</span>
177
- {problem.note ? (
178
- <p className="w-full border-l-2 pl-2 border-line-strong text-muted">
179
- {problem.note}
180
- </p>
181
- ) : null}
182
- {problem.source ? (
183
- <div className="mono w-full text-muted">
184
- <Ident value={problem.source} />
126
+ <div className="stagger-in px-3 py-2.5" style={staggerStyle(at)} data-rule={problem.rule}>
127
+ <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
128
+ <KindIcon kind={ICON_OF[over]} className="self-center" />
129
+ {near ? (
130
+ <Link to={near} data-nav-item className="mono rounded-control text-accent hover:underline" title={problem.id}>
131
+ {problem.id}
132
+ </Link>
133
+ ) : (
134
+ <Ident value={problem.id} />
135
+ )}
136
+ {/* A rule may name no far end; then there is no arrow to draw. */}
137
+ {problem.peer ? (
138
+ <>
139
+ <span aria-hidden className="text-faint">
140
+
141
+ </span>
142
+ {peerTo ? (
143
+ <Link to={peerTo} className={`mono rounded-control hover:underline ${tone}`} title={problem.peer}>
144
+ {problem.peer}
145
+ </Link>
146
+ ) : (
147
+ <Ident value={problem.peer} className={tone} title={`${problem.peer} — ${note}. Click to copy.`} />
148
+ )}
149
+ </>
150
+ ) : null}
151
+ {problem.context ? (
152
+ <span className="chip ctx" style={ctxStyle(problem.context)}>
153
+ <span aria-hidden className="dot" />
154
+ {problem.context}
155
+ </span>
156
+ ) : null}
157
+ {showRule ? (
158
+ <Link
159
+ to={`${paths.settingsRules()}#rule-${problem.rule}`}
160
+ className="mono ml-auto max-w-[45%] truncate rounded-control text-muted hover:text-ink hover:underline"
161
+ title={`rule ${problem.rule}${rule?.action ? ` — ${rule.action}` : ""}`}
162
+ >
163
+ {note}
164
+ </Link>
165
+ ) : null}
166
+ </div>
167
+ {text ? <p className="mt-1 max-w-prose text-muted">{text}</p> : null}
168
+ {flows.length > 0 || source || url ? (
169
+ <div className="mono mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-muted">
170
+ {flows.length > 0 ? (
171
+ <span className="flex flex-wrap items-center gap-1">
172
+ <span className="text-faint">in</span>
173
+ {flows.slice(0, MAX_FLOWS).map((hit) => (
174
+ <Link
175
+ key={hit.flow.id}
176
+ to={hit.stepId ? paths.flowStep(hit.flow.slug, hit.stepId) : paths.flow(hit.flow.slug)}
177
+ className="chip border-line-strong hover:border-accent hover:text-accent"
178
+ title={hit.stepId ? `${hit.flow.name}, step ${hit.number}` : hit.flow.name}
179
+ >
180
+ {hit.flow.name}
181
+ {hit.number ? <span className="text-faint">·{hit.number}</span> : null}
182
+ </Link>
183
+ ))}
184
+ {flows.length > MAX_FLOWS ? (
185
+ <span className="text-faint" title={flows.slice(MAX_FLOWS).map((hit) => hit.flow.name).join(", ")}>
186
+ +{flows.length - MAX_FLOWS}
187
+ </span>
188
+ ) : null}
189
+ </span>
190
+ ) : null}
191
+ {problem.source ? (
192
+ source ? (
193
+ <a href={source} target="_blank" rel="noreferrer" className="truncate rounded-control text-accent hover:underline" title={problem.source}>
194
+ {problem.source} ↗
195
+ </a>
196
+ ) : (
197
+ <Ident value={problem.source} className="truncate" />
198
+ )
199
+ ) : null}
200
+ {url ? (
201
+ <a href={url} target="_blank" rel="noreferrer" className="flex items-center gap-1 rounded-control text-accent hover:underline">
202
+ <ExternalLink size={12} aria-hidden /> open in the deployer
203
+ </a>
204
+ ) : null}
185
205
  </div>
186
206
  ) : null}
187
207
  </div>
@@ -0,0 +1,22 @@
1
+ // What the source says a field's value must satisfy, as marks beside the
2
+ // type: "required", "≥ 1 chars", "one of USD, EUR". One component so the
3
+ // schema table, the field tree and the inline shape of a request say it the
4
+ // same way; the words come from lib/rules.
5
+
6
+ import type { Field } from "../catalog";
7
+ import { ruleMarks } from "../lib/rules";
8
+
9
+ export function RuleMarks({ field }: { field: Field }) {
10
+ const marks = ruleMarks(field);
11
+ if (marks.length === 0) return null;
12
+
13
+ return (
14
+ <>
15
+ {marks.map((mark) => (
16
+ <span key={mark.title} className="type-mark rule-mark" title={mark.title}>
17
+ {mark.text}
18
+ </span>
19
+ ))}
20
+ </>
21
+ );
22
+ }
@@ -10,6 +10,7 @@ import { Fragment, useState } from "react";
10
10
  import { ChevronDown, ChevronRight } from "lucide-react";
11
11
  import type { Field, RpcEnum } from "../catalog";
12
12
  import { parseType } from "../lib/shape";
13
+ import { RuleMarks } from "./RuleMarks";
13
14
 
14
15
  /**
15
16
  * The enum a field's type names, among the ones the interface declares. By
@@ -91,26 +92,29 @@ export function ShapeRows({
91
92
  primitive, so a reader can tell which names are worth following
92
93
  without the row becoming a link it is not. */}
93
94
  <td className="mono py-0.5 pr-3 whitespace-nowrap text-muted">
94
- {set ? (
95
- <button
96
- type="button"
97
- onClick={() => toggle(field.name)}
98
- aria-expanded={shown}
99
- className="inline-flex items-center gap-1 rounded-control hover:text-ink"
100
- title={`${set.name}: ${set.values.length} values click to ${shown ? "hide" : "show"} them`}
101
- >
102
- {shown ? (
103
- <ChevronDown size={11} aria-hidden />
104
- ) : (
105
- <ChevronRight size={11} aria-hidden />
106
- )}
107
- {field.type}
108
- </button>
109
- ) : field.ref ? (
110
- `${field.type} →`
111
- ) : (
112
- field.type
113
- )}
95
+ <span className="inline-flex items-center gap-1.5">
96
+ {set ? (
97
+ <button
98
+ type="button"
99
+ onClick={() => toggle(field.name)}
100
+ aria-expanded={shown}
101
+ className="inline-flex items-center gap-1 rounded-control hover:text-ink"
102
+ title={`${set.name}: ${set.values.length} values — click to ${shown ? "hide" : "show"} them`}
103
+ >
104
+ {shown ? (
105
+ <ChevronDown size={11} aria-hidden />
106
+ ) : (
107
+ <ChevronRight size={11} aria-hidden />
108
+ )}
109
+ {field.type}
110
+ </button>
111
+ ) : field.ref ? (
112
+ `${field.type} →`
113
+ ) : (
114
+ field.type
115
+ )}
116
+ <RuleMarks field={field} />
117
+ </span>
114
118
  </td>
115
119
  <td className="py-0.5 text-muted">{field.doc}</td>
116
120
  </tr>
package/src/data.ts CHANGED
@@ -63,6 +63,22 @@ export const activeCatalogProfile = catalogProfileNamed(
63
63
 
64
64
  export const activeCatalogDocs = catalogDocs(manifestJson, activeCatalogProfile.id, import.meta.env.BASE_URL);
65
65
 
66
+ // The manifest is imported, so a change to it would reload the page - and
67
+ // the page changes it: keeping a recording widens a verify step, adding a
68
+ // project appends one. Neither touches what this module derived from it (the
69
+ // profiles: which sources make which catalog), and in local mode the setup
70
+ // itself is read from the server, not from here. So the update is taken
71
+ // without a reload unless the profiles did change, in which case the reload
72
+ // is the right answer and is asked for.
73
+ if (import.meta.hot) {
74
+ const profileKey = (m: CatalogProfileManifest & { sources?: string[] }) =>
75
+ JSON.stringify(profilesFromManifest(m).map((p) => [p.id, p.sources, p.contexts]));
76
+ import.meta.hot.accept("../portolan.json", (next) => {
77
+ const incoming = (next as { default?: CatalogProfileManifest & { sources?: string[] } } | undefined)?.default;
78
+ if (!incoming || profileKey(incoming) !== profileKey(manifest)) import.meta.hot?.invalidate();
79
+ });
80
+ }
81
+
66
82
  /**
67
83
  * Where sources are looked for. The patterns are written out because
68
84
  * import.meta.glob resolves at build time and needs literals - and because
@@ -78,6 +94,7 @@ export const SOURCE_GLOBS = [
78
94
  "examples/*/*/*/portolan/*.json",
79
95
  "vendor/repos/*/*/git.repo.json",
80
96
  "vendor/repos/**/portolan/*.json",
97
+ "examples/argocd/argocd.apps.json",
81
98
  ] as const;
82
99
 
83
100
  const modules: Record<string, unknown> = {
@@ -103,6 +120,10 @@ const modules: Record<string, unknown> = {
103
120
  eager: true,
104
121
  import: "default",
105
122
  }),
123
+ ...import.meta.glob("../examples/argocd/argocd.apps.json", {
124
+ eager: true,
125
+ import: "default",
126
+ }),
106
127
  };
107
128
 
108
129
  /** What the app draws when the catalog cannot be trusted: nothing. */
@@ -11,11 +11,14 @@ import type {
11
11
  Service,
12
12
  Status,
13
13
  } from "./catalog";
14
- import { validateCatalog, walkSteps } from "./catalog";
14
+ import { buildIndex, validateCatalog, walkSteps } from "./catalog";
15
15
  import { enrichCatalog } from "./enrich";
16
16
  import { contextMap } from "./lib/context-map";
17
17
  import { mergeCatalogs } from "./merge";
18
- import { problems } from "./lib/derive";
18
+ import { builtinProblems } from "./lib/problem-rules";
19
+
20
+ // The unresolved edges, as the two shipped rules about them find them.
21
+ const problems = (of: Catalog) => builtinProblems(of, buildIndex(of), ["rpc", "consumer"]);
19
22
 
20
23
  // ---------------------------------------------------------------------------
21
24
  // A tiny estate: two services, one event, one method, and whatever flow the
@@ -479,6 +482,92 @@ describe("enrichCatalog: HTTP route correlation", () => {
479
482
  ]);
480
483
  });
481
484
 
485
+ // The same two providers, and now the manifests say which name each one
486
+ // answers on. The host the call names decides between them; a host nobody
487
+ // answers on decides nothing.
488
+ it("lets the manifests decide between two providers of one route", () => {
489
+ const toHost = (host: string): Service["consumes"][number] => ({
490
+ id: `http-client/POST /foo @ ${host}`,
491
+ peer: host.replace(".", "-"),
492
+ status: "unresolved",
493
+ source: "client.go:11",
494
+ destination: {
495
+ callSite: "client.go:11",
496
+ endpointExpression: "path",
497
+ method: "POST",
498
+ localPath: "/foo",
499
+ fullPath: "/foo",
500
+ serviceDiscoveryAlias: host,
501
+ },
502
+ });
503
+ const caller = service("shop", "oms", {
504
+ consumes: [
505
+ toHost("payments.internal"),
506
+ toHost("ledger.internal"),
507
+ toHost("nobody.internal"),
508
+ ],
509
+ });
510
+ const payments = {
511
+ ...httpProvider("payments", "pay", "/foo"),
512
+ hosts: ["payments", "payments.internal"],
513
+ };
514
+ const ledger = {
515
+ ...httpProvider("ledger", "post", "/foo"),
516
+ hosts: ["ledger.internal"],
517
+ };
518
+
519
+ const out = enrichCatalog(estate([], [caller, payments, ledger])).catalog;
520
+ expect(
521
+ serviceOf(out, "shop.oms").consumes.map((call) => [
522
+ call.id,
523
+ call.peer,
524
+ call.status,
525
+ call.destination?.resolution?.basis,
526
+ ]),
527
+ ).toEqual([
528
+ ["api/pay", "shop.payments", "declared", "kubernetes-host"],
529
+ ["api/post", "shop.ledger", "declared", "kubernetes-host"],
530
+ [
531
+ "http-client/POST /foo @ nobody.internal",
532
+ "nobody-internal",
533
+ "unresolved",
534
+ undefined,
535
+ ],
536
+ ]);
537
+ });
538
+
539
+ it("uses what the caller dials when the call names no host", () => {
540
+ const caller = service("shop", "oms", {
541
+ consumes: [
542
+ {
543
+ id: "http-client/POST /foo",
544
+ peer: "http-peer",
545
+ status: "unresolved",
546
+ source: "client.go:10",
547
+ },
548
+ ],
549
+ dials: ["ledger.payments.svc"],
550
+ });
551
+ const payments = {
552
+ ...httpProvider("payments", "pay", "/foo"),
553
+ hosts: ["payments.shop.svc"],
554
+ };
555
+ const ledger = {
556
+ ...httpProvider("ledger", "post", "/foo"),
557
+ hosts: ["ledger.payments.svc"],
558
+ };
559
+
560
+ const out = enrichCatalog(estate([], [caller, payments, ledger])).catalog;
561
+ expect(
562
+ serviceOf(out, "shop.oms").consumes.map((call) => [
563
+ call.id,
564
+ call.peer,
565
+ call.status,
566
+ call.destination?.resolution?.basis,
567
+ ]),
568
+ ).toEqual([["api/post", "shop.ledger", "declared", "kubernetes-host"]]);
569
+ });
570
+
482
571
  it("resolves a destination-qualified route like a bare one", () => {
483
572
  const caller = service("shop", "oms", {
484
573
  consumes: [
@@ -1038,7 +1127,7 @@ describe("enrichCatalog: consumers from event steps", () => {
1038
1127
  ["risk", "unresolved"],
1039
1128
  ["ghost.svc", "unresolved"],
1040
1129
  ]);
1041
- expect(problems(catalog).map((p) => [p.kind, p.peer])).toEqual([
1130
+ expect(problems(catalog).map((p) => [p.rule, p.peer])).toEqual([
1042
1131
  ["consumer", "risk"],
1043
1132
  ["consumer", "ghost.svc"],
1044
1133
  ]);
@@ -1677,6 +1766,41 @@ describe("enrichCatalog: a foreign key into another service's table", () => {
1677
1766
  expect(keyOf(enrichCatalog(catalog).catalog)).toBe("orders");
1678
1767
  });
1679
1768
 
1769
+ it("tells two tables of one name apart by who the referencing service talks to", () => {
1770
+ // The delivery service calls the order service; an unrelated shop in
1771
+ // the same estate has an `orders` table too.
1772
+ const catalog = estateWithStores();
1773
+ catalog.contexts.push(
1774
+ context("delivery", [
1775
+ service("delivery", "core", {
1776
+ consumes: [{ id: "oms.v1.Orders/GetOrder", peer: "shop.oms", status: "declared", source: "x" }],
1777
+ }),
1778
+ ]),
1779
+ context("commerce", [service("commerce", "bagisto")]),
1780
+ );
1781
+ catalog.stores!.push({
1782
+ id: "commerce.bagisto.db",
1783
+ slug: "db",
1784
+ name: "Bagisto database",
1785
+ kind: "mysql",
1786
+ owner: "commerce.bagisto",
1787
+ tables: [{ id: "commerce.bagisto.db.orders", name: "orders", columns: [] }],
1788
+ });
1789
+
1790
+ expect(keyOf(enrichCatalog(catalog).catalog)).toBe("shop.oms.pg.orders");
1791
+ });
1792
+
1793
+ it("prefers a table of the store's own before looking across the estate", () => {
1794
+ const catalog = estateWithStores();
1795
+ catalog.stores![0]!.tables.push({
1796
+ id: "delivery.core.pg.orders",
1797
+ name: "orders",
1798
+ columns: [{ name: "id", type: "text", nullable: false, pk: true }],
1799
+ });
1800
+
1801
+ expect(keyOf(enrichCatalog(catalog).catalog)).toBe("delivery.core.pg.orders");
1802
+ });
1803
+
1680
1804
  it("leaves a key that already resolves untouched, and enriching twice changes nothing", () => {
1681
1805
  const once = enrichCatalog(estateWithStores()).catalog;
1682
1806
  const twice = enrichCatalog(once).catalog;