@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.
- package/README.md +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -1,13 +1,29 @@
|
|
|
1
|
-
// One problem, as a row:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
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
|
|
60
|
-
function nearPath(problem: Problem): string | null {
|
|
61
|
-
switch (
|
|
62
|
-
case "
|
|
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
|
-
//
|
|
73
|
-
// the service itself when the claim comes from its document
|
|
74
|
-
case "
|
|
75
|
-
return eventPath(problem.id) ?? servicePath(problem.service);
|
|
76
|
-
case "
|
|
77
|
-
|
|
78
|
-
case "
|
|
79
|
-
return
|
|
80
|
-
case "
|
|
81
|
-
|
|
82
|
-
case "
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
case "
|
|
87
|
-
case "
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
/**
|
|
100
|
-
function
|
|
101
|
-
|
|
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
|
-
/**
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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="
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
problem.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
</
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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. */
|
package/src/enrich.test.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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;
|