@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/package.json +38 -0
  3. package/src/components/AutomationMenuItems.tsx +37 -0
  4. package/src/editor/ActionEditor.tsx +367 -0
  5. package/src/editor/ActionListEditor.tsx +203 -0
  6. package/src/editor/AddActionDialog.tsx +225 -0
  7. package/src/editor/AutomationDefinitionContext.tsx +37 -0
  8. package/src/editor/AutomationDefinitionEditor.tsx +99 -0
  9. package/src/editor/ConditionEditor.tsx +218 -0
  10. package/src/editor/ConditionsEditor.tsx +89 -0
  11. package/src/editor/ItemPicker.tsx +147 -0
  12. package/src/editor/TriggersEditor.tsx +269 -0
  13. package/src/editor/action-composite-cards.tsx +390 -0
  14. package/src/editor/action-helpers.ts +365 -0
  15. package/src/editor/action-leaf-cards.tsx +426 -0
  16. package/src/editor/editor-validation.test.ts +95 -0
  17. package/src/editor/editor-validation.tsx +200 -0
  18. package/src/editor/registry-context.tsx +192 -0
  19. package/src/editor/template-completion.test.ts +412 -0
  20. package/src/editor/template-completion.ts +664 -0
  21. package/src/editor/template-helpers.test.ts +145 -0
  22. package/src/editor/template-helpers.ts +95 -0
  23. package/src/editor/trigger-helpers.test.ts +58 -0
  24. package/src/editor/trigger-helpers.ts +67 -0
  25. package/src/editor/useConnectionOptionResolvers.ts +80 -0
  26. package/src/editor/yaml-markers.ts +116 -0
  27. package/src/index.tsx +95 -0
  28. package/src/pages/AutomationEditPage.tsx +567 -0
  29. package/src/pages/AutomationListPage.tsx +304 -0
  30. package/src/pages/RunDetailPage.tsx +333 -0
  31. package/src/pages/RunsPage.tsx +233 -0
  32. package/src/pages/TemplatePlaygroundPage.tsx +224 -0
  33. package/src/script-context.test.ts +247 -0
  34. package/src/script-context.ts +218 -0
  35. 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);