@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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Which services the estate's paths run through.
|
|
2
|
+
//
|
|
3
|
+
// The dependency graph shows who talks to whom; the context map shows which
|
|
4
|
+
// contexts depend on which. Neither answers a third question a reader asks
|
|
5
|
+
// before touching a service: if this one goes, what stops reaching what? A
|
|
6
|
+
// service with three neighbours can be the only road between two contexts,
|
|
7
|
+
// and a service with twelve can be a leaf of every one of them. Degree does
|
|
8
|
+
// not tell them apart. Betweenness does: the share of shortest paths between
|
|
9
|
+
// every pair of services that pass through this one.
|
|
10
|
+
//
|
|
11
|
+
// The graph is the same two facts the context map is drawn from - an event
|
|
12
|
+
// with a consumer, a call with a peer - collapsed to one undirected edge per
|
|
13
|
+
// pair of services. Undirected, because the question is "who is between",
|
|
14
|
+
// and a reader who asks it means the road, not which way the traffic runs.
|
|
15
|
+
// Unweighted, because a pair joined by one call and a pair joined by twenty
|
|
16
|
+
// are both joined.
|
|
17
|
+
//
|
|
18
|
+
// Two things are counted, and the page says which is which:
|
|
19
|
+
//
|
|
20
|
+
// betweenness - Brandes' number, normalised so 1 means every shortest path
|
|
21
|
+
// between every other pair runs through this service. It ranks.
|
|
22
|
+
// between - the pairs of CONTEXTS for which more than half of the
|
|
23
|
+
// shortest paths between their services run through this one.
|
|
24
|
+
// It is the finding: "auth reaches payments only through cart".
|
|
25
|
+
//
|
|
26
|
+
// Consumers with no service of their own and calls to a peer the catalog does
|
|
27
|
+
// not know are left out. A ghost has no context to be between, and an
|
|
28
|
+
// unresolved peer is a problem the problems page already lists.
|
|
29
|
+
|
|
30
|
+
import type { Catalog } from "../catalog";
|
|
31
|
+
|
|
32
|
+
/** A pair of contexts this service stands between. */
|
|
33
|
+
export interface Brokered {
|
|
34
|
+
/** The two contexts, in catalog order. */
|
|
35
|
+
a: string;
|
|
36
|
+
b: string;
|
|
37
|
+
/** Share of shortest paths between the two that pass through the service. */
|
|
38
|
+
share: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Centrality {
|
|
42
|
+
id: string;
|
|
43
|
+
context: string;
|
|
44
|
+
/** Distinct services it exchanges anything with. */
|
|
45
|
+
degree: number;
|
|
46
|
+
/** Normalised to 0..1; 0 on an estate of fewer than three services. */
|
|
47
|
+
betweenness: number;
|
|
48
|
+
between: Brokered[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The share of a context pair's paths a service must carry MORE than to be
|
|
53
|
+
* said to stand between them. Half, strictly: two equal roads are two roads,
|
|
54
|
+
* and a service on one of them is not what the other side depends on.
|
|
55
|
+
*/
|
|
56
|
+
export const BROKER_SHARE = 0.5;
|
|
57
|
+
|
|
58
|
+
export interface ServiceGraph {
|
|
59
|
+
/** Service ids in catalog order. */
|
|
60
|
+
nodes: string[];
|
|
61
|
+
/** Service id -> context id. */
|
|
62
|
+
context: Map<string, string>;
|
|
63
|
+
/** Undirected adjacency, neighbours in first-seen order. */
|
|
64
|
+
adjacent: Map<string, string[]>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One undirected edge per pair of catalog services that exchange anything. */
|
|
68
|
+
export function serviceGraph(catalog: Catalog): ServiceGraph {
|
|
69
|
+
const nodes: string[] = [];
|
|
70
|
+
const context = new Map<string, string>();
|
|
71
|
+
for (const ctx of catalog.contexts) {
|
|
72
|
+
for (const service of ctx.services) {
|
|
73
|
+
nodes.push(service.id);
|
|
74
|
+
context.set(service.id, ctx.id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const adjacent = new Map<string, string[]>(nodes.map((id) => [id, []]));
|
|
79
|
+
const join = (a: string, b: string): void => {
|
|
80
|
+
if (a === b || !context.has(a) || !context.has(b)) return;
|
|
81
|
+
const ofA = adjacent.get(a) as string[];
|
|
82
|
+
if (ofA.includes(b)) return;
|
|
83
|
+
ofA.push(b);
|
|
84
|
+
(adjacent.get(b) as string[]).push(a);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (const ctx of catalog.contexts) {
|
|
88
|
+
for (const service of ctx.services) {
|
|
89
|
+
for (const aggregate of service.aggregates) {
|
|
90
|
+
for (const event of aggregate.events) {
|
|
91
|
+
for (const consumer of event.consumers) join(service.id, consumer.service);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const call of service.consumes) join(service.id, call.peer);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { nodes, context, adjacent };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Brandes' algorithm, with the dependency of each source split by the target's
|
|
102
|
+
* context, so the same pass that sums betweenness also says which context
|
|
103
|
+
* pairs a service carries.
|
|
104
|
+
*/
|
|
105
|
+
export function centrality(catalog: Catalog): Centrality[] {
|
|
106
|
+
const graph = serviceGraph(catalog);
|
|
107
|
+
const { nodes, context, adjacent } = graph;
|
|
108
|
+
const n = nodes.length;
|
|
109
|
+
const order = catalog.contexts.map((c) => c.id);
|
|
110
|
+
const rank = new Map(order.map((id, i) => [id, i]));
|
|
111
|
+
|
|
112
|
+
const raw = new Map<string, number>(nodes.map((id) => [id, 0]));
|
|
113
|
+
// node -> (source context ~ target context) -> summed dependency.
|
|
114
|
+
const carried = new Map<string, Map<string, number>>(
|
|
115
|
+
nodes.map((id) => [id, new Map()]),
|
|
116
|
+
);
|
|
117
|
+
// source -> target context -> reachable targets (the source excluded).
|
|
118
|
+
const reach = new Map<string, Map<string, number>>();
|
|
119
|
+
|
|
120
|
+
for (const s of nodes) {
|
|
121
|
+
const stack: string[] = [];
|
|
122
|
+
const pred = new Map<string, string[]>(nodes.map((id) => [id, []]));
|
|
123
|
+
const sigma = new Map<string, number>(nodes.map((id) => [id, 0]));
|
|
124
|
+
const dist = new Map<string, number>(nodes.map((id) => [id, -1]));
|
|
125
|
+
sigma.set(s, 1);
|
|
126
|
+
dist.set(s, 0);
|
|
127
|
+
const queue: string[] = [s];
|
|
128
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
129
|
+
const v = queue[head] as string;
|
|
130
|
+
stack.push(v);
|
|
131
|
+
const dv = dist.get(v) as number;
|
|
132
|
+
for (const w of adjacent.get(v) as string[]) {
|
|
133
|
+
if ((dist.get(w) as number) < 0) {
|
|
134
|
+
dist.set(w, dv + 1);
|
|
135
|
+
queue.push(w);
|
|
136
|
+
}
|
|
137
|
+
if ((dist.get(w) as number) === dv + 1) {
|
|
138
|
+
sigma.set(w, (sigma.get(w) as number) + (sigma.get(v) as number));
|
|
139
|
+
(pred.get(w) as string[]).push(v);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const seen = new Map<string, number>();
|
|
145
|
+
for (const t of stack) {
|
|
146
|
+
if (t === s) continue;
|
|
147
|
+
const c = context.get(t) as string;
|
|
148
|
+
seen.set(c, (seen.get(c) ?? 0) + 1);
|
|
149
|
+
}
|
|
150
|
+
reach.set(s, seen);
|
|
151
|
+
|
|
152
|
+
// delta[w][targetContext]: dependency of s on w, over targets in that context.
|
|
153
|
+
const delta = new Map<string, Map<string, number>>(
|
|
154
|
+
nodes.map((id) => [id, new Map()]),
|
|
155
|
+
);
|
|
156
|
+
while (stack.length > 0) {
|
|
157
|
+
const w = stack.pop() as string;
|
|
158
|
+
const dw = delta.get(w) as Map<string, number>;
|
|
159
|
+
const own = context.get(w) as string;
|
|
160
|
+
for (const v of pred.get(w) as string[]) {
|
|
161
|
+
const ratio = (sigma.get(v) as number) / (sigma.get(w) as number);
|
|
162
|
+
const dv = delta.get(v) as Map<string, number>;
|
|
163
|
+
dv.set(own, (dv.get(own) ?? 0) + ratio);
|
|
164
|
+
for (const [c, d] of dw) dv.set(c, (dv.get(c) ?? 0) + ratio * d);
|
|
165
|
+
}
|
|
166
|
+
if (w === s) continue;
|
|
167
|
+
const from = context.get(s) as string;
|
|
168
|
+
const bucket = carried.get(w) as Map<string, number>;
|
|
169
|
+
let sum = 0;
|
|
170
|
+
for (const [to, d] of dw) {
|
|
171
|
+
sum += d;
|
|
172
|
+
const key = pairKey(from, to, rank);
|
|
173
|
+
bucket.set(key, (bucket.get(key) ?? 0) + d);
|
|
174
|
+
}
|
|
175
|
+
raw.set(w, (raw.get(w) as number) + sum);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Ordered (u, v) pairs with u in A and v in B, both reachable, any u and v.
|
|
180
|
+
const pairs = (a: string, b: string): number => {
|
|
181
|
+
let count = 0;
|
|
182
|
+
for (const s of nodes) {
|
|
183
|
+
if (context.get(s) !== a) continue;
|
|
184
|
+
count += reach.get(s)?.get(b) ?? 0;
|
|
185
|
+
}
|
|
186
|
+
return count;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const scale = n < 3 ? 0 : 2 / ((n - 1) * (n - 2));
|
|
190
|
+
return nodes.map((id) => {
|
|
191
|
+
const own = context.get(id) as string;
|
|
192
|
+
const between: Brokered[] = [];
|
|
193
|
+
for (const [key, sum] of carried.get(id) as Map<string, number>) {
|
|
194
|
+
const [a, b] = key.split("~") as [string, string];
|
|
195
|
+
// Every (u, v) was counted from both ends; and pairs with the service
|
|
196
|
+
// itself at an end are roads it is on, not roads it is between.
|
|
197
|
+
let total = a === b ? pairs(a, a) / 2 : pairs(a, b);
|
|
198
|
+
if (own === a) total -= reach.get(id)?.get(b) ?? 0;
|
|
199
|
+
else if (own === b) total -= reach.get(id)?.get(a) ?? 0;
|
|
200
|
+
const share = total > 0 ? sum / 2 / total : 0;
|
|
201
|
+
if (a !== b && share > BROKER_SHARE) between.push({ a, b, share });
|
|
202
|
+
}
|
|
203
|
+
between.sort(
|
|
204
|
+
(x, y) =>
|
|
205
|
+
y.share - x.share ||
|
|
206
|
+
(rank.get(x.a) ?? 0) - (rank.get(y.a) ?? 0) ||
|
|
207
|
+
(rank.get(x.b) ?? 0) - (rank.get(y.b) ?? 0),
|
|
208
|
+
);
|
|
209
|
+
return {
|
|
210
|
+
id,
|
|
211
|
+
context: own,
|
|
212
|
+
degree: (adjacent.get(id) as string[]).length,
|
|
213
|
+
betweenness: ((raw.get(id) as number) / 2) * scale,
|
|
214
|
+
between,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function pairKey(a: string, b: string, rank: Map<string, number>): string {
|
|
220
|
+
return (rank.get(a) ?? 0) <= (rank.get(b) ?? 0) ? `${a}~${b}` : `${b}~${a}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The services worth a reader's attention: on some shortest path, and the
|
|
225
|
+
* main road between at least one pair of contexts. Highest betweenness first,
|
|
226
|
+
* catalog order between equals.
|
|
227
|
+
*/
|
|
228
|
+
export function bridges(catalog: Catalog): Centrality[] {
|
|
229
|
+
return centrality(catalog)
|
|
230
|
+
.filter((c) => c.betweenness > 0 && c.between.length > 0)
|
|
231
|
+
.sort((x, y) => y.betweenness - x.betweenness);
|
|
232
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { confluenceSearchUrl } from "./confluence";
|
|
3
|
+
|
|
4
|
+
describe("Confluence integration", () => {
|
|
5
|
+
it("searches the whole Cloud site from a site URL", () => {
|
|
6
|
+
expect(confluenceSearchUrl("https://acme.atlassian.net/wiki", "shop.oms")).toBe(
|
|
7
|
+
"https://acme.atlassian.net/wiki/search?text=shop.oms",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("narrows the search to the space a Cloud URL names", () => {
|
|
12
|
+
expect(
|
|
13
|
+
confluenceSearchUrl(
|
|
14
|
+
"https://acme.atlassian.net/wiki/spaces/ARCH/overview",
|
|
15
|
+
"Order management",
|
|
16
|
+
),
|
|
17
|
+
).toBe(
|
|
18
|
+
"https://acme.atlassian.net/wiki/search?text=Order+management&spaces=ARCH",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses the Server search page behind a context path", () => {
|
|
23
|
+
expect(
|
|
24
|
+
confluenceSearchUrl("https://wiki.example/confluence/display/ARCH", "shop.oms"),
|
|
25
|
+
).toBe(
|
|
26
|
+
"https://wiki.example/confluence/dosearchsite.action?queryString=shop.oms&where=ARCH",
|
|
27
|
+
);
|
|
28
|
+
expect(confluenceSearchUrl("https://wiki.example/", "shop.oms")).toBe(
|
|
29
|
+
"https://wiki.example/dosearchsite.action?queryString=shop.oms",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("offers nothing before a URL is configured or when it is unsafe", () => {
|
|
34
|
+
expect(confluenceSearchUrl("", "shop.oms")).toBeNull();
|
|
35
|
+
expect(confluenceSearchUrl("javascript:alert(1)", "shop.oms")).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The optional hand-off from a catalogued service or context to the wiki
|
|
2
|
+
// where its team writes.
|
|
3
|
+
//
|
|
4
|
+
// The catalog carries no page ids, so the link is a search by name: the one
|
|
5
|
+
// address Confluence answers for any installation. A site URL searches the
|
|
6
|
+
// whole site; a URL that names a space narrows the search to it. Cloud and
|
|
7
|
+
// Server answer at different paths, told apart by the `/wiki` prefix Cloud
|
|
8
|
+
// always has.
|
|
9
|
+
|
|
10
|
+
import { createIntegrationStore, normalizeIntegrationUrl } from "./integration-url";
|
|
11
|
+
|
|
12
|
+
export const CONFLUENCE_KEY = "portolan.integrations.confluence";
|
|
13
|
+
|
|
14
|
+
/** The search page for `name`, on the site or in the space the URL names. */
|
|
15
|
+
export function confluenceSearchUrl(
|
|
16
|
+
configured: string,
|
|
17
|
+
name: string,
|
|
18
|
+
): string | null {
|
|
19
|
+
const normalized = normalizeIntegrationUrl(configured);
|
|
20
|
+
if (!normalized) return null;
|
|
21
|
+
const url = new URL(normalized);
|
|
22
|
+
const path = url.pathname;
|
|
23
|
+
const space = path.match(/\/(?:spaces|display)\/([^/]+)/)?.[1];
|
|
24
|
+
const cloud = path.match(/^(.*\/wiki)(?:\/.*)?$/)?.[1];
|
|
25
|
+
const params = new URLSearchParams();
|
|
26
|
+
if (cloud !== undefined) {
|
|
27
|
+
url.pathname = `${cloud}/search`;
|
|
28
|
+
params.set("text", name);
|
|
29
|
+
if (space) params.set("spaces", space);
|
|
30
|
+
} else {
|
|
31
|
+
const base = path.replace(/\/(?:spaces|display)\/.*$/, "").replace(/\/$/, "");
|
|
32
|
+
url.pathname = `${base}/dosearchsite.action`;
|
|
33
|
+
params.set("queryString", name);
|
|
34
|
+
if (space) params.set("where", space);
|
|
35
|
+
}
|
|
36
|
+
url.search = params.toString();
|
|
37
|
+
url.hash = "";
|
|
38
|
+
return url.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const useConfluence = createIntegrationStore(CONFLUENCE_KEY);
|
package/src/lib/context-color.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CSSProperties } from "react";
|
|
2
2
|
import { catalog } from "../data";
|
|
3
3
|
|
|
4
|
-
const CTX_SLOTS = 6;
|
|
4
|
+
export const CTX_SLOTS = 6;
|
|
5
5
|
|
|
6
6
|
/** Context colour is assigned deterministically by index in catalog order. */
|
|
7
7
|
const order: string[] = catalog.contexts.map((c) => c.id);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The fields the GitOps tree and the deployer disagree on, said in one line
|
|
2
|
+
// each. Read by the deployment rows on a service's page and by the
|
|
3
|
+
// `deployment` subject a rule sees, so the two say the same words.
|
|
4
|
+
|
|
5
|
+
import type { Deployment } from "../catalog";
|
|
6
|
+
|
|
7
|
+
export function driftLines(deployment: Deployment): string[] {
|
|
8
|
+
const drift = deployment.drift;
|
|
9
|
+
if (!drift) return [];
|
|
10
|
+
const lines: string[] = [];
|
|
11
|
+
if (drift.project) lines.push(`project ${drift.project} in the tree, ${deployment.project} deployed`);
|
|
12
|
+
if (drift.cluster) lines.push(`cluster ${drift.cluster} in the tree, ${deployment.cluster} deployed`);
|
|
13
|
+
if (drift.namespace) lines.push(`namespace ${drift.namespace} in the tree, ${deployment.namespace} deployed`);
|
|
14
|
+
if (drift.path) lines.push(`path ${drift.path} in the tree, ${deployment.path} deployed`);
|
|
15
|
+
if (drift.targetRevision) {
|
|
16
|
+
lines.push(`tracks ${drift.targetRevision} in the tree, ${deployment.targetRevision} deployed`);
|
|
17
|
+
}
|
|
18
|
+
if (drift.images?.length) {
|
|
19
|
+
lines.push(`pins ${drift.images.join(", ")} in the tree; running ${(deployment.images ?? []).join(", ") || "nothing"}`);
|
|
20
|
+
}
|
|
21
|
+
return lines;
|
|
22
|
+
}
|
package/src/lib/derive.ts
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
// so every number a Phase 2 page shows can be asserted in a test.
|
|
3
3
|
|
|
4
4
|
import type {
|
|
5
|
+
Aggregate,
|
|
5
6
|
Block,
|
|
6
7
|
BoundedContext,
|
|
7
8
|
Catalog,
|
|
8
9
|
Event,
|
|
9
10
|
Field,
|
|
10
11
|
Flow,
|
|
12
|
+
Operation,
|
|
11
13
|
Service,
|
|
12
14
|
Step,
|
|
13
15
|
} from "../catalog";
|
|
@@ -185,6 +187,38 @@ export interface StepRef {
|
|
|
185
187
|
number: number;
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The flows in which an operation runs: one whose step carries the
|
|
192
|
+
* operation's own ref, `<aggregate id>/<operation id>` - a call across a
|
|
193
|
+
* boundary - or whose incoming rpc step is a method the operation says
|
|
194
|
+
* exposes it. Each flow once, at its first such step.
|
|
195
|
+
*/
|
|
196
|
+
export function flowsRunning(
|
|
197
|
+
catalog: Catalog,
|
|
198
|
+
service: Service,
|
|
199
|
+
aggregate: Aggregate,
|
|
200
|
+
operation: Operation,
|
|
201
|
+
): StepRef[] {
|
|
202
|
+
const own = `${aggregate.id}/${operation.id}`;
|
|
203
|
+
const methods = new Set(
|
|
204
|
+
(operation.exposedBy ?? []).flatMap((method) =>
|
|
205
|
+
service.provides.map((provided) => `${provided.id}/${method}`),
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
const out: StepRef[] = [];
|
|
209
|
+
for (const flow of catalog.flows) {
|
|
210
|
+
const steps = walkSteps(flow.steps);
|
|
211
|
+
const i = steps.findIndex(
|
|
212
|
+
(step) =>
|
|
213
|
+
step.ref !== undefined &&
|
|
214
|
+
(step.ref === own || (step.kind === "rpc" && methods.has(step.ref))),
|
|
215
|
+
);
|
|
216
|
+
const hit = steps[i];
|
|
217
|
+
if (hit) out.push({ flow, stepId: hit.id, number: i + 1 });
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
188
222
|
/** Every step across every flow that references an event, with its 1-based number. */
|
|
189
223
|
export function stepsReferencing(catalog: Catalog, eventId: string): StepRef[] {
|
|
190
224
|
const out: StepRef[] = [];
|
|
@@ -404,29 +438,6 @@ export function usesOfDef(catalog: Catalog, defId: string): DefUse {
|
|
|
404
438
|
// Problems
|
|
405
439
|
// ---------------------------------------------------------------------------
|
|
406
440
|
|
|
407
|
-
/**
|
|
408
|
-
* One edge in the estate that does not land. Two things can be wrong, and they
|
|
409
|
-
* are wrong in the same way: a service calls an rpc whose provider is not in
|
|
410
|
-
* the catalog, or an event names a consumer that is not either. Both mean the
|
|
411
|
-
* chart draws an arrow into open water.
|
|
412
|
-
*/
|
|
413
|
-
export type ProblemKind =
|
|
414
|
-
| "rpc"
|
|
415
|
-
| "consumer"
|
|
416
|
-
| "cross-service-fk"
|
|
417
|
-
| "cross-service-lineage"
|
|
418
|
-
| "shared-store"
|
|
419
|
-
| "persistence-drift"
|
|
420
|
-
| "column-type"
|
|
421
|
-
| "outbox-payload"
|
|
422
|
-
| "proto-missing"
|
|
423
|
-
| "proto-drift"
|
|
424
|
-
| "shared-channel"
|
|
425
|
-
| "channel-undeclared"
|
|
426
|
-
| "channel-unpublished"
|
|
427
|
-
| "message-encoding"
|
|
428
|
-
| "subscription-unresolved";
|
|
429
|
-
|
|
430
441
|
/**
|
|
431
442
|
* How wrong a problem is. Two values, not five: an edge either lands somewhere
|
|
432
443
|
* the catalog knows about or it does not (an error), or the catalog holds two
|
|
@@ -435,26 +446,27 @@ export type ProblemKind =
|
|
|
435
446
|
*/
|
|
436
447
|
export type Severity = "error" | "warning";
|
|
437
448
|
|
|
449
|
+
/**
|
|
450
|
+
* One row of the Problems page: what a rule found, from the near end the
|
|
451
|
+
* reader can see. Every rule is CEL over one subject (rules/builtin.json for
|
|
452
|
+
* the ones the package ships, `problemRules` in the manifest for the estate's
|
|
453
|
+
* own), and `rule` says which; the subject says where `id` leads.
|
|
454
|
+
*/
|
|
438
455
|
export interface Problem {
|
|
439
|
-
|
|
456
|
+
rule: string;
|
|
440
457
|
severity: Severity;
|
|
441
458
|
/** The context that owns the end we can see. */
|
|
442
459
|
context: string;
|
|
443
460
|
/** The service that owns the end we can see. */
|
|
444
461
|
service: string;
|
|
445
|
-
/**
|
|
462
|
+
/** What is on the near end of the edge: the subject's id. */
|
|
446
463
|
id: string;
|
|
447
|
-
/** The
|
|
464
|
+
/** The far end, when the rule names one. */
|
|
448
465
|
peer: string;
|
|
449
466
|
note: string | undefined;
|
|
450
467
|
source: string | undefined;
|
|
451
468
|
}
|
|
452
469
|
|
|
453
|
-
/**
|
|
454
|
-
* Every unresolved edge, contexts in catalog order. Nothing is scored and
|
|
455
|
-
* nothing is ranked: an unresolved consumer is exactly as broken as an
|
|
456
|
-
* unresolved call, and sorting them by badness would invent a fact.
|
|
457
|
-
*/
|
|
458
470
|
/**
|
|
459
471
|
* How many edges there were to resolve at all - every rpc call a service makes
|
|
460
472
|
* and every consumer an event names, whatever their status.
|
|
@@ -477,41 +489,3 @@ export function edgeCount(catalog: Catalog): number {
|
|
|
477
489
|
return n;
|
|
478
490
|
}
|
|
479
491
|
|
|
480
|
-
export function problems(catalog: Catalog): Problem[] {
|
|
481
|
-
const out: Problem[] = [];
|
|
482
|
-
for (const context of catalog.contexts) {
|
|
483
|
-
for (const service of context.services) {
|
|
484
|
-
for (const call of service.consumes) {
|
|
485
|
-
if (call.status !== "unresolved") continue;
|
|
486
|
-
out.push({
|
|
487
|
-
kind: "rpc",
|
|
488
|
-
severity: "error",
|
|
489
|
-
context: context.id,
|
|
490
|
-
service: service.id,
|
|
491
|
-
id: call.id,
|
|
492
|
-
peer: call.peer,
|
|
493
|
-
note: call.note,
|
|
494
|
-
source: call.source,
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
for (const aggregate of service.aggregates) {
|
|
498
|
-
for (const event of aggregate.events) {
|
|
499
|
-
for (const consumer of event.consumers) {
|
|
500
|
-
if (consumer.status !== "unresolved") continue;
|
|
501
|
-
out.push({
|
|
502
|
-
kind: "consumer",
|
|
503
|
-
severity: "error",
|
|
504
|
-
context: context.id,
|
|
505
|
-
service: service.id,
|
|
506
|
-
id: event.id,
|
|
507
|
-
peer: consumer.service,
|
|
508
|
-
note: consumer.note,
|
|
509
|
-
source: undefined,
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
return out;
|
|
517
|
-
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Catalog, Deployment } from "../catalog";
|
|
3
|
+
import { narrowToEnvironments, servicesDeployedIn } from "./environments";
|
|
4
|
+
|
|
5
|
+
const service = (id: string, path: string) => ({
|
|
6
|
+
id,
|
|
7
|
+
slug: id.split(".")[1] ?? id,
|
|
8
|
+
name: id,
|
|
9
|
+
repo: "github.com/acme/shop",
|
|
10
|
+
path,
|
|
11
|
+
readme: "",
|
|
12
|
+
provides: [],
|
|
13
|
+
consumes: [],
|
|
14
|
+
aggregates: [],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const placed = (name: string, environment: string, path: string, extra: Partial<Deployment> = {}): Deployment => ({
|
|
18
|
+
id: `argocd/${name}`,
|
|
19
|
+
name,
|
|
20
|
+
project: "shop",
|
|
21
|
+
environment,
|
|
22
|
+
cluster: "in-cluster",
|
|
23
|
+
namespace: "shop",
|
|
24
|
+
repo: "github.com/acme/shop",
|
|
25
|
+
path,
|
|
26
|
+
targetRevision: "main",
|
|
27
|
+
revision: "a".repeat(40),
|
|
28
|
+
tool: "kustomize",
|
|
29
|
+
url: `https://argocd.example.com/applications/argocd/${name}`,
|
|
30
|
+
...extra,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const catalog: Catalog = {
|
|
34
|
+
generatedAt: "",
|
|
35
|
+
commit: "",
|
|
36
|
+
contexts: [
|
|
37
|
+
{
|
|
38
|
+
id: "shop",
|
|
39
|
+
slug: "shop",
|
|
40
|
+
name: "Shop",
|
|
41
|
+
summary: "",
|
|
42
|
+
services: [service("shop.cart", "services/cart"), service("shop.oms", "services/oms")],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "payments",
|
|
46
|
+
slug: "payments",
|
|
47
|
+
name: "Payments",
|
|
48
|
+
summary: "",
|
|
49
|
+
services: [service("payments.ledger", "services/ledger")],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
defs: {},
|
|
53
|
+
flows: [],
|
|
54
|
+
adrs: [],
|
|
55
|
+
deployments: [
|
|
56
|
+
placed("cart", "prod", "services/cart/deploy"),
|
|
57
|
+
placed("cart-staging", "staging", "services/cart/deploy"),
|
|
58
|
+
placed("oms", "prod", "services/oms/deploy"),
|
|
59
|
+
// No environment label and no cluster name: placed by the one word left.
|
|
60
|
+
placed("ledger", "", "services/ledger/deploy", { cluster: "" }),
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe("the estate narrowed to an environment", () => {
|
|
65
|
+
it("names the services the snapshot places in the environments asked for", () => {
|
|
66
|
+
expect([...servicesDeployedIn(catalog, new Set(["prod"]))]).toEqual(["shop.cart", "shop.oms"]);
|
|
67
|
+
expect([...servicesDeployedIn(catalog, new Set(["staging"]))]).toEqual(["shop.cart"]);
|
|
68
|
+
expect([...servicesDeployedIn(catalog, new Set(["prod", "staging"]))]).toEqual(["shop.cart", "shop.oms"]);
|
|
69
|
+
expect([...servicesDeployedIn(catalog, new Set(["unplaced"]))]).toEqual(["payments.ledger"]);
|
|
70
|
+
expect(servicesDeployedIn(catalog, new Set(["nowhere"])).size).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("keeps only the deployed services, drops a context left empty, and leaves the rest of the catalog alone", () => {
|
|
74
|
+
const staging = narrowToEnvironments(catalog, new Set(["staging"]));
|
|
75
|
+
expect(staging.contexts.map((c) => [c.id, c.services.map((s) => s.id)])).toEqual([["shop", ["shop.cart"]]]);
|
|
76
|
+
expect(staging.deployments).toBe(catalog.deployments);
|
|
77
|
+
expect(staging.flows).toBe(catalog.flows);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("is the whole estate when no environment is chosen", () => {
|
|
81
|
+
expect(narrowToEnvironments(catalog, new Set())).toBe(catalog);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The estate narrowed to what runs somewhere.
|
|
2
|
+
//
|
|
3
|
+
// A map answers "who leans on whom"; with an environment chosen it answers
|
|
4
|
+
// "who leans on whom IN STAGING" - and the honest way to answer that is to
|
|
5
|
+
// take the services the snapshot does not place there out of the catalog
|
|
6
|
+
// and let the same graph, map and counts run over what is left. A call to
|
|
7
|
+
// a service that is not deployed there is then an edge that leaves the
|
|
8
|
+
// chart, which is what it is. Nothing here is a second graph; it is the
|
|
9
|
+
// first graph over a smaller estate.
|
|
10
|
+
|
|
11
|
+
import type { Catalog } from "../catalog";
|
|
12
|
+
import { allDeployments, deploys, environmentOf } from "../catalog";
|
|
13
|
+
|
|
14
|
+
/** The ids of the services the snapshot places in any of the environments named. */
|
|
15
|
+
export function servicesDeployedIn(
|
|
16
|
+
catalog: Catalog,
|
|
17
|
+
environments: ReadonlySet<string>,
|
|
18
|
+
): Set<string> {
|
|
19
|
+
const placed = allDeployments(catalog).filter((deployment) =>
|
|
20
|
+
environments.has(environmentOf(deployment)),
|
|
21
|
+
);
|
|
22
|
+
const out = new Set<string>();
|
|
23
|
+
for (const context of catalog.contexts) {
|
|
24
|
+
for (const service of context.services) {
|
|
25
|
+
if (placed.some((deployment) => deploys(deployment, service))) {
|
|
26
|
+
out.add(service.id);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The catalog with only the services deployed in the environments named,
|
|
35
|
+
* and only the contexts that still hold one. No environment named is the
|
|
36
|
+
* whole estate: an empty filter is no filter, as everywhere else in the app.
|
|
37
|
+
*
|
|
38
|
+
* Flows, ADRs, stores and terms are left as they are: a flow through a
|
|
39
|
+
* service that is not in staging is still the flow, and the reader asked
|
|
40
|
+
* about the map, not the archive.
|
|
41
|
+
*/
|
|
42
|
+
export function narrowToEnvironments(
|
|
43
|
+
catalog: Catalog,
|
|
44
|
+
environments: ReadonlySet<string>,
|
|
45
|
+
): Catalog {
|
|
46
|
+
if (environments.size === 0) return catalog;
|
|
47
|
+
const kept = servicesDeployedIn(catalog, environments);
|
|
48
|
+
return {
|
|
49
|
+
...catalog,
|
|
50
|
+
contexts: catalog.contexts
|
|
51
|
+
.map((context) => ({
|
|
52
|
+
...context,
|
|
53
|
+
services: context.services.filter((service) => kept.has(service.id)),
|
|
54
|
+
}))
|
|
55
|
+
.filter((context) => context.services.length > 0),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createIntegrationStore, normalizeIntegrationUrl } from "./integration-url";
|
|
3
|
+
|
|
4
|
+
describe("integration URLs", () => {
|
|
5
|
+
it("accepts web URLs and rejects values that cannot be opened safely", () => {
|
|
6
|
+
expect(normalizeIntegrationUrl(" https://docs.example/ ")).toBe(
|
|
7
|
+
"https://docs.example",
|
|
8
|
+
);
|
|
9
|
+
expect(normalizeIntegrationUrl("https://docs.example/wiki#top")).toBe(
|
|
10
|
+
"https://docs.example/wiki",
|
|
11
|
+
);
|
|
12
|
+
expect(normalizeIntegrationUrl("javascript:alert(1)")).toBeNull();
|
|
13
|
+
expect(normalizeIntegrationUrl("not a URL")).toBeNull();
|
|
14
|
+
expect(normalizeIntegrationUrl(" ")).toBe("");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps a normalized value and ignores one that cannot be opened", () => {
|
|
18
|
+
// vitest runs under node, so the store keeps the value for this session
|
|
19
|
+
// only; what is asserted is the behaviour the page sees either way.
|
|
20
|
+
const store = createIntegrationStore("portolan.integrations.test");
|
|
21
|
+
store.getState().setUrl("https://docs.example/");
|
|
22
|
+
expect(store.getState().url).toBe("https://docs.example");
|
|
23
|
+
|
|
24
|
+
store.getState().setUrl("javascript:alert(1)");
|
|
25
|
+
expect(store.getState().url).toBe("https://docs.example");
|
|
26
|
+
|
|
27
|
+
store.getState().setUrl("");
|
|
28
|
+
expect(store.getState().url).toBe("");
|
|
29
|
+
});
|
|
30
|
+
});
|