Skip to content

Commit f901580

Browse files
committed
publishes node statuses for real time UI feedback
1 parent 2938b8f commit f901580

15 files changed

Lines changed: 269 additions & 52 deletions

File tree

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"dependencies": {
1515
"@ai-sdk/google": "^2.0.26",
1616
"@hookform/resolvers": "^5.2.2",
17+
"@inngest/realtime": "^0.4.5",
1718
"@paralleldrive/cuid2": "^3.0.4",
1819
"@polar-sh/better-auth": "^1.3.0",
1920
"@polar-sh/sdk": "^0.40.3",

src/components/react-flow/NodeStatusIndicator.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import { LoaderCircle } from "lucide-react";
33

44
import { cn } from "@/lib/utils";
55

6-
export type NodeStatus = "loading" | "success" | "error" | "initial";
6+
export const NodeStatus = {
7+
loading: "loading",
8+
success: "success",
9+
error: "error",
10+
initial: "initial",
11+
} as const;
12+
13+
export type NodeStatus = (typeof NodeStatus)[keyof typeof NodeStatus];
714

815
export type NodeStatusVariant = "overlay" | "border";
916

src/features/executions/ExecutorRegistry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NodeType } from "@/generated/prisma/enums";
22
import { manualTriggerExecutor } from "@/features/executions/components/manual-trigger/Executor";
33
import { HttpRequestExecutor } from "./components/http-request/Executor";
44
import type { GetStepTools, Inngest } from "inngest";
5+
import type { Realtime } from "@inngest/realtime";
56

67
export type WorkflowContext = Record<string, unknown>;
78

@@ -12,6 +13,7 @@ export type NodeExecutorParams<TData = Record<string, unknown>> = {
1213
nodeId: string;
1314
context: WorkflowContext;
1415
step: StepTools;
16+
publish: Realtime.PublishFn;
1517
};
1618

