@shortlink-org/portolan 0.2.2 → 0.2.4
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 +7 -2
- package/cli/portolan.mjs +36 -6
- package/package.json +4 -4
- package/plugins/README.md +5 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -2
- package/plugins/extract-python-kafka/extract_test.py +17 -1
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +2 -1
- package/scripts/catalog-sources.test.mjs +11 -1
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +78 -20
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/local-api.mjs +26 -377
- package/scripts/local-api.test.mjs +50 -0
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +4 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/CatalogApp.tsx +4 -4
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1360 -0
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +1611 -0
- package/src/catalog.test.ts +29 -2
- package/src/catalog.ts +6 -3306
- package/src/components/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
- package/src/components/ProblemRow.tsx +7 -0
- package/src/components/SourcePreview.tsx +1 -1
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/enrich.test.ts +4 -5
- package/src/flow/StepDetail.tsx +98 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/index.css +0 -17
- package/src/landing/LandingPage.tsx +4 -4
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +19 -1
- package/src/lib/derive.ts +2 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +19 -7
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/merge.test.ts +69 -0
- package/src/merge.ts +52 -2
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/Settings.tsx +12 -129
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +46 -1
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/vite.config.ts +3 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { MemoryRouter } from "react-router";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { Channel } from "../catalog";
|
|
5
|
+
import { ChannelRows } from "./ChannelRows";
|
|
6
|
+
|
|
7
|
+
describe("ChannelRows MessagePack presentation", () => {
|
|
8
|
+
it("shows a machine-readable encoding with its declared content type", () => {
|
|
9
|
+
const channel: Channel = {
|
|
10
|
+
address: "inventory.snapshots",
|
|
11
|
+
kind: "message",
|
|
12
|
+
messages: [{
|
|
13
|
+
name: "inventory.Snapshot",
|
|
14
|
+
direction: "send",
|
|
15
|
+
encoding: "msgpack",
|
|
16
|
+
contentType: "application/msgpack",
|
|
17
|
+
}],
|
|
18
|
+
};
|
|
19
|
+
const markup = renderToStaticMarkup(
|
|
20
|
+
<MemoryRouter>
|
|
21
|
+
<ChannelRows channels={[channel]} service="shop.inventory" />
|
|
22
|
+
</MemoryRouter>,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(markup).toContain('title="application/msgpack"');
|
|
26
|
+
expect(markup).toContain(">msgpack</span>");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { MemoryRouter } from "react-router";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { Channel } from "../catalog";
|
|
5
|
+
import { ChannelRowsContent } from "./ChannelRows";
|
|
6
|
+
|
|
7
|
+
const kafka: Channel = {
|
|
8
|
+
address: "orders/created",
|
|
9
|
+
kind: "message",
|
|
10
|
+
title: "Kafka · orders/created",
|
|
11
|
+
messages: [],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const nats: Channel = {
|
|
15
|
+
address: "orders.created",
|
|
16
|
+
kind: "message",
|
|
17
|
+
title: "JetStream subject",
|
|
18
|
+
messages: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("ChannelRows Kafka UI integration", () => {
|
|
22
|
+
it("links only Kafka cards to their exact topic when configured", () => {
|
|
23
|
+
const markup = renderToStaticMarkup(
|
|
24
|
+
<MemoryRouter>
|
|
25
|
+
<ChannelRowsContent
|
|
26
|
+
channels={[kafka, nats]}
|
|
27
|
+
service="shop.orders"
|
|
28
|
+
kafkaUi="https://ops.example/ui/clusters/prod"
|
|
29
|
+
/>
|
|
30
|
+
</MemoryRouter>,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(markup).toContain("view in Kafka UI");
|
|
34
|
+
expect(markup).toContain(
|
|
35
|
+
'href="https://ops.example/ui/clusters/prod/all-topics/orders%2Fcreated"',
|
|
36
|
+
);
|
|
37
|
+
expect(markup.match(/view in Kafka UI/g)).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not offer an operational link before Kafka UI is configured", () => {
|
|
41
|
+
const markup = renderToStaticMarkup(
|
|
42
|
+
<MemoryRouter>
|
|
43
|
+
<ChannelRowsContent
|
|
44
|
+
channels={[kafka]}
|
|
45
|
+
service="shop.orders"
|
|
46
|
+
kafkaUi=""
|
|
47
|
+
/>
|
|
48
|
+
</MemoryRouter>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(markup).not.toContain("view in Kafka UI");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
});
|
|
@@ -13,13 +13,22 @@
|
|
|
13
13
|
// Problems page says which; a row that quietly dropped it would be the site
|
|
14
14
|
// hiding the interesting case.
|
|
15
15
|
|
|
16
|
+
import { ExternalLink } from "lucide-react";
|
|
16
17
|
import { Link } from "react-router";
|
|
17
18
|
import type { Channel, ChannelMessage } from "../catalog";
|
|
18
|
-
import { index } from "../data";
|
|
19
|
+
import { catalog, index } from "../data";
|
|
20
|
+
import {
|
|
21
|
+
isKafkaChannel,
|
|
22
|
+
kafkaHandoffChannels,
|
|
23
|
+
kafkaUiTopicUrl,
|
|
24
|
+
useKafkaUi,
|
|
25
|
+
} from "../lib/kafka-ui";
|
|
19
26
|
import { eventPath } from "../routes";
|
|
20
27
|
import { Ident } from "./Ident";
|
|
21
28
|
import { RowActions } from "./RowActions";
|
|
22
29
|
|
|
30
|
+
const KAFKA_HANDOFF_CHANNELS = kafkaHandoffChannels(catalog);
|
|
31
|
+
|
|
23
32
|
/** The event that goes out under a wire name, when the catalog knows one. */
|
|
24
33
|
function publisherOf(name: string) {
|
|
25
34
|
const event = index.eventByWireName.get(name);
|
|
@@ -75,6 +84,11 @@ function MessageRow({
|
|
|
75
84
|
{elsewhere}
|
|
76
85
|
</span>
|
|
77
86
|
) : null}
|
|
87
|
+
{message.encoding || message.contentType ? (
|
|
88
|
+
<span className="chip mono" title={message.contentType || "payload encoding"}>
|
|
89
|
+
{message.encoding || message.contentType}
|
|
90
|
+
</span>
|
|
91
|
+
) : null}
|
|
78
92
|
</div>
|
|
79
93
|
{message.title || message.doc ? (
|
|
80
94
|
<p className="mt-0.5 text-muted">{message.doc || message.title}</p>
|
|
@@ -91,20 +105,53 @@ export function ChannelRows({
|
|
|
91
105
|
}: {
|
|
92
106
|
channels: Channel[];
|
|
93
107
|
service: string;
|
|
108
|
+
}) {
|
|
109
|
+
const kafkaUi = useKafkaUi((state) => state.url);
|
|
110
|
+
return (
|
|
111
|
+
<ChannelRowsContent
|
|
112
|
+
channels={channels}
|
|
113
|
+
service={service}
|
|
114
|
+
kafkaUi={kafkaUi}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The presentational half is exported so the generated links can be rendered in isolation. */
|
|
120
|
+
export function ChannelRowsContent({
|
|
121
|
+
channels,
|
|
122
|
+
service,
|
|
123
|
+
kafkaUi,
|
|
124
|
+
}: {
|
|
125
|
+
channels: Channel[];
|
|
126
|
+
service: string;
|
|
127
|
+
kafkaUi: string;
|
|
94
128
|
}) {
|
|
95
129
|
return (
|
|
96
130
|
<div className="flex flex-col gap-section" data-nav-list>
|
|
97
131
|
{channels.map((channel) => (
|
|
98
132
|
<div key={channel.address} className="rounded-card border border-line">
|
|
99
|
-
<div className="flex flex-wrap items-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
133
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line px-3 py-2">
|
|
134
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2">
|
|
135
|
+
<Ident value={channel.address} />
|
|
136
|
+
{channel.kind === "job" ? (
|
|
137
|
+
<span className="chip">work queue</span>
|
|
138
|
+
) : channel.kind === "message" ? (
|
|
139
|
+
<span className="chip">message stream</span>
|
|
140
|
+
) : null}
|
|
141
|
+
{channel.title ? (
|
|
142
|
+
<span className="text-muted">{channel.title}</span>
|
|
143
|
+
) : null}
|
|
144
|
+
</div>
|
|
145
|
+
{kafkaUi && isKafkaChannel(channel, KAFKA_HANDOFF_CHANNELS) ? (
|
|
146
|
+
<a
|
|
147
|
+
href={kafkaUiTopicUrl(kafkaUi, channel.address) ?? kafkaUi}
|
|
148
|
+
target="_blank"
|
|
149
|
+
rel="noreferrer"
|
|
150
|
+
className="mono inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-control text-accent hover:underline"
|
|
151
|
+
title={`Open ${channel.address} in Kafka UI`}
|
|
152
|
+
>
|
|
153
|
+
view in Kafka UI <ExternalLink size={12} aria-hidden />
|
|
154
|
+
</a>
|
|
108
155
|
) : null}
|
|
109
156
|
</div>
|
|
110
157
|
{channel.doc ? (
|
|
@@ -3,6 +3,12 @@ import type { Aggregate } from "../catalog";
|
|
|
3
3
|
import { layoutLifecycle, METRICS } from "../lib/lifecycle";
|
|
4
4
|
import { KindIcon } from "./kind";
|
|
5
5
|
|
|
6
|
+
function transitionTone(on: string) {
|
|
7
|
+
if (on === "Fail") return { color: "var(--status-unresolved)", marker: "lc-arrow-fail" };
|
|
8
|
+
if (on === "Succeed") return { color: "var(--status-verified)", marker: "lc-arrow-succeed" };
|
|
9
|
+
return { color: "var(--fg-muted)", marker: "lc-arrow" };
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
/**
|
|
7
13
|
* The aggregate's state machine, as the code wrote it down. Boxes and arrows
|
|
8
14
|
* are SVG; the labels are HTML laid over it, because a label holds a link to
|
|
@@ -29,18 +35,27 @@ export function LifecycleDiagram({
|
|
|
29
35
|
<marker id="lc-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
|
30
36
|
<path d="M 0 0 L 8 4 L 0 8 z" fill="var(--fg-muted)" />
|
|
31
37
|
</marker>
|
|
38
|
+
<marker id="lc-arrow-fail" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
|
39
|
+
<path d="M 0 0 L 8 4 L 0 8 z" fill="var(--status-unresolved)" />
|
|
40
|
+
</marker>
|
|
41
|
+
<marker id="lc-arrow-succeed" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
|
42
|
+
<path d="M 0 0 L 8 4 L 0 8 z" fill="var(--status-verified)" />
|
|
43
|
+
</marker>
|
|
32
44
|
</defs>
|
|
33
|
-
{edges.map((e) =>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
{edges.map((e) => {
|
|
46
|
+
const tone = transitionTone(e.on);
|
|
47
|
+
return (
|
|
48
|
+
<path
|
|
49
|
+
key={`${e.from}-${e.to}-${e.on}`}
|
|
50
|
+
d={e.path}
|
|
51
|
+
fill="none"
|
|
52
|
+
stroke={tone.color}
|
|
53
|
+
strokeWidth={1.25}
|
|
54
|
+
strokeDasharray={e.back ? "4 3" : undefined}
|
|
55
|
+
markerEnd={`url(#${tone.marker})`}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
44
59
|
{boxes.map((b) => (
|
|
45
60
|
<g key={b.state}>
|
|
46
61
|
<rect
|
|
@@ -80,11 +95,12 @@ export function LifecycleDiagram({
|
|
|
80
95
|
))}
|
|
81
96
|
{edges.map((e) => {
|
|
82
97
|
const to = e.emits ? eventPath(e.emits) : null;
|
|
98
|
+
const tone = transitionTone(e.on);
|
|
83
99
|
return (
|
|
84
100
|
<div
|
|
85
101
|
key={`${e.from}-${e.to}-${e.on}-label`}
|
|
86
102
|
className="mono absolute flex -translate-x-1/2 -translate-y-1/2 items-center gap-1 whitespace-nowrap rounded-control bg-bg px-1 text-muted"
|
|
87
|
-
style={{ left: e.labelX, top: e.labelY }}
|
|
103
|
+
style={{ left: e.labelX, top: e.labelY, color: tone.color }}
|
|
88
104
|
title={e.source ? `made at ${e.source}` : undefined}
|
|
89
105
|
>
|
|
90
106
|
{e.on}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createElement } from "react";
|
|
2
1
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
2
|
import { describe, expect, it } from "vitest";
|
|
4
3
|
|
|
@@ -6,15 +5,15 @@ import { CapabilityEmpty } from "./PageHeader";
|
|
|
6
5
|
|
|
7
6
|
describe("CapabilityEmpty", () => {
|
|
8
7
|
it("keeps the explanation, action and evidence signal together", () => {
|
|
9
|
-
const markup = renderToStaticMarkup(
|
|
10
|
-
CapabilityEmpty
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
8
|
+
const markup = renderToStaticMarkup(
|
|
9
|
+
<CapabilityEmpty
|
|
10
|
+
title="No dependency graph yet"
|
|
11
|
+
actions={<button type="button">Review extraction</button>}
|
|
12
|
+
signal="Signals: domain events and consumers."
|
|
13
|
+
>
|
|
14
|
+
The catalog has components, but no events to connect.
|
|
15
|
+
</CapabilityEmpty>,
|
|
16
|
+
);
|
|
18
17
|
|
|
19
18
|
expect(markup).toContain("next useful step");
|
|
20
19
|
expect(markup).toContain("No dependency graph yet");
|
|
@@ -27,9 +27,11 @@ const KIND_OF: Record<Problem["kind"], "service" | "event" | "table"> = {
|
|
|
27
27
|
"column-type": "table",
|
|
28
28
|
"outbox-payload": "table",
|
|
29
29
|
"proto-missing": "service",
|
|
30
|
+
"proto-drift": "service",
|
|
30
31
|
"shared-channel": "event",
|
|
31
32
|
"channel-undeclared": "event",
|
|
32
33
|
"channel-unpublished": "service",
|
|
34
|
+
"message-encoding": "service",
|
|
33
35
|
"subscription-unresolved": "service",
|
|
34
36
|
};
|
|
35
37
|
|
|
@@ -44,10 +46,12 @@ const KIND_NOTE: Record<Problem["kind"], string> = {
|
|
|
44
46
|
"outbox-payload": "an outbox with no payload column",
|
|
45
47
|
"proto-missing":
|
|
46
48
|
"the provider is in the catalog but answers on no such method",
|
|
49
|
+
"proto-drift": "the vendored proto and provider schema disagree",
|
|
47
50
|
"shared-channel": "a second service publishes on this channel",
|
|
48
51
|
"channel-undeclared":
|
|
49
52
|
"this event goes out on a channel the service does not declare",
|
|
50
53
|
"channel-unpublished": "a declared channel no event of this service names",
|
|
54
|
+
"message-encoding": "publisher and subscriber use different payload encodings",
|
|
51
55
|
"subscription-unresolved":
|
|
52
56
|
"nothing in the catalog publishes what this service listens for",
|
|
53
57
|
};
|
|
@@ -60,6 +64,7 @@ function nearPath(problem: Problem): string | null {
|
|
|
60
64
|
// peer is nobody; this is a call whose peer is known and whose method is
|
|
61
65
|
// not - and in both the thing to go and look at is the caller.
|
|
62
66
|
case "proto-missing":
|
|
67
|
+
case "proto-drift":
|
|
63
68
|
return servicePath(problem.service);
|
|
64
69
|
case "consumer":
|
|
65
70
|
case "channel-undeclared":
|
|
@@ -69,6 +74,7 @@ function nearPath(problem: Problem): string | null {
|
|
|
69
74
|
case "shared-channel":
|
|
70
75
|
return eventPath(problem.id) ?? servicePath(problem.service);
|
|
71
76
|
case "channel-unpublished":
|
|
77
|
+
case "message-encoding":
|
|
72
78
|
case "subscription-unresolved":
|
|
73
79
|
return servicePath(problem.service);
|
|
74
80
|
case "shared-store":
|
|
@@ -107,6 +113,7 @@ function peerPath(problem: Problem): string | null {
|
|
|
107
113
|
);
|
|
108
114
|
case "shared-store":
|
|
109
115
|
case "shared-channel":
|
|
116
|
+
case "message-encoding":
|
|
110
117
|
return servicePath(problem.peer);
|
|
111
118
|
case "outbox-payload":
|
|
112
119
|
return storePath(problem.peer);
|
|
@@ -106,7 +106,7 @@ function AccessForm({
|
|
|
106
106
|
<button
|
|
107
107
|
type="submit"
|
|
108
108
|
disabled={!draft.trim()}
|
|
109
|
-
className="
|
|
109
|
+
className="product-primary shrink-0 disabled:opacity-40"
|
|
110
110
|
>
|
|
111
111
|
{connected ? "Replace token" : "Connect"}
|
|
112
112
|
</button>
|
|
@@ -14,7 +14,11 @@ import { useMemo } from "react";
|
|
|
14
14
|
import type { ReactNode } from "react";
|
|
15
15
|
import { Link } from "react-router";
|
|
16
16
|
import { catalog, index } from "../data";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
backlinkCount,
|
|
19
|
+
backlinksFor,
|
|
20
|
+
distinctBacklinks,
|
|
21
|
+
} from "../lib/backlinks";
|
|
18
22
|
import type { Backlink, BacklinkGroup, BacklinkTarget } from "../lib/backlinks";
|
|
19
23
|
import { plural } from "../lib/format";
|
|
20
24
|
import { KIND_LABEL, KIND_PLURAL } from "../lib/kinds";
|
|
@@ -163,9 +167,7 @@ export function WhatLinksHere({
|
|
|
163
167
|
className={`mono flex flex-wrap items-center gap-1.5 text-muted ${className || "mt-2"}`}
|
|
164
168
|
>
|
|
165
169
|
linked from
|
|
166
|
-
{groups
|
|
167
|
-
.flatMap((g) => g.links)
|
|
168
|
-
.map((link) => {
|
|
170
|
+
{distinctBacklinks(groups).map((link) => {
|
|
169
171
|
const to = backlinkPath(link);
|
|
170
172
|
const body = (
|
|
171
173
|
<>
|
package/src/enrich.test.ts
CHANGED
|
@@ -864,14 +864,13 @@ describe("enrichCatalog: calls from rpc steps", () => {
|
|
|
864
864
|
expect(() => validateCatalog(catalog)).not.toThrow();
|
|
865
865
|
});
|
|
866
866
|
|
|
867
|
-
it("
|
|
868
|
-
// The
|
|
869
|
-
//
|
|
870
|
-
// is on record as calling; after it, shop.oms is.
|
|
867
|
+
it("keeps a step naming a provided method valid while recording its caller", () => {
|
|
868
|
+
// The provided method is enough to resolve the flow step. Enrichment adds
|
|
869
|
+
// the separate fact that shop.oms is one of its callers.
|
|
871
870
|
const c = estate([
|
|
872
871
|
flow("a", [step("shop.oms", "shop.pricing", "rpc", { ref: METHOD })]),
|
|
873
872
|
]);
|
|
874
|
-
expect(() => validateCatalog(c)).toThrow(
|
|
873
|
+
expect(() => validateCatalog(c)).not.toThrow();
|
|
875
874
|
expect(() => validateCatalog(enrichCatalog(c).catalog)).not.toThrow();
|
|
876
875
|
});
|
|
877
876
|
|
package/src/flow/StepDetail.tsx
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { Link } from "react-router";
|
|
3
|
-
import { AlertTriangle } from "lucide-react";
|
|
3
|
+
import { AlertTriangle, FileCode2 } from "lucide-react";
|
|
4
4
|
import { allRepos, stepFrames } from "../catalog";
|
|
5
5
|
import type { Flow, Step, StepFrame } from "../catalog";
|
|
6
6
|
import { catalog, index } from "../data";
|
|
7
7
|
import { Ident } from "../components/Ident";
|
|
8
|
-
import {
|
|
9
|
-
import { SourcePreviewButton } from "../components/SourcePreview";
|
|
8
|
+
import { SourcePreviewLink } from "../components/SourcePreview";
|
|
10
9
|
import { flowRepoService } from "../lib/derive";
|
|
11
10
|
import { sourceLocation } from "../lib/source-link";
|
|
12
11
|
import { AdrNumber, StatusChip } from "../components/primitives";
|
|
13
12
|
import { ShapeRows } from "../components/ShapeRows";
|
|
14
13
|
import { shapeFor } from "../components/MethodRows";
|
|
15
|
-
import { stepAnswer } from "./answers";
|
|
14
|
+
import { stepAnswer, stepRpcContract } from "./answers";
|
|
16
15
|
import {
|
|
17
16
|
aggregatePath,
|
|
18
17
|
paths,
|
|
@@ -154,12 +153,28 @@ function EventDetail({ step, flow }: { step: Step; flow: Flow }) {
|
|
|
154
153
|
}
|
|
155
154
|
|
|
156
155
|
function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
|
|
157
|
-
const
|
|
156
|
+
const contract = stepRpcContract(index, step);
|
|
157
|
+
const method = contract?.id ?? step.ref ?? step.label ?? "(unknown method)";
|
|
158
158
|
const call = step.ref ? index.rpcById.get(step.ref) : undefined;
|
|
159
|
-
const provider =
|
|
160
|
-
|
|
159
|
+
const provider = contract?.provider;
|
|
160
|
+
const internalProvider = provider
|
|
161
|
+
? index.serviceById.get(provider.id)
|
|
161
162
|
: undefined;
|
|
162
|
-
const providerPath =
|
|
163
|
+
const providerPath = internalProvider ? servicePath(internalProvider.id) : null;
|
|
164
|
+
const requestFields = contract
|
|
165
|
+
? shapeFor(
|
|
166
|
+
contract.provided,
|
|
167
|
+
contract.method.request,
|
|
168
|
+
contract.method.requestRef,
|
|
169
|
+
)
|
|
170
|
+
: null;
|
|
171
|
+
const responseFields = contract
|
|
172
|
+
? shapeFor(
|
|
173
|
+
contract.provided,
|
|
174
|
+
contract.method.response,
|
|
175
|
+
contract.method.responseRef,
|
|
176
|
+
)
|
|
177
|
+
: null;
|
|
163
178
|
// The flow records the hop; what comes back is the contract's to say.
|
|
164
179
|
const answer = stepAnswer(index, step);
|
|
165
180
|
|
|
@@ -185,10 +200,12 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
|
|
|
185
200
|
) : null}
|
|
186
201
|
<dt className="mono text-muted">Provider</dt>
|
|
187
202
|
<dd>
|
|
188
|
-
{
|
|
203
|
+
{internalProvider && providerPath ? (
|
|
189
204
|
<Link to={providerPath} className="mono text-accent">
|
|
190
|
-
{
|
|
205
|
+
{internalProvider.id} →
|
|
191
206
|
</Link>
|
|
207
|
+
) : provider ? (
|
|
208
|
+
<span className="mono text-ink">{provider.id}</span>
|
|
192
209
|
) : (
|
|
193
210
|
<span className="mono inline-flex items-center gap-1.5 rounded-control border px-1.5 py-0.5 status-unresolved">
|
|
194
211
|
<AlertTriangle size={11} aria-hidden />
|
|
@@ -205,6 +222,34 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
|
|
|
205
222
|
</dl>
|
|
206
223
|
</DetailSection>
|
|
207
224
|
|
|
225
|
+
{contract?.method.request ? (
|
|
226
|
+
<DetailSection title="Request" meta={contract.method.request}>
|
|
227
|
+
{requestFields ? (
|
|
228
|
+
<ShapeRows
|
|
229
|
+
fields={requestFields}
|
|
230
|
+
enums={contract.provided.enums}
|
|
231
|
+
showHeader
|
|
232
|
+
/>
|
|
233
|
+
) : (
|
|
234
|
+
<div className="mono text-muted">shape not recorded</div>
|
|
235
|
+
)}
|
|
236
|
+
</DetailSection>
|
|
237
|
+
) : null}
|
|
238
|
+
|
|
239
|
+
{contract?.method.response ? (
|
|
240
|
+
<DetailSection title="Response" meta={contract.method.response}>
|
|
241
|
+
{responseFields ? (
|
|
242
|
+
<ShapeRows
|
|
243
|
+
fields={responseFields}
|
|
244
|
+
enums={contract.provided.enums}
|
|
245
|
+
showHeader
|
|
246
|
+
/>
|
|
247
|
+
) : (
|
|
248
|
+
<div className="mono text-muted">shape not recorded</div>
|
|
249
|
+
)}
|
|
250
|
+
</DetailSection>
|
|
251
|
+
) : null}
|
|
252
|
+
|
|
208
253
|
<DetailSection title="Source">
|
|
209
254
|
{call ? (
|
|
210
255
|
<SourceWhere where={call.source} flow={flow} structured />
|
|
@@ -525,48 +570,25 @@ function SourceWhere({
|
|
|
525
570
|
allRepos(catalog),
|
|
526
571
|
);
|
|
527
572
|
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
title="Open on the forge, at the built commit"
|
|
541
|
-
>
|
|
542
|
-
forge ↗
|
|
543
|
-
</a>
|
|
544
|
-
) : null}
|
|
545
|
-
<EditorLink
|
|
546
|
-
location={location}
|
|
547
|
-
variant="text"
|
|
548
|
-
className="inline-flex items-center border px-2 py-1 border-line bg-canvas hover:bg-raised hover:no-underline"
|
|
549
|
-
/>
|
|
550
|
-
</>
|
|
573
|
+
const source = (
|
|
574
|
+
<SourcePreviewLink
|
|
575
|
+
location={location}
|
|
576
|
+
className={
|
|
577
|
+
structured
|
|
578
|
+
? "w-full min-w-0 border px-2.5 py-2 border-line bg-canvas text-ink hover:border-line-strong hover:bg-raised hover:no-underline"
|
|
579
|
+
: "max-w-full min-w-0 text-muted hover:text-accent"
|
|
580
|
+
}
|
|
581
|
+
>
|
|
582
|
+
<FileCode2 size={14} aria-hidden className="shrink-0" />
|
|
583
|
+
<span className="min-w-0 break-all text-left">{where}</span>
|
|
584
|
+
</SourcePreviewLink>
|
|
551
585
|
);
|
|
552
586
|
|
|
553
587
|
if (structured) {
|
|
554
|
-
return
|
|
555
|
-
<div className="flex min-w-0 flex-col gap-2">
|
|
556
|
-
<div className="mono min-w-0 break-all text-muted">
|
|
557
|
-
<Ident block value={where} className="text-muted" />
|
|
558
|
-
</div>
|
|
559
|
-
<div className="flex flex-wrap items-center gap-1.5">{actions}</div>
|
|
560
|
-
</div>
|
|
561
|
-
);
|
|
588
|
+
return <div className="min-w-0">{source}</div>;
|
|
562
589
|
}
|
|
563
590
|
|
|
564
|
-
return
|
|
565
|
-
<div className="mono flex flex-wrap items-center gap-2 break-all text-muted">
|
|
566
|
-
<Ident block value={where} className="text-muted" />
|
|
567
|
-
{actions}
|
|
568
|
-
</div>
|
|
569
|
-
);
|
|
591
|
+
return <div className="min-w-0">{source}</div>;
|
|
570
592
|
}
|
|
571
593
|
|
|
572
594
|
function Where({ step, flow }: { step: Step; flow: Flow }) {
|
|
@@ -574,6 +596,34 @@ function Where({ step, flow }: { step: Step; flow: Flow }) {
|
|
|
574
596
|
return <SourceWhere where={step.line} flow={flow} />;
|
|
575
597
|
}
|
|
576
598
|
|
|
599
|
+
function CallDetail({ step, flow }: { step: Step; flow: Flow }) {
|
|
600
|
+
return (
|
|
601
|
+
<section
|
|
602
|
+
aria-label="Call detail"
|
|
603
|
+
className="overflow-hidden rounded-card border shadow-xs border-line"
|
|
604
|
+
>
|
|
605
|
+
<header className="border-b px-3 py-2.5 border-line bg-surface">
|
|
606
|
+
<h2 className="label">Call</h2>
|
|
607
|
+
<div className="mt-2">
|
|
608
|
+
<Ident
|
|
609
|
+
block
|
|
610
|
+
value={step.label ?? "internal call"}
|
|
611
|
+
className="text-ink"
|
|
612
|
+
/>
|
|
613
|
+
</div>
|
|
614
|
+
</header>
|
|
615
|
+
|
|
616
|
+
<DetailSection title="Source">
|
|
617
|
+
{step.line ? (
|
|
618
|
+
<SourceWhere where={step.line} flow={flow} structured />
|
|
619
|
+
) : (
|
|
620
|
+
<div className="mono text-muted">not recorded</div>
|
|
621
|
+
)}
|
|
622
|
+
</DetailSection>
|
|
623
|
+
</section>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
577
627
|
function StoreCallDetail({ step, flow }: { step: Step; flow: Flow }) {
|
|
578
628
|
const access = step.storeAccess!;
|
|
579
629
|
const store = index.storeById.get(access.store);
|
|
@@ -699,13 +749,7 @@ export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
|
|
|
699
749
|
) : step.storeAccess ? (
|
|
700
750
|
<StoreCallDetail step={step} flow={flow} />
|
|
701
751
|
) : (
|
|
702
|
-
|
|
703
|
-
<div className="mono text-[13px]">
|
|
704
|
-
{step.label ?? "internal call"}
|
|
705
|
-
</div>
|
|
706
|
-
<Label>Source</Label>
|
|
707
|
-
<Where step={step} flow={flow} />
|
|
708
|
-
</>
|
|
752
|
+
<CallDetail step={step} flow={flow} />
|
|
709
753
|
)}
|
|
710
754
|
|
|
711
755
|
{step.note ? (
|
package/src/flow/answers.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { catalog, index } from "../data";
|
|
3
3
|
import { walkSteps } from "../catalog";
|
|
4
|
-
import { flowAnswers, stepAnswer } from "./answers";
|
|
4
|
+
import { flowAnswers, stepAnswer, stepRpcContract } from "./answers";
|
|
5
5
|
|
|
6
6
|
const flow = (slug: string) => {
|
|
7
7
|
const found = catalog.flows.find((f) => f.slug === slug);
|
|
@@ -16,6 +16,23 @@ const step = (slug: string, id: string) => {
|
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
describe("stepAnswer", () => {
|
|
19
|
+
it("resolves an incoming endpoint to its full request and response contract", () => {
|
|
20
|
+
const contract = stepRpcContract(
|
|
21
|
+
index,
|
|
22
|
+
step("pricing-archive-price-list", "s1"),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(contract?.id).toBe("shop.v1.PriceLists/ArchivePriceList");
|
|
26
|
+
expect(contract?.provider.id).toBe("shop.pricing");
|
|
27
|
+
expect(contract?.method.request).toBe("ArchivePriceListRequest");
|
|
28
|
+
expect(contract?.method.response).toBe("ArchivePriceListResponse");
|
|
29
|
+
expect(
|
|
30
|
+
contract?.provided.messages?.find(
|
|
31
|
+
(message) => message.name === contract.method.request,
|
|
32
|
+
)?.fields.map((field) => field.name),
|
|
33
|
+
).toEqual(["price_list_id"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
19
36
|
it("reads the answer of an endpoint off the document that declares it", () => {
|
|
20
37
|
// billing's ViewSet is exposed by billing.v1.Invoices, and the document
|
|
21
38
|
// says a void answers 204 and an issue answers with the invoice's id.
|