@shortlink-org/portolan 0.2.3 → 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/package.json +1 -1
- 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/gen-likec4.mjs +78 -20
- package/scripts/gen-likec4.test.mjs +25 -2
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/catalog-model.ts +22 -1
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +43 -2
- package/src/catalog.test.ts +13 -2
- 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/ProblemRow.tsx +4 -0
- 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/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 +1 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- 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 +23 -0
- package/src/merge.ts +17 -1
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/Settings.tsx +11 -3
- 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/catalog.test.ts
CHANGED
|
@@ -105,7 +105,7 @@ describe("validateCatalog", () => {
|
|
|
105
105
|
if (!step) throw new Error("fixture has no steps");
|
|
106
106
|
step.ref = "shop.oms.order.NoSuchEvent";
|
|
107
107
|
expect(() => validateCatalog(bad)).toThrowError(
|
|
108
|
-
/resolves to neither an Event nor
|
|
108
|
+
/resolves to neither an Event, an RpcCall nor a method/,
|
|
109
109
|
);
|
|
110
110
|
step.status = "unresolved";
|
|
111
111
|
expect(() => validateCatalog(bad)).not.toThrow();
|
|
@@ -1077,7 +1077,10 @@ describe("validateCatalog: channels", () => {
|
|
|
1077
1077
|
}
|
|
1078
1078
|
|
|
1079
1079
|
it("accepts a service that declares what it says on the bus", () => {
|
|
1080
|
-
|
|
1080
|
+
const good = speaking();
|
|
1081
|
+
good.contexts[0]!.services[0]!.channels![0]!.messages[0]!.encoding = "msgpack";
|
|
1082
|
+
good.contexts[0]!.services[0]!.channels![0]!.messages[0]!.contentType = "application/msgpack";
|
|
1083
|
+
expect(() => validateCatalog(good)).not.toThrow();
|
|
1081
1084
|
});
|
|
1082
1085
|
|
|
1083
1086
|
// A service with no document is the normal case, and it is not a service
|
|
@@ -1130,6 +1133,14 @@ describe("validateCatalog: channels", () => {
|
|
|
1130
1133
|
|
|
1131
1134
|
expect(() => validateCatalog(bad)).toThrow(/neither send nor receive/);
|
|
1132
1135
|
});
|
|
1136
|
+
|
|
1137
|
+
it("rejects an explicitly empty message encoding", () => {
|
|
1138
|
+
const bad = speaking();
|
|
1139
|
+
const message = bad.contexts[0]!.services[0]!.channels![0]!.messages[0]!;
|
|
1140
|
+
message.encoding = "";
|
|
1141
|
+
|
|
1142
|
+
expect(() => validateCatalog(bad)).toThrow(/empty encoding/);
|
|
1143
|
+
});
|
|
1133
1144
|
});
|
|
1134
1145
|
|
|
1135
1146
|
// A system outside the estate with a contract sits at the root beside the
|
|
@@ -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}
|
|
@@ -31,6 +31,7 @@ const KIND_OF: Record<Problem["kind"], "service" | "event" | "table"> = {
|
|
|
31
31
|
"shared-channel": "event",
|
|
32
32
|
"channel-undeclared": "event",
|
|
33
33
|
"channel-unpublished": "service",
|
|
34
|
+
"message-encoding": "service",
|
|
34
35
|
"subscription-unresolved": "service",
|
|
35
36
|
};
|
|
36
37
|
|
|
@@ -50,6 +51,7 @@ const KIND_NOTE: Record<Problem["kind"], string> = {
|
|
|
50
51
|
"channel-undeclared":
|
|
51
52
|
"this event goes out on a channel the service does not declare",
|
|
52
53
|
"channel-unpublished": "a declared channel no event of this service names",
|
|
54
|
+
"message-encoding": "publisher and subscriber use different payload encodings",
|
|
53
55
|
"subscription-unresolved":
|
|
54
56
|
"nothing in the catalog publishes what this service listens for",
|
|
55
57
|
};
|
|
@@ -72,6 +74,7 @@ function nearPath(problem: Problem): string | null {
|
|
|
72
74
|
case "shared-channel":
|
|
73
75
|
return eventPath(problem.id) ?? servicePath(problem.service);
|
|
74
76
|
case "channel-unpublished":
|
|
77
|
+
case "message-encoding":
|
|
75
78
|
case "subscription-unresolved":
|
|
76
79
|
return servicePath(problem.service);
|
|
77
80
|
case "shared-store":
|
|
@@ -110,6 +113,7 @@ function peerPath(problem: Problem): string | null {
|
|
|
110
113
|
);
|
|
111
114
|
case "shared-store":
|
|
112
115
|
case "shared-channel":
|
|
116
|
+
case "message-encoding":
|
|
113
117
|
return servicePath(problem.peer);
|
|
114
118
|
case "outbox-payload":
|
|
115
119
|
return storePath(problem.peer);
|
|
@@ -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.
|