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