1719
export type NodeExecutor<TData = Record<string, unknown>> = (

src/features/executions/components/http-request/Executor.ts

Lines changed: 72 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,60 +3,90 @@ import type { HttpRequestNodeData } from "./Node";
33
import { NonRetriableError } from "inngest";
44
import ky, { type Options } from "ky";
55
import Handlebars from "handlebars";
6+
import { httpRequestChannel } from "@/inngest/channels/http-request";
7+
import type { Realtime } from "@inngest/realtime";
8+
import type { NodeStatus } from "@/components/react-flow/NodeStatusIndicator";
69

710
Handlebars.registerHelper("json", (context) => {
811
const jsonString = JSON.stringify(context, null, 2);
912
return new Handlebars.SafeString(jsonString);
1013
});
1114

15+
const publishStatus =
16+
(publish: Realtime.PublishFn) =>
17+
async (nodeId: string, status: NodeStatus) => {
18+
await publish(
19+
httpRequestChannel().status({
20+
nodeId,
21+
status,
22+
}),
23+
);
24+
};
25+
1226
export const HttpRequestExecutor: NodeExecutor<HttpRequestNodeData> = async ({
27+
nodeId,
1328
data,
1429
context,
1530
step,
31+
publish,
1632
}) => {
17-
if (!data.variableName) {
18-
throw new NonRetriableError("[Http request]: Variable name not configured");
19-
}
20-
if (!data.endpoint) {
21-
throw new NonRetriableError("[Http request]: No endopoint configured");
22-
}
23-
if (!data.method) {
24-
throw new NonRetriableError("[Http request]: No method configured");
25-
}
33+
try {
34+
await publishStatus(publish)(nodeId, "loading");
2635

27-
return await step.run("http-request", async () => {
28-
const endpoint = Handlebars.compile(data.endpoint)(context);
29-
const method = data.method || "GET";
30-
const options: Options = {
31-
method,
32-
headers: {
33-
"Content-Type": "application/json",
34-
},
35-
};
36-
37-
if (method !== "GET") {
38-
const resolved = Handlebars.compile(data.body)(context);
39-
JSON.parse(resolved);
40-
options.body = resolved;
36+
if (!data.variableName) {
37+
throw new NonRetriableError(
38+
"[HttpRequestExecutor]: No variable name configured",
39+
);
40+
}
41+
if (!data.endpoint) {
42+
throw new NonRetriableError(
43+
"[HttpRequestExecutor]: No endopoint configured",
44+
);
45+
}
46+
if (!data.method) {
47+
throw new NonRetriableError(
48+
"[HttpRequestExecutor]: No method configured",
49+
);
4150
}
51+
return await step.run("http-request", async () => {
52+
const endpoint = Handlebars.compile(data.endpoint)(context);
53+
const method = data.method || "GET";
54+
const options: Options = {
55+
method,
56+
headers: {
57+
"Content-Type": "application/json",
58+
},
59+
};
4260

43-
const response = await ky(endpoint, options);
44-
const contentType = response.headers.get("content-type");
45-
const responseData = contentType?.includes("application/json")
46-
? await response.json()
47-
: await response.text();
48-
49-
const responsePayload = {
50-
httpResponse: {
51-
status: response.status,
52-
statusText: response.statusText,
53-
data: responseData,
54-
},
55-
};
56-
57-
return {
58-
...context,
59-
[data.variableName]: responsePayload,
60-
};
61-
});
61+
if (method !== "GET") {
62+
const resolved = Handlebars.compile(data.body)(context);
63+
JSON.parse(resolved);
64+
options.body = resolved;
65+
}
66+
67+
const response = await ky(endpoint, options);
68+
const contentType = response.headers.get("content-type");
69+
const responseData = contentType?.includes("application/json")
70+
? await response.json()
71+
: await response.text();
72+
73+
const responsePayload = {
74+
httpResponse: {
75+
status: response.status,
76+
statusText: response.statusText,
77+
data: responseData,
78+
},
79+
};
80+
81+
await publishStatus(publish)(nodeId, "success");
82+
83+
return {
84+
...context,
85+
[data.variableName]: responsePayload,
86+
};
87+
});
88+
} catch (error) {
89+
await publishStatus(publish)(nodeId, "error");
90+
throw error;
91+
}
6292
};

src/features/executions/components/http-request/Node.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { memo, useState } from "react";
55
import BaseExecutionNode from "../BaseExecutionNode";
66
import { GlobeIcon } from "lucide-react";
77
import { type HttpRequestDialogFormValues, HttpRequestDialog } from "./Dialog";
8+
import useNodeStatus from "../../hooks/useNodeStatus";
9+
import { HTTP_REQUEST_CHANNEL_NAME } from "@/inngest/channels/http-request";
10+
import { fetchHttpRequestRealtimeToken } from "./serverActions";
811

912
export type HttpRequestNodeData = HttpRequestDialogFormValues;
1013

@@ -14,6 +17,13 @@ export const HttpRequestNode = memo(
1417

1518
const { setNodes } = useReactFlow();
1619

20+
const status = useNodeStatus({
21+
nodeId: props.id,
22+
channel: HTTP_REQUEST_CHANNEL_NAME,
23+
topic: "status",
24+
refreshToken: fetchHttpRequestRealtimeToken,
25+
});
26+
1727
const description = props.data?.endpoint
1828
? `${props.data.method || "GET"}: ${props.data.endpoint}`
1929
: "Not configured";
@@ -53,7 +63,7 @@ export const HttpRequestNode = memo(
5363
onSettings={onSettings}
5464
onDoubleClick={onSettings}
5565
showToolbar
56-
status="initial"
66+
status={status}
5767
/>
5868
</>
5969
);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use server";
2+
3+
import { httpRequestChannel } from "@/inngest/channels/http-request";
4+
import { inngest } from "@/inngest/client";
5+
import { getSubscriptionToken, type Realtime } from "@inngest/realtime";
6+
7+
export type HttpRequestToken = Realtime.Token<
8+
typeof httpRequestChannel,
9+
["status"]
10+
>;
11+
12+
export async function fetchHttpRequestRealtimeToken(): Promise<HttpRequestToken> {
13+
const token = await getSubscriptionToken(inngest, {
14+
channel: httpRequestChannel(),
15+
topics: ["status"],
16+
});
17+
return token;
18+
}
Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
1+
import type { NodeStatus } from "@/components/react-flow/NodeStatusIndicator";
12
import type { NodeExecutor } from "@/features/executions/ExecutorRegistry";
3+
import { manualTriggerChannel } from "@/inngest/channels/manual-trigger";
4+
import type { Realtime } from "@inngest/realtime";
25

36
type ManualTriggerData = Record<string, unknown>;
47

8+
const publishStatus =
9+
(publish: Realtime.PublishFn) =>
10+
async (nodeId: string, status: NodeStatus) => {
11+
await publish(
12+
manualTriggerChannel().status({
13+
nodeId,
14+
status,
15+
}),
16+
);
17+
};
18+
519
export const manualTriggerExecutor: NodeExecutor<ManualTriggerData> = async ({
20+
nodeId,
621
context,
722
step,
23+
publish,
824
}) => {
9-
// TODO: Showing loading node state
10-
return await step.run("manual-trigger", () => context);
11-
// TODO: Showing success node state
25+
try {
26+
await publishStatus(publish)(nodeId, "loading");
27+
const trigger = await step.run("manual-trigger", () => context);
28+
await publishStatus(publish)(nodeId, "success");
29+
return trigger;
30+
} catch (error) {
31+
await publishStatus(publish)(nodeId, "error");
32+
throw error;
33+
}
1234
};

src/features/executions/components/manual-trigger/Node.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,20 @@ import { memo, useState } from "react";
55
import BaseTriggerNode from "../BaseTriggerNode";
66
import { MousePointerIcon } from "lucide-react";
77
import { ManualTriggerDialog } from "./Dialog";
8+
import useNodeStatus from "../../hooks/useNodeStatus";
9+
import { MANUAL_TRIGGER_CHANNEL_NAME } from "@/inngest/channels/manual-trigger";
10+
import { fetchManualTriggerRealtimeToken } from "./serverActions";
811

912
export const ManualTriggerNode = memo((props: NodeProps) => {
1013
const [open, setOpen] = useState(false);
1114

15+
const status = useNodeStatus({
16+
nodeId: props.id,
17+
channel: MANUAL_TRIGGER_CHANNEL_NAME,
18+
topic: "status",
19+
refreshToken: fetchManualTriggerRealtimeToken,
20+
});
21+
1222
const onSettings = () => setOpen(true);
1323

1424
return (
@@ -19,7 +29,7 @@ export const ManualTriggerNode = memo((props: NodeProps) => {
1929
icon={MousePointerIcon}
2030
name="When clicking Execute workflow"
2131
showToolbar
22-
status={"initial"}
32+
status={status}
2333
onSettings={onSettings}
2434
onDoubleClick={onSettings}
2535
/>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"use server";
2+
3+
import { manualTriggerChannel } from "@/inngest/channels/manual-trigger";
4+
import { inngest } from "@/inngest/client";
5+
import { getSubscriptionToken, type Realtime } from "@inngest/realtime";
6+
7+
export async function fetchManualTriggerRealtimeToken(): Promise<
8+
Realtime.Token<typeof manualTriggerChannel, ["status"]>
9+
> {
10+
const token = await getSubscriptionToken(inngest, {
11+
channel: manualTriggerChannel(),
12+
topics: ["status"],
13+
});
14+
return token;
15+
}

0 commit comments

Comments
 (0)