@checkstack/automation-frontend 0.2.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/CHANGELOG.md +664 -0
- package/package.json +38 -0
- package/src/components/AutomationMenuItems.tsx +37 -0
- package/src/editor/ActionEditor.tsx +367 -0
- package/src/editor/ActionListEditor.tsx +203 -0
- package/src/editor/AddActionDialog.tsx +225 -0
- package/src/editor/AutomationDefinitionContext.tsx +37 -0
- package/src/editor/AutomationDefinitionEditor.tsx +99 -0
- package/src/editor/ConditionEditor.tsx +218 -0
- package/src/editor/ConditionsEditor.tsx +89 -0
- package/src/editor/ItemPicker.tsx +147 -0
- package/src/editor/TriggersEditor.tsx +269 -0
- package/src/editor/action-composite-cards.tsx +390 -0
- package/src/editor/action-helpers.ts +365 -0
- package/src/editor/action-leaf-cards.tsx +426 -0
- package/src/editor/editor-validation.test.ts +95 -0
- package/src/editor/editor-validation.tsx +200 -0
- package/src/editor/registry-context.tsx +192 -0
- package/src/editor/template-completion.test.ts +412 -0
- package/src/editor/template-completion.ts +664 -0
- package/src/editor/template-helpers.test.ts +145 -0
- package/src/editor/template-helpers.ts +95 -0
- package/src/editor/trigger-helpers.test.ts +58 -0
- package/src/editor/trigger-helpers.ts +67 -0
- package/src/editor/useConnectionOptionResolvers.ts +80 -0
- package/src/editor/yaml-markers.ts +116 -0
- package/src/index.tsx +95 -0
- package/src/pages/AutomationEditPage.tsx +567 -0
- package/src/pages/AutomationListPage.tsx +304 -0
- package/src/pages/RunDetailPage.tsx +333 -0
- package/src/pages/RunsPage.tsx +233 -0
- package/src/pages/TemplatePlaygroundPage.tsx +224 -0
- package/src/script-context.test.ts +247 -0
- package/src/script-context.ts +218 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, useNavigate } from "react-router-dom";
|
|
3
|
+
import { Workflow, Plus, FlaskConical, Trash2 } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
usePluginClient,
|
|
6
|
+
accessApiRef,
|
|
7
|
+
useApi,
|
|
8
|
+
wrapInSuspense,
|
|
9
|
+
} from "@checkstack/frontend-api";
|
|
10
|
+
import {
|
|
11
|
+
AutomationApi,
|
|
12
|
+
automationAccess,
|
|
13
|
+
automationRoutes,
|
|
14
|
+
} from "@checkstack/automation-common";
|
|
15
|
+
import {
|
|
16
|
+
PageLayout,
|
|
17
|
+
Card,
|
|
18
|
+
CardHeader,
|
|
19
|
+
CardTitle,
|
|
20
|
+
CardContent,
|
|
21
|
+
Button,
|
|
22
|
+
Badge,
|
|
23
|
+
Toggle,
|
|
24
|
+
Table,
|
|
25
|
+
TableHeader,
|
|
26
|
+
TableRow,
|
|
27
|
+
TableHead,
|
|
28
|
+
TableBody,
|
|
29
|
+
TableCell,
|
|
30
|
+
LoadingSpinner,
|
|
31
|
+
QueryErrorState,
|
|
32
|
+
EmptyState,
|
|
33
|
+
ConfirmationModal,
|
|
34
|
+
useToast,
|
|
35
|
+
} from "@checkstack/ui";
|
|
36
|
+
import { extractErrorMessage, resolveRoute } from "@checkstack/common";
|
|
37
|
+
import { formatDistanceToNow } from "date-fns";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Lists every automation the operator can see, with quick enable / disable
|
|
41
|
+
* toggle and delete. The "Create" button navigates to `/automation/new`
|
|
42
|
+
* which Phase 12.x will route to a blank edit page.
|
|
43
|
+
*
|
|
44
|
+
* Status filter, name + last-run columns, and a tiny mode badge per row.
|
|
45
|
+
* Pagination defers to a "Load more" button rather than numbered pagers —
|
|
46
|
+
* the most common operation here is "find the one I broke", which a
|
|
47
|
+
* single sorted list of recent activity covers without a pager UX.
|
|
48
|
+
*/
|
|
49
|
+
const AutomationListContent: React.FC = () => {
|
|
50
|
+
const client = usePluginClient(AutomationApi);
|
|
51
|
+
const accessApi = useApi(accessApiRef);
|
|
52
|
+
const toast = useToast();
|
|
53
|
+
const navigate = useNavigate();
|
|
54
|
+
|
|
55
|
+
const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
|
|
56
|
+
automationAccess.read,
|
|
57
|
+
);
|
|
58
|
+
const { allowed: canManage } = accessApi.useAccess(automationAccess.manage);
|
|
59
|
+
|
|
60
|
+
const [statusFilter, setStatusFilter] = React.useState<
|
|
61
|
+
"all" | "enabled" | "disabled"
|
|
62
|
+
>("all");
|
|
63
|
+
const [deleteId, setDeleteId] = React.useState<string | undefined>();
|
|
64
|
+
|
|
65
|
+
const query = client.listAutomations.useQuery({
|
|
66
|
+
limit: 100,
|
|
67
|
+
offset: 0,
|
|
68
|
+
...(statusFilter === "all" ? {} : { status: statusFilter }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const toggleMutation = client.toggleAutomation.useMutation({
|
|
72
|
+
onSuccess: (data) => {
|
|
73
|
+
toast.success(
|
|
74
|
+
`${data.name} ${data.status === "enabled" ? "enabled" : "disabled"}`,
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
onError: (error) => toast.error(extractErrorMessage(error)),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const deleteMutation = client.deleteAutomation.useMutation({
|
|
81
|
+
onSuccess: () => {
|
|
82
|
+
toast.success("Automation deleted");
|
|
83
|
+
setDeleteId(undefined);
|
|
84
|
+
},
|
|
85
|
+
onError: (error) => toast.error(extractErrorMessage(error)),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const automations = query.data?.items ?? [];
|
|
89
|
+
const isEmpty = !query.isLoading && automations.length === 0;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<PageLayout
|
|
93
|
+
title="Automations"
|
|
94
|
+
subtitle="Trigger-driven workflows that react to platform events"
|
|
95
|
+
icon={Workflow}
|
|
96
|
+
loading={accessLoading}
|
|
97
|
+
allowed={canRead}
|
|
98
|
+
actions={
|
|
99
|
+
<div className="flex items-center gap-2">
|
|
100
|
+
<Link to={resolveRoute(automationRoutes.routes.playground)}>
|
|
101
|
+
<Button variant="outline" size="sm">
|
|
102
|
+
<FlaskConical className="mr-1 h-4 w-4" />
|
|
103
|
+
Playground
|
|
104
|
+
</Button>
|
|
105
|
+
</Link>
|
|
106
|
+
{canManage && (
|
|
107
|
+
<Link to={resolveRoute(automationRoutes.routes.create)}>
|
|
108
|
+
<Button size="sm">
|
|
109
|
+
<Plus className="mr-1 h-4 w-4" />
|
|
110
|
+
New automation
|
|
111
|
+
</Button>
|
|
112
|
+
</Link>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
<Card>
|
|
118
|
+
<CardHeader className="border-b">
|
|
119
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
120
|
+
<CardTitle className="text-base">All automations</CardTitle>
|
|
121
|
+
<div className="flex items-center gap-1">
|
|
122
|
+
{(["all", "enabled", "disabled"] as const).map((option) => (
|
|
123
|
+
<Button
|
|
124
|
+
key={option}
|
|
125
|
+
size="sm"
|
|
126
|
+
variant={statusFilter === option ? "primary" : "outline"}
|
|
127
|
+
onClick={() => setStatusFilter(option)}
|
|
128
|
+
className="capitalize"
|
|
129
|
+
>
|
|
130
|
+
{option}
|
|
131
|
+
</Button>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</CardHeader>
|
|
136
|
+
<CardContent className="p-0">
|
|
137
|
+
{query.isLoading ? (
|
|
138
|
+
<div className="p-6">
|
|
139
|
+
<LoadingSpinner />
|
|
140
|
+
</div>
|
|
141
|
+
) : query.isError ? (
|
|
142
|
+
<QueryErrorState
|
|
143
|
+
error={query.error}
|
|
144
|
+
onRetry={() => query.refetch()}
|
|
145
|
+
/>
|
|
146
|
+
) : isEmpty ? (
|
|
147
|
+
<EmptyState
|
|
148
|
+
icon={<Workflow className="h-8 w-8 text-muted-foreground" />}
|
|
149
|
+
title="No automations yet"
|
|
150
|
+
description={
|
|
151
|
+
canManage
|
|
152
|
+
? 'Click "New automation" to wire your first trigger.'
|
|
153
|
+
: "Once an admin creates an automation it will appear here."
|
|
154
|
+
}
|
|
155
|
+
/>
|
|
156
|
+
) : (
|
|
157
|
+
<Table>
|
|
158
|
+
<TableHeader>
|
|
159
|
+
<TableRow>
|
|
160
|
+
<TableHead className="w-8" />
|
|
161
|
+
<TableHead>Name</TableHead>
|
|
162
|
+
<TableHead>Triggers</TableHead>
|
|
163
|
+
<TableHead>Mode</TableHead>
|
|
164
|
+
<TableHead>Updated</TableHead>
|
|
165
|
+
<TableHead className="w-24 text-right">Actions</TableHead>
|
|
166
|
+
</TableRow>
|
|
167
|
+
</TableHeader>
|
|
168
|
+
<TableBody>
|
|
169
|
+
{automations.map((automation) => (
|
|
170
|
+
<TableRow
|
|
171
|
+
key={automation.id}
|
|
172
|
+
className="cursor-pointer hover:bg-accent/40"
|
|
173
|
+
onClick={() =>
|
|
174
|
+
navigate(
|
|
175
|
+
resolveRoute(automationRoutes.routes.edit, {
|
|
176
|
+
automationId: automation.id,
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
>
|
|
181
|
+
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
182
|
+
{canManage ? (
|
|
183
|
+
<Toggle
|
|
184
|
+
checked={automation.status === "enabled"}
|
|
185
|
+
onCheckedChange={(enabled) =>
|
|
186
|
+
toggleMutation.mutate({
|
|
187
|
+
id: automation.id,
|
|
188
|
+
enabled,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
aria-label={
|
|
192
|
+
automation.status === "enabled"
|
|
193
|
+
? "Disable automation"
|
|
194
|
+
: "Enable automation"
|
|
195
|
+
}
|
|
196
|
+
/>
|
|
197
|
+
) : (
|
|
198
|
+
<Badge
|
|
199
|
+
variant={
|
|
200
|
+
automation.status === "enabled"
|
|
201
|
+
? "success"
|
|
202
|
+
: "outline"
|
|
203
|
+
}
|
|
204
|
+
>
|
|
205
|
+
{automation.status}
|
|
206
|
+
</Badge>
|
|
207
|
+
)}
|
|
208
|
+
</TableCell>
|
|
209
|
+
<TableCell>
|
|
210
|
+
<div className="flex flex-col">
|
|
211
|
+
<span className="font-medium">{automation.name}</span>
|
|
212
|
+
{automation.description && (
|
|
213
|
+
<span className="text-xs text-muted-foreground">
|
|
214
|
+
{automation.description}
|
|
215
|
+
</span>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</TableCell>
|
|
219
|
+
<TableCell>
|
|
220
|
+
<div className="flex flex-wrap gap-1">
|
|
221
|
+
{automation.definition.triggers
|
|
222
|
+
.slice(0, 3)
|
|
223
|
+
.map((trigger, index) => (
|
|
224
|
+
<Badge
|
|
225
|
+
key={`${trigger.event}-${index}`}
|
|
226
|
+
variant="outline"
|
|
227
|
+
className="text-[10px] font-mono"
|
|
228
|
+
>
|
|
229
|
+
{trigger.event}
|
|
230
|
+
</Badge>
|
|
231
|
+
))}
|
|
232
|
+
{automation.definition.triggers.length > 3 && (
|
|
233
|
+
<Badge variant="outline" className="text-[10px]">
|
|
234
|
+
+{automation.definition.triggers.length - 3}
|
|
235
|
+
</Badge>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
</TableCell>
|
|
239
|
+
<TableCell>
|
|
240
|
+
<Badge variant="secondary" className="text-[10px]">
|
|
241
|
+
{automation.definition.mode}
|
|
242
|
+
</Badge>
|
|
243
|
+
</TableCell>
|
|
244
|
+
<TableCell>
|
|
245
|
+
<span className="text-xs text-muted-foreground">
|
|
246
|
+
{formatDistanceToNow(new Date(automation.updatedAt), {
|
|
247
|
+
addSuffix: true,
|
|
248
|
+
})}
|
|
249
|
+
</span>
|
|
250
|
+
</TableCell>
|
|
251
|
+
<TableCell
|
|
252
|
+
onClick={(e) => e.stopPropagation()}
|
|
253
|
+
className="text-right"
|
|
254
|
+
>
|
|
255
|
+
<div className="flex justify-end gap-1">
|
|
256
|
+
<Link
|
|
257
|
+
to={resolveRoute(automationRoutes.routes.runs, {
|
|
258
|
+
automationId: automation.id,
|
|
259
|
+
})}
|
|
260
|
+
>
|
|
261
|
+
<Button variant="ghost" size="sm">
|
|
262
|
+
Runs
|
|
263
|
+
</Button>
|
|
264
|
+
</Link>
|
|
265
|
+
{canManage && (
|
|
266
|
+
<Button
|
|
267
|
+
variant="ghost"
|
|
268
|
+
size="icon"
|
|
269
|
+
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
|
270
|
+
onClick={() => setDeleteId(automation.id)}
|
|
271
|
+
aria-label="Delete automation"
|
|
272
|
+
>
|
|
273
|
+
<Trash2 className="h-4 w-4" />
|
|
274
|
+
</Button>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
</TableCell>
|
|
278
|
+
</TableRow>
|
|
279
|
+
))}
|
|
280
|
+
</TableBody>
|
|
281
|
+
</Table>
|
|
282
|
+
)}
|
|
283
|
+
</CardContent>
|
|
284
|
+
</Card>
|
|
285
|
+
|
|
286
|
+
<ConfirmationModal
|
|
287
|
+
isOpen={deleteId !== undefined}
|
|
288
|
+
onClose={() => setDeleteId(undefined)}
|
|
289
|
+
title="Delete automation?"
|
|
290
|
+
message="This will stop the automation from triggering. Existing run history is preserved."
|
|
291
|
+
confirmText="Delete"
|
|
292
|
+
variant="danger"
|
|
293
|
+
isLoading={deleteMutation.isPending}
|
|
294
|
+
onConfirm={() => {
|
|
295
|
+
if (deleteId !== undefined) {
|
|
296
|
+
deleteMutation.mutate({ id: deleteId });
|
|
297
|
+
}
|
|
298
|
+
}}
|
|
299
|
+
/>
|
|
300
|
+
</PageLayout>
|
|
301
|
+
);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
export const AutomationListPage = wrapInSuspense(AutomationListContent);
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, useParams } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
CheckCircle2,
|
|
5
|
+
ChevronLeft,
|
|
6
|
+
CircleDot,
|
|
7
|
+
XCircle,
|
|
8
|
+
Clock,
|
|
9
|
+
History,
|
|
10
|
+
Hourglass,
|
|
11
|
+
StopCircle,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import {
|
|
14
|
+
usePluginClient,
|
|
15
|
+
accessApiRef,
|
|
16
|
+
useApi,
|
|
17
|
+
wrapInSuspense,
|
|
18
|
+
} from "@checkstack/frontend-api";
|
|
19
|
+
import {
|
|
20
|
+
AutomationApi,
|
|
21
|
+
automationAccess,
|
|
22
|
+
automationRoutes,
|
|
23
|
+
} from "@checkstack/automation-common";
|
|
24
|
+
import type { StepStatus, RunStatus } from "@checkstack/automation-common";
|
|
25
|
+
import {
|
|
26
|
+
PageLayout,
|
|
27
|
+
Card,
|
|
28
|
+
CardContent,
|
|
29
|
+
CardHeader,
|
|
30
|
+
CardTitle,
|
|
31
|
+
Badge,
|
|
32
|
+
Button,
|
|
33
|
+
CodeEditor,
|
|
34
|
+
LoadingSpinner,
|
|
35
|
+
QueryErrorState,
|
|
36
|
+
EmptyState,
|
|
37
|
+
Alert,
|
|
38
|
+
AlertTitle,
|
|
39
|
+
AlertDescription,
|
|
40
|
+
} from "@checkstack/ui";
|
|
41
|
+
import { resolveRoute } from "@checkstack/common";
|
|
42
|
+
import { formatDistanceToNow } from "date-fns";
|
|
43
|
+
|
|
44
|
+
const noop = (): void => {
|
|
45
|
+
return;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const STEP_STATUS_ICON: Record<StepStatus, React.ComponentType<{ className?: string }>> = {
|
|
49
|
+
pending: Clock,
|
|
50
|
+
running: CircleDot,
|
|
51
|
+
success: CheckCircle2,
|
|
52
|
+
failed: XCircle,
|
|
53
|
+
skipped: StopCircle,
|
|
54
|
+
waiting: Hourglass,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const STEP_STATUS_COLOR: Record<StepStatus, string> = {
|
|
58
|
+
pending: "text-muted-foreground",
|
|
59
|
+
running: "text-primary",
|
|
60
|
+
success: "text-emerald-500",
|
|
61
|
+
failed: "text-destructive",
|
|
62
|
+
skipped: "text-muted-foreground",
|
|
63
|
+
waiting: "text-amber-500",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const RUN_STATUS_VARIANT: Record<
|
|
67
|
+
RunStatus,
|
|
68
|
+
"default" | "secondary" | "outline" | "destructive" | "success" | "warning"
|
|
69
|
+
> = {
|
|
70
|
+
pending: "outline",
|
|
71
|
+
running: "secondary",
|
|
72
|
+
waiting: "warning",
|
|
73
|
+
success: "success",
|
|
74
|
+
failed: "destructive",
|
|
75
|
+
cancelled: "outline",
|
|
76
|
+
skipped: "outline",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Drill into a single automation run. Layout:
|
|
81
|
+
*
|
|
82
|
+
* - Header (status, trigger event, started/finished).
|
|
83
|
+
* - If the run failed, surface the `errorMessage` as an Alert at the top.
|
|
84
|
+
* - Step timeline — one row per `AutomationRunStep` with status icon,
|
|
85
|
+
* action kind, attempts, and the action's `errorMessage` inline when
|
|
86
|
+
* it failed. The result payload (typically the artifact data) is shown
|
|
87
|
+
* as collapsible JSON beneath the row when present.
|
|
88
|
+
* - Trigger payload as a read-only JSON `CodeEditor`.
|
|
89
|
+
* - Artifacts panel listing every `AutomationArtifact` the run produced,
|
|
90
|
+
* keyed by `artifactType`.
|
|
91
|
+
*/
|
|
92
|
+
const RunDetailContent: React.FC = () => {
|
|
93
|
+
const { automationId, runId } = useParams<{
|
|
94
|
+
automationId: string;
|
|
95
|
+
runId: string;
|
|
96
|
+
}>();
|
|
97
|
+
const client = usePluginClient(AutomationApi);
|
|
98
|
+
const accessApi = useApi(accessApiRef);
|
|
99
|
+
const { allowed, loading: accessLoading } = accessApi.useAccess(
|
|
100
|
+
automationAccess.read,
|
|
101
|
+
);
|
|
102
|
+
const { allowed: canManage } = accessApi.useAccess(automationAccess.manage);
|
|
103
|
+
|
|
104
|
+
const query = client.getRun.useQuery(
|
|
105
|
+
{ id: runId ?? "" },
|
|
106
|
+
{ enabled: Boolean(runId) },
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const cancelMutation = client.cancelRun.useMutation();
|
|
110
|
+
|
|
111
|
+
if (!automationId || !runId) {
|
|
112
|
+
return (
|
|
113
|
+
<PageLayout title="Run not found" icon={History} allowed={false}>
|
|
114
|
+
<EmptyState
|
|
115
|
+
icon={<History className="h-8 w-8 text-muted-foreground" />}
|
|
116
|
+
title="Missing run id"
|
|
117
|
+
description="The URL is malformed."
|
|
118
|
+
/>
|
|
119
|
+
</PageLayout>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<PageLayout
|
|
125
|
+
title={query.data ? `Run ${query.data.run.id.slice(0, 8)}` : "Run"}
|
|
126
|
+
subtitle={
|
|
127
|
+
query.data
|
|
128
|
+
? `Triggered by ${query.data.run.triggerEventId || "manual run"}`
|
|
129
|
+
: undefined
|
|
130
|
+
}
|
|
131
|
+
icon={History}
|
|
132
|
+
loading={accessLoading}
|
|
133
|
+
allowed={allowed}
|
|
134
|
+
actions={
|
|
135
|
+
<div className="flex items-center gap-2">
|
|
136
|
+
<Link
|
|
137
|
+
to={resolveRoute(automationRoutes.routes.runs, { automationId })}
|
|
138
|
+
>
|
|
139
|
+
<Button variant="outline" size="sm">
|
|
140
|
+
<ChevronLeft className="mr-1 h-4 w-4" />
|
|
141
|
+
All runs
|
|
142
|
+
</Button>
|
|
143
|
+
</Link>
|
|
144
|
+
{canManage &&
|
|
145
|
+
query.data &&
|
|
146
|
+
(query.data.run.status === "running" ||
|
|
147
|
+
query.data.run.status === "waiting") && (
|
|
148
|
+
<Button
|
|
149
|
+
variant="destructive"
|
|
150
|
+
size="sm"
|
|
151
|
+
onClick={() => cancelMutation.mutate({ id: runId })}
|
|
152
|
+
disabled={cancelMutation.isPending}
|
|
153
|
+
>
|
|
154
|
+
Cancel run
|
|
155
|
+
</Button>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
}
|
|
159
|
+
>
|
|
160
|
+
{query.isLoading ? (
|
|
161
|
+
<LoadingSpinner />
|
|
162
|
+
) : query.isError ? (
|
|
163
|
+
<QueryErrorState error={query.error} onRetry={() => query.refetch()} />
|
|
164
|
+
) : query.data ? (
|
|
165
|
+
<div className="flex flex-col gap-4">
|
|
166
|
+
<RunHeader run={query.data.run} />
|
|
167
|
+
|
|
168
|
+
{query.data.run.errorMessage && (
|
|
169
|
+
<Alert variant="error">
|
|
170
|
+
<AlertTitle>Run failed</AlertTitle>
|
|
171
|
+
<AlertDescription className="whitespace-pre-wrap font-mono text-xs">
|
|
172
|
+
{query.data.run.errorMessage}
|
|
173
|
+
</AlertDescription>
|
|
174
|
+
</Alert>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
<Card>
|
|
178
|
+
<CardHeader className="border-b">
|
|
179
|
+
<CardTitle className="text-base">Steps</CardTitle>
|
|
180
|
+
</CardHeader>
|
|
181
|
+
<CardContent className="space-y-2 p-3">
|
|
182
|
+
{query.data.steps.length === 0 ? (
|
|
183
|
+
<p className="text-sm text-muted-foreground italic">
|
|
184
|
+
No steps recorded.
|
|
185
|
+
</p>
|
|
186
|
+
) : (
|
|
187
|
+
query.data.steps.map((step) => <StepRow key={step.id} step={step} />)
|
|
188
|
+
)}
|
|
189
|
+
</CardContent>
|
|
190
|
+
</Card>
|
|
191
|
+
|
|
192
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
193
|
+
<Card>
|
|
194
|
+
<CardHeader className="border-b">
|
|
195
|
+
<CardTitle className="text-base">Trigger payload</CardTitle>
|
|
196
|
+
</CardHeader>
|
|
197
|
+
<CardContent className="p-0">
|
|
198
|
+
<CodeEditor
|
|
199
|
+
value={JSON.stringify(query.data.run.triggerPayload, null, 2)}
|
|
200
|
+
onChange={noop}
|
|
201
|
+
language="json"
|
|
202
|
+
readOnly
|
|
203
|
+
minHeight="240px"
|
|
204
|
+
/>
|
|
205
|
+
</CardContent>
|
|
206
|
+
</Card>
|
|
207
|
+
<Card>
|
|
208
|
+
<CardHeader className="border-b">
|
|
209
|
+
<CardTitle className="text-base">
|
|
210
|
+
Artifacts ({query.data.artifacts.length})
|
|
211
|
+
</CardTitle>
|
|
212
|
+
</CardHeader>
|
|
213
|
+
<CardContent className="space-y-2 p-3">
|
|
214
|
+
{query.data.artifacts.length === 0 ? (
|
|
215
|
+
<p className="text-sm text-muted-foreground italic">
|
|
216
|
+
This run produced no artifacts.
|
|
217
|
+
</p>
|
|
218
|
+
) : (
|
|
219
|
+
query.data.artifacts.map((artifact) => (
|
|
220
|
+
<details
|
|
221
|
+
key={artifact.id}
|
|
222
|
+
className="rounded border border-border bg-card"
|
|
223
|
+
>
|
|
224
|
+
<summary className="flex cursor-pointer items-center justify-between px-2 py-1 text-xs">
|
|
225
|
+
<Badge variant="outline" className="font-mono">
|
|
226
|
+
{artifact.artifactType}
|
|
227
|
+
</Badge>
|
|
228
|
+
{artifact.actionId && (
|
|
229
|
+
<code className="font-mono text-muted-foreground">
|
|
230
|
+
{artifact.actionId}
|
|
231
|
+
</code>
|
|
232
|
+
)}
|
|
233
|
+
</summary>
|
|
234
|
+
<pre className="overflow-x-auto p-2 text-xs">
|
|
235
|
+
{JSON.stringify(artifact.data, null, 2)}
|
|
236
|
+
</pre>
|
|
237
|
+
</details>
|
|
238
|
+
))
|
|
239
|
+
)}
|
|
240
|
+
</CardContent>
|
|
241
|
+
</Card>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
) : null}
|
|
245
|
+
</PageLayout>
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const RunHeader: React.FC<{
|
|
250
|
+
run: { status: RunStatus; startedAt: Date; finishedAt?: Date };
|
|
251
|
+
}> = ({ run }) => (
|
|
252
|
+
<Card>
|
|
253
|
+
<CardContent className="flex flex-wrap items-center gap-4 p-4">
|
|
254
|
+
<Badge variant={RUN_STATUS_VARIANT[run.status]} className="capitalize">
|
|
255
|
+
{run.status}
|
|
256
|
+
</Badge>
|
|
257
|
+
<div className="flex flex-col text-xs text-muted-foreground">
|
|
258
|
+
<span>
|
|
259
|
+
Started{" "}
|
|
260
|
+
{formatDistanceToNow(new Date(run.startedAt), { addSuffix: true })}
|
|
261
|
+
</span>
|
|
262
|
+
{run.finishedAt && (
|
|
263
|
+
<span>
|
|
264
|
+
Finished{" "}
|
|
265
|
+
{formatDistanceToNow(new Date(run.finishedAt), { addSuffix: true })}
|
|
266
|
+
</span>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
</CardContent>
|
|
270
|
+
</Card>
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const StepRow: React.FC<{
|
|
274
|
+
step: {
|
|
275
|
+
id: string;
|
|
276
|
+
actionPath: string;
|
|
277
|
+
actionKind: string;
|
|
278
|
+
providerActionId: string | null;
|
|
279
|
+
actionId: string | null;
|
|
280
|
+
status: StepStatus;
|
|
281
|
+
attempts: number;
|
|
282
|
+
errorMessage?: string;
|
|
283
|
+
resultPayload?: Record<string, unknown>;
|
|
284
|
+
};
|
|
285
|
+
}> = ({ step }) => {
|
|
286
|
+
const Icon = STEP_STATUS_ICON[step.status];
|
|
287
|
+
const colorClass = STEP_STATUS_COLOR[step.status];
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className="rounded border border-border bg-card">
|
|
291
|
+
<div className="flex items-start gap-2 p-2">
|
|
292
|
+
<Icon className={`mt-0.5 h-4 w-4 shrink-0 ${colorClass}`} />
|
|
293
|
+
<div className="flex-1 min-w-0">
|
|
294
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
295
|
+
<code className="truncate font-mono text-xs">
|
|
296
|
+
{step.actionPath}
|
|
297
|
+
</code>
|
|
298
|
+
<Badge variant="outline" className="text-[10px]">
|
|
299
|
+
{step.providerActionId ?? step.actionKind}
|
|
300
|
+
</Badge>
|
|
301
|
+
{step.actionId && (
|
|
302
|
+
<Badge variant="secondary" className="text-[10px]">
|
|
303
|
+
id: {step.actionId}
|
|
304
|
+
</Badge>
|
|
305
|
+
)}
|
|
306
|
+
{step.attempts > 1 && (
|
|
307
|
+
<span className="text-[10px] text-muted-foreground">
|
|
308
|
+
{step.attempts} attempts
|
|
309
|
+
</span>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
{step.errorMessage && (
|
|
313
|
+
<p className="mt-1 whitespace-pre-wrap font-mono text-xs text-destructive">
|
|
314
|
+
{step.errorMessage}
|
|
315
|
+
</p>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
{step.resultPayload && Object.keys(step.resultPayload).length > 0 && (
|
|
320
|
+
<details className="border-t border-border">
|
|
321
|
+
<summary className="cursor-pointer px-2 py-1 text-[10px] text-muted-foreground">
|
|
322
|
+
Result payload
|
|
323
|
+
</summary>
|
|
324
|
+
<pre className="overflow-x-auto p-2 text-xs">
|
|
325
|
+
{JSON.stringify(step.resultPayload, null, 2)}
|
|
326
|
+
</pre>
|
|
327
|
+
</details>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export const RunDetailPage = wrapInSuspense(RunDetailContent);
|