@checkstack/automation-frontend 0.7.1 → 0.8.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 +160 -0
- package/package.json +16 -16
- package/src/components/RunAsServiceAccountPicker.tsx +12 -12
- package/src/components/SaveBlockersSummary.tsx +75 -0
- package/src/editor/RunReplayPicker.tsx +1 -1
- package/src/editor/blocking-issues.test.ts +77 -0
- package/src/editor/blocking-issues.ts +68 -0
- package/src/editor/condition-kind.ts +1 -1
- package/src/pages/AutomationEditPage.tsx +132 -32
- package/src/pages/AutomationListPage.tsx +241 -38
- package/src/pages/AutomationTemplatePickerPage.tsx +27 -31
- package/src/pages/RunDetailPage.tsx +89 -32
- package/src/pages/RunsPage.tsx +96 -76
- package/src/pages/run-duration.test.ts +36 -0
- package/src/pages/run-duration.ts +18 -0
- package/src/pages/run-status-pill.tsx +78 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
usePluginClient,
|
|
6
6
|
accessApiRef,
|
|
7
7
|
useApi,
|
|
8
|
+
useQueryClient,
|
|
8
9
|
wrapInSuspense,
|
|
9
10
|
} from "@checkstack/frontend-api";
|
|
10
11
|
import {
|
|
@@ -28,6 +29,8 @@ import {
|
|
|
28
29
|
TableHead,
|
|
29
30
|
TableBody,
|
|
30
31
|
TableCell,
|
|
32
|
+
ResponsiveTable,
|
|
33
|
+
MobileCardList,
|
|
31
34
|
LoadingSpinner,
|
|
32
35
|
QueryErrorState,
|
|
33
36
|
EmptyState,
|
|
@@ -37,11 +40,22 @@ import {
|
|
|
37
40
|
AccordionTrigger,
|
|
38
41
|
AccordionContent,
|
|
39
42
|
useToast,
|
|
43
|
+
toastError,
|
|
44
|
+
cn,
|
|
40
45
|
} from "@checkstack/ui";
|
|
41
|
-
import {
|
|
46
|
+
import { resolveRoute } from "@checkstack/common";
|
|
42
47
|
import { formatDistanceToNow } from "date-fns";
|
|
43
48
|
import { groupAutomations } from "./automation-grouping";
|
|
44
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Left status-accent class for an automation's enabled/disabled state. Reuses
|
|
52
|
+
* the colorblind-safe triad so the stripe encodes status by position + hue,
|
|
53
|
+
* not by the toggle switch alone: an enabled automation reads `ok`, a disabled
|
|
54
|
+
* one reads muted/`unknown`. Spelled out so Tailwind's JIT keeps the classes.
|
|
55
|
+
*/
|
|
56
|
+
const automationAccent = (status: Automation["status"]): string =>
|
|
57
|
+
status === "enabled" ? "bg-status-ok" : "bg-status-unknown/60";
|
|
58
|
+
|
|
45
59
|
/**
|
|
46
60
|
* Lists every automation the operator can see, with quick enable / disable
|
|
47
61
|
* toggle and delete. The "Create" button navigates to `/automation/new`
|
|
@@ -52,9 +66,18 @@ import { groupAutomations } from "./automation-grouping";
|
|
|
52
66
|
* the most common operation here is "find the one I broke", which a
|
|
53
67
|
* single sorted list of recent activity covers without a pager UX.
|
|
54
68
|
*/
|
|
69
|
+
/**
|
|
70
|
+
* Cache shape of the `listAutomations` loader: the paginated wrapper around the
|
|
71
|
+
* automation rows. Used to type the optimistic snapshot/patch on the toggle.
|
|
72
|
+
*/
|
|
73
|
+
type AutomationsQueryData = {
|
|
74
|
+
items: Automation[];
|
|
75
|
+
};
|
|
76
|
+
|
|
55
77
|
const AutomationListContent: React.FC = () => {
|
|
56
78
|
const client = usePluginClient(AutomationApi);
|
|
57
79
|
const accessApi = useApi(accessApiRef);
|
|
80
|
+
const queryClient = useQueryClient();
|
|
58
81
|
const toast = useToast();
|
|
59
82
|
const navigate = useNavigate();
|
|
60
83
|
|
|
@@ -68,19 +91,64 @@ const AutomationListContent: React.FC = () => {
|
|
|
68
91
|
>("all");
|
|
69
92
|
const [deleteId, setDeleteId] = React.useState<string | undefined>();
|
|
70
93
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
// The loader input depends on the status filter, so the cache key changes
|
|
95
|
+
// when the filter changes. Build the input once and derive the exact oRPC
|
|
96
|
+
// query key from it so the optimistic patch addresses the same cache entry.
|
|
97
|
+
// See `docs/.../frontend/optimistic-updates.md` for the query-key contract.
|
|
98
|
+
const listInput = React.useMemo(
|
|
99
|
+
() => ({
|
|
100
|
+
limit: 100,
|
|
101
|
+
offset: 0,
|
|
102
|
+
...(statusFilter === "all" ? {} : { status: statusFilter }),
|
|
103
|
+
}),
|
|
104
|
+
[statusFilter],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const automationsQueryKey = React.useMemo(
|
|
108
|
+
() =>
|
|
109
|
+
[
|
|
110
|
+
["automation", "listAutomations"],
|
|
111
|
+
{ input: listInput, type: "query" },
|
|
112
|
+
] as const,
|
|
113
|
+
[listInput],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const query = client.listAutomations.useQuery(listInput);
|
|
76
117
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
118
|
+
// Toggle is a cheap, low-risk per-row flip — apply the optimistic pattern so
|
|
119
|
+
// the switch flips on click instead of after the round-trip:
|
|
120
|
+
// 1. onMutate cancels in-flight refetches, snapshots, flips the row status.
|
|
121
|
+
// 2. onError rolls back from the snapshot, then surfaces a toast.
|
|
122
|
+
// 3. onSettled invalidates so server truth settles in either branch.
|
|
123
|
+
// 4. No success toast — the visible switch flip IS the feedback.
|
|
124
|
+
const toggleMutation = client.toggleAutomation.useMutation<{
|
|
125
|
+
previous: AutomationsQueryData | undefined;
|
|
126
|
+
}>({
|
|
127
|
+
onMutate: async ({ id, enabled }) => {
|
|
128
|
+
await queryClient.cancelQueries({ queryKey: automationsQueryKey });
|
|
129
|
+
const previous =
|
|
130
|
+
queryClient.getQueryData<AutomationsQueryData>(automationsQueryKey);
|
|
131
|
+
if (previous) {
|
|
132
|
+
queryClient.setQueryData<AutomationsQueryData>(automationsQueryKey, {
|
|
133
|
+
...previous,
|
|
134
|
+
items: previous.items.map((automation) =>
|
|
135
|
+
automation.id === id
|
|
136
|
+
? { ...automation, status: enabled ? "enabled" : "disabled" }
|
|
137
|
+
: automation,
|
|
138
|
+
),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return { previous };
|
|
142
|
+
},
|
|
143
|
+
onError: (error, _vars, ctx) => {
|
|
144
|
+
if (ctx?.previous) {
|
|
145
|
+
queryClient.setQueryData(automationsQueryKey, ctx.previous);
|
|
146
|
+
}
|
|
147
|
+
toastError(toast, "Failed to toggle automation", error);
|
|
148
|
+
},
|
|
149
|
+
onSettled: () => {
|
|
150
|
+
void queryClient.invalidateQueries({ queryKey: automationsQueryKey });
|
|
82
151
|
},
|
|
83
|
-
onError: (error) => toast.error(extractErrorMessage(error)),
|
|
84
152
|
});
|
|
85
153
|
|
|
86
154
|
const deleteMutation = client.deleteAutomation.useMutation({
|
|
@@ -88,7 +156,7 @@ const AutomationListContent: React.FC = () => {
|
|
|
88
156
|
toast.success("Automation deleted");
|
|
89
157
|
setDeleteId(undefined);
|
|
90
158
|
},
|
|
91
|
-
onError: (error) => toast
|
|
159
|
+
onError: (error) => toastError(toast, "Failed to delete automation", error),
|
|
92
160
|
});
|
|
93
161
|
|
|
94
162
|
const items = query.data?.items;
|
|
@@ -106,7 +174,7 @@ const AutomationListContent: React.FC = () => {
|
|
|
106
174
|
const renderRow = (automation: Automation) => (
|
|
107
175
|
<TableRow
|
|
108
176
|
key={automation.id}
|
|
109
|
-
className="cursor-pointer hover:bg-
|
|
177
|
+
className="relative cursor-pointer transition-colors hover:bg-surface-inset"
|
|
110
178
|
onClick={() =>
|
|
111
179
|
navigate(
|
|
112
180
|
resolveRoute(automationRoutes.routes.edit, {
|
|
@@ -115,7 +183,18 @@ const AutomationListContent: React.FC = () => {
|
|
|
115
183
|
)
|
|
116
184
|
}
|
|
117
185
|
>
|
|
118
|
-
<TableCell
|
|
186
|
+
<TableCell
|
|
187
|
+
onClick={(e) => e.stopPropagation()}
|
|
188
|
+
className="relative pl-4"
|
|
189
|
+
>
|
|
190
|
+
{/* Status accent: enabled/disabled by position + hue, not toggle alone. */}
|
|
191
|
+
<span
|
|
192
|
+
className={cn(
|
|
193
|
+
"absolute inset-y-0 left-0 w-1",
|
|
194
|
+
automationAccent(automation.status),
|
|
195
|
+
)}
|
|
196
|
+
aria-hidden
|
|
197
|
+
/>
|
|
119
198
|
{canManage ? (
|
|
120
199
|
<Toggle
|
|
121
200
|
checked={automation.status === "enabled"}
|
|
@@ -142,8 +221,10 @@ const AutomationListContent: React.FC = () => {
|
|
|
142
221
|
)}
|
|
143
222
|
</TableCell>
|
|
144
223
|
<TableCell>
|
|
145
|
-
<div className="flex flex-col">
|
|
146
|
-
<span className="font-
|
|
224
|
+
<div className="flex flex-col gap-0.5">
|
|
225
|
+
<span className="font-semibold text-foreground">
|
|
226
|
+
{automation.name}
|
|
227
|
+
</span>
|
|
147
228
|
{automation.description && (
|
|
148
229
|
<span className="text-xs text-muted-foreground">
|
|
149
230
|
{automation.description}
|
|
@@ -157,13 +238,16 @@ const AutomationListContent: React.FC = () => {
|
|
|
157
238
|
<Badge
|
|
158
239
|
key={`${trigger.event}-${index}`}
|
|
159
240
|
variant="outline"
|
|
160
|
-
className="text-[10px] font-
|
|
241
|
+
className="font-mono text-[10px] font-normal text-muted-foreground"
|
|
161
242
|
>
|
|
162
243
|
{trigger.event}
|
|
163
244
|
</Badge>
|
|
164
245
|
))}
|
|
165
246
|
{automation.definition.triggers.length > 3 && (
|
|
166
|
-
<Badge
|
|
247
|
+
<Badge
|
|
248
|
+
variant="outline"
|
|
249
|
+
className="text-[10px] font-normal text-muted-foreground"
|
|
250
|
+
>
|
|
167
251
|
+{automation.definition.triggers.length - 3}
|
|
168
252
|
</Badge>
|
|
169
253
|
)}
|
|
@@ -208,6 +292,118 @@ const AutomationListContent: React.FC = () => {
|
|
|
208
292
|
</TableRow>
|
|
209
293
|
);
|
|
210
294
|
|
|
295
|
+
const renderMobileCard = (automation: Automation) => (
|
|
296
|
+
<div
|
|
297
|
+
key={automation.id}
|
|
298
|
+
className="group relative cursor-pointer overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)] transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-xl"
|
|
299
|
+
onClick={() =>
|
|
300
|
+
navigate(
|
|
301
|
+
resolveRoute(automationRoutes.routes.edit, {
|
|
302
|
+
automationId: automation.id,
|
|
303
|
+
}),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
>
|
|
307
|
+
{/* Status accent: enabled/disabled by position + hue, not toggle alone. */}
|
|
308
|
+
<span
|
|
309
|
+
className={cn(
|
|
310
|
+
"absolute inset-y-0 left-0 w-1",
|
|
311
|
+
automationAccent(automation.status),
|
|
312
|
+
)}
|
|
313
|
+
aria-hidden
|
|
314
|
+
/>
|
|
315
|
+
<div className="flex items-start justify-between gap-2 pl-2">
|
|
316
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
317
|
+
<span className="truncate text-sm font-semibold text-foreground">
|
|
318
|
+
{automation.name}
|
|
319
|
+
</span>
|
|
320
|
+
{automation.description && (
|
|
321
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
322
|
+
{automation.description}
|
|
323
|
+
</span>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
327
|
+
{canManage ? (
|
|
328
|
+
<Toggle
|
|
329
|
+
checked={automation.status === "enabled"}
|
|
330
|
+
onCheckedChange={(enabled) =>
|
|
331
|
+
toggleMutation.mutate({
|
|
332
|
+
id: automation.id,
|
|
333
|
+
enabled,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
aria-label={
|
|
337
|
+
automation.status === "enabled"
|
|
338
|
+
? "Disable automation"
|
|
339
|
+
: "Enable automation"
|
|
340
|
+
}
|
|
341
|
+
/>
|
|
342
|
+
) : (
|
|
343
|
+
<Badge
|
|
344
|
+
variant={automation.status === "enabled" ? "success" : "outline"}
|
|
345
|
+
>
|
|
346
|
+
{automation.status}
|
|
347
|
+
</Badge>
|
|
348
|
+
)}
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
<div className="mt-2 flex flex-wrap items-center gap-1 pl-2">
|
|
352
|
+
{automation.definition.triggers.slice(0, 3).map((trigger, index) => (
|
|
353
|
+
<Badge
|
|
354
|
+
key={`${trigger.event}-${index}`}
|
|
355
|
+
variant="outline"
|
|
356
|
+
className="font-mono text-[10px] font-normal text-muted-foreground"
|
|
357
|
+
>
|
|
358
|
+
{trigger.event}
|
|
359
|
+
</Badge>
|
|
360
|
+
))}
|
|
361
|
+
{automation.definition.triggers.length > 3 && (
|
|
362
|
+
<Badge
|
|
363
|
+
variant="outline"
|
|
364
|
+
className="text-[10px] font-normal text-muted-foreground"
|
|
365
|
+
>
|
|
366
|
+
+{automation.definition.triggers.length - 3}
|
|
367
|
+
</Badge>
|
|
368
|
+
)}
|
|
369
|
+
<Badge variant="secondary" className="text-[10px]">
|
|
370
|
+
{automation.definition.mode}
|
|
371
|
+
</Badge>
|
|
372
|
+
</div>
|
|
373
|
+
<div className="mt-1 pl-2 text-xs text-muted-foreground">
|
|
374
|
+
Updated{" "}
|
|
375
|
+
{formatDistanceToNow(new Date(automation.updatedAt), {
|
|
376
|
+
addSuffix: true,
|
|
377
|
+
})}
|
|
378
|
+
</div>
|
|
379
|
+
<div
|
|
380
|
+
onClick={(e) => e.stopPropagation()}
|
|
381
|
+
className="mt-3 flex justify-end gap-1 pl-2"
|
|
382
|
+
>
|
|
383
|
+
<Link
|
|
384
|
+
to={resolveRoute(automationRoutes.routes.runs, {
|
|
385
|
+
automationId: automation.id,
|
|
386
|
+
})}
|
|
387
|
+
>
|
|
388
|
+
<Button variant="ghost" size="sm">
|
|
389
|
+
Runs
|
|
390
|
+
</Button>
|
|
391
|
+
</Link>
|
|
392
|
+
{canManage && (
|
|
393
|
+
<Button
|
|
394
|
+
variant="ghost"
|
|
395
|
+
size="icon"
|
|
396
|
+
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
|
397
|
+
onClick={() => setDeleteId(automation.id)}
|
|
398
|
+
aria-label="Delete automation"
|
|
399
|
+
>
|
|
400
|
+
<Trash2 className="h-4 w-4" />
|
|
401
|
+
</Button>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
|
|
211
407
|
return (
|
|
212
408
|
<PageLayout
|
|
213
409
|
title="Automations"
|
|
@@ -305,25 +501,32 @@ const AutomationListContent: React.FC = () => {
|
|
|
305
501
|
</span>
|
|
306
502
|
</AccordionTrigger>
|
|
307
503
|
<AccordionContent className="px-0 pb-0">
|
|
308
|
-
<
|
|
309
|
-
<
|
|
310
|
-
<
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
504
|
+
<ResponsiveTable>
|
|
505
|
+
<Table>
|
|
506
|
+
<TableHeader>
|
|
507
|
+
<TableRow>
|
|
508
|
+
<TableHead className="w-8" />
|
|
509
|
+
<TableHead>Name</TableHead>
|
|
510
|
+
<TableHead>Triggers</TableHead>
|
|
511
|
+
<TableHead>Mode</TableHead>
|
|
512
|
+
<TableHead>Updated</TableHead>
|
|
513
|
+
<TableHead className="w-24 text-right">
|
|
514
|
+
Actions
|
|
515
|
+
</TableHead>
|
|
516
|
+
</TableRow>
|
|
517
|
+
</TableHeader>
|
|
518
|
+
<TableBody>
|
|
519
|
+
{group.items.map((automation) =>
|
|
520
|
+
renderRow(automation),
|
|
521
|
+
)}
|
|
522
|
+
</TableBody>
|
|
523
|
+
</Table>
|
|
524
|
+
</ResponsiveTable>
|
|
525
|
+
<MobileCardList className="p-2">
|
|
526
|
+
{group.items.map((automation) =>
|
|
527
|
+
renderMobileCard(automation),
|
|
528
|
+
)}
|
|
529
|
+
</MobileCardList>
|
|
327
530
|
</AccordionContent>
|
|
328
531
|
</AccordionItem>
|
|
329
532
|
))}
|
|
@@ -15,10 +15,6 @@ import {
|
|
|
15
15
|
} from "@checkstack/automation-common";
|
|
16
16
|
import {
|
|
17
17
|
PageLayout,
|
|
18
|
-
Card,
|
|
19
|
-
CardHeader,
|
|
20
|
-
CardTitle,
|
|
21
|
-
CardContent,
|
|
22
18
|
Button,
|
|
23
19
|
Badge,
|
|
24
20
|
DynamicIcon,
|
|
@@ -75,25 +71,25 @@ const AutomationTemplatePickerContent: React.FC = () => {
|
|
|
75
71
|
allowed={canManage}
|
|
76
72
|
>
|
|
77
73
|
<div className="space-y-6">
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
<div className="flex items-center
|
|
81
|
-
<
|
|
82
|
-
|
|
74
|
+
<div className="relative flex flex-col gap-3 overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)] sm:flex-row sm:items-center sm:justify-between">
|
|
75
|
+
<div className="flex items-center gap-3">
|
|
76
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-surface-inset">
|
|
77
|
+
<FilePlus2 className="h-5 w-5 text-muted-foreground" />
|
|
78
|
+
</div>
|
|
79
|
+
<div>
|
|
80
|
+
<div className="font-semibold text-foreground">
|
|
81
|
+
Blank automation
|
|
83
82
|
</div>
|
|
84
|
-
<div>
|
|
85
|
-
|
|
86
|
-
<div className="text-sm text-muted-foreground">
|
|
87
|
-
Start with an empty editor and add triggers and actions yourself.
|
|
88
|
-
</div>
|
|
83
|
+
<div className="text-sm text-muted-foreground">
|
|
84
|
+
Start with an empty editor and add triggers and actions yourself.
|
|
89
85
|
</div>
|
|
90
86
|
</div>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
</
|
|
96
|
-
</
|
|
87
|
+
</div>
|
|
88
|
+
<Button onClick={startBlank}>
|
|
89
|
+
Start blank
|
|
90
|
+
<ArrowRight className="ml-1 h-4 w-4" />
|
|
91
|
+
</Button>
|
|
92
|
+
</div>
|
|
97
93
|
|
|
98
94
|
{query.isLoading ? (
|
|
99
95
|
<div className="p-6">
|
|
@@ -109,20 +105,20 @@ const AutomationTemplatePickerContent: React.FC = () => {
|
|
|
109
105
|
</h2>
|
|
110
106
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
111
107
|
{items.map((template) => (
|
|
112
|
-
<
|
|
108
|
+
<div
|
|
113
109
|
key={template.id}
|
|
114
|
-
className="flex h-full cursor-pointer flex-col transition-
|
|
110
|
+
className="group relative flex h-full cursor-pointer flex-col overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)] transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-xl"
|
|
115
111
|
onClick={() => openTemplate(template)}
|
|
116
112
|
>
|
|
117
|
-
<
|
|
113
|
+
<div className="flex items-start gap-3">
|
|
118
114
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
|
119
115
|
<DynamicIcon name={template.icon} className="h-5 w-5" />
|
|
120
116
|
</div>
|
|
121
|
-
<
|
|
117
|
+
<h3 className="text-base font-semibold leading-snug text-foreground">
|
|
122
118
|
{template.name}
|
|
123
|
-
</
|
|
124
|
-
</
|
|
125
|
-
<
|
|
119
|
+
</h3>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="mt-3 flex flex-1 flex-col justify-between gap-3">
|
|
126
122
|
<p className="text-sm text-muted-foreground">
|
|
127
123
|
{template.description}
|
|
128
124
|
</p>
|
|
@@ -130,13 +126,13 @@ const AutomationTemplatePickerContent: React.FC = () => {
|
|
|
130
126
|
<Badge variant="secondary" className="font-normal">
|
|
131
127
|
{template.ownerPluginId}
|
|
132
128
|
</Badge>
|
|
133
|
-
<span className="flex items-center text-sm font-medium text-primary">
|
|
129
|
+
<span className="flex items-center text-sm font-medium text-primary transition-colors group-hover:text-primary">
|
|
134
130
|
Use template
|
|
135
|
-
<ArrowRight className="ml-1 h-4 w-4" />
|
|
131
|
+
<ArrowRight className="ml-1 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
|
136
132
|
</span>
|
|
137
133
|
</div>
|
|
138
|
-
</
|
|
139
|
-
</
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
140
136
|
))}
|
|
141
137
|
</div>
|
|
142
138
|
</section>
|
|
@@ -37,14 +37,27 @@ import {
|
|
|
37
37
|
Alert,
|
|
38
38
|
AlertTitle,
|
|
39
39
|
AlertDescription,
|
|
40
|
+
cn,
|
|
40
41
|
} from "@checkstack/ui";
|
|
41
42
|
import { resolveRoute } from "@checkstack/common";
|
|
42
43
|
import { formatDistanceToNow } from "date-fns";
|
|
44
|
+
import {
|
|
45
|
+
RunStatusPill,
|
|
46
|
+
RUN_STATUS_TONE,
|
|
47
|
+
RUN_TONE_STYLES,
|
|
48
|
+
} from "./run-status-pill";
|
|
49
|
+
import { formatDuration } from "./run-duration";
|
|
43
50
|
|
|
44
51
|
const noop = (): void => {
|
|
45
52
|
return;
|
|
46
53
|
};
|
|
47
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Poll interval for a live (`running`/`waiting`) run. Polling stops the moment
|
|
57
|
+
* the run reaches a terminal status, so a finished run makes no further calls.
|
|
58
|
+
*/
|
|
59
|
+
const RUN_POLL_INTERVAL_MS = 2000;
|
|
60
|
+
|
|
48
61
|
const STEP_STATUS_ICON: Record<StepStatus, React.ComponentType<{ className?: string }>> = {
|
|
49
62
|
pending: Clock,
|
|
50
63
|
running: CircleDot,
|
|
@@ -54,26 +67,29 @@ const STEP_STATUS_ICON: Record<StepStatus, React.ComponentType<{ className?: str
|
|
|
54
67
|
waiting: Hourglass,
|
|
55
68
|
};
|
|
56
69
|
|
|
70
|
+
// Step status icons carry the signal via shape + the status triad hue (never
|
|
71
|
+
// hue alone): terminal outcomes map to the colorblind-safe status colors, while
|
|
72
|
+
// in-flight/neutral lifecycle states stay muted/primary.
|
|
57
73
|
const STEP_STATUS_COLOR: Record<StepStatus, string> = {
|
|
58
74
|
pending: "text-muted-foreground",
|
|
59
75
|
running: "text-primary",
|
|
60
|
-
success: "text-
|
|
61
|
-
failed: "text-
|
|
76
|
+
success: "text-status-ok",
|
|
77
|
+
failed: "text-status-down",
|
|
62
78
|
skipped: "text-muted-foreground",
|
|
63
|
-
waiting: "text-
|
|
79
|
+
waiting: "text-status-warn",
|
|
64
80
|
};
|
|
65
81
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
success: "
|
|
74
|
-
failed: "
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
// Left status-accent for each step, mirroring STEP_STATUS_COLOR's tone intent
|
|
83
|
+
// so a failed/waiting step reads down the timeline's left edge by position +
|
|
84
|
+
// hue, not the icon alone. Neutral lifecycle states stay muted. Spelled out as
|
|
85
|
+
// full literal strings so Tailwind's JIT keeps them.
|
|
86
|
+
const STEP_STATUS_ACCENT: Record<StepStatus, string> = {
|
|
87
|
+
pending: "bg-muted-foreground/40",
|
|
88
|
+
running: "bg-primary",
|
|
89
|
+
success: "bg-status-ok",
|
|
90
|
+
failed: "bg-status-down",
|
|
91
|
+
skipped: "bg-muted-foreground/40",
|
|
92
|
+
waiting: "bg-status-warn",
|
|
77
93
|
};
|
|
78
94
|
|
|
79
95
|
/**
|
|
@@ -103,9 +119,24 @@ const RunDetailContent: React.FC = () => {
|
|
|
103
119
|
|
|
104
120
|
const query = client.getRun.useQuery(
|
|
105
121
|
{ id: runId ?? "" },
|
|
106
|
-
{
|
|
122
|
+
{
|
|
123
|
+
enabled: Boolean(runId),
|
|
124
|
+
// Live runs only: poll every 2s while the run is `running`/`waiting` so a
|
|
125
|
+
// user watching an execution sees steps progress without a manual reload.
|
|
126
|
+
// Returning `false` on a terminal status stops the polling entirely.
|
|
127
|
+
refetchInterval: (query) => {
|
|
128
|
+
const status = query.state.data?.run.status;
|
|
129
|
+
return status === "running" || status === "waiting"
|
|
130
|
+
? RUN_POLL_INTERVAL_MS
|
|
131
|
+
: false;
|
|
132
|
+
},
|
|
133
|
+
},
|
|
107
134
|
);
|
|
108
135
|
|
|
136
|
+
const isLive =
|
|
137
|
+
query.data?.run.status === "running" ||
|
|
138
|
+
query.data?.run.status === "waiting";
|
|
139
|
+
|
|
109
140
|
const cancelMutation = client.cancelRun.useMutation();
|
|
110
141
|
|
|
111
142
|
if (!automationId || !runId) {
|
|
@@ -133,6 +164,12 @@ const RunDetailContent: React.FC = () => {
|
|
|
133
164
|
allowed={allowed}
|
|
134
165
|
actions={
|
|
135
166
|
<div className="flex items-center gap-2">
|
|
167
|
+
{isLive && (
|
|
168
|
+
<span className="flex items-center gap-1.5 text-xs font-medium text-primary">
|
|
169
|
+
<CircleDot className="h-3.5 w-3.5" />
|
|
170
|
+
Live
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
136
173
|
<Link
|
|
137
174
|
to={resolveRoute(automationRoutes.routes.runs, { automationId })}
|
|
138
175
|
>
|
|
@@ -219,7 +256,7 @@ const RunDetailContent: React.FC = () => {
|
|
|
219
256
|
query.data.artifacts.map((artifact) => (
|
|
220
257
|
<details
|
|
221
258
|
key={artifact.id}
|
|
222
|
-
className="rounded border border-border bg-
|
|
259
|
+
className="rounded border border-border bg-surface-inset"
|
|
223
260
|
>
|
|
224
261
|
<summary className="flex cursor-pointer items-center justify-between px-2 py-1 text-xs">
|
|
225
262
|
<Badge variant="outline" className="font-mono">
|
|
@@ -248,13 +285,27 @@ const RunDetailContent: React.FC = () => {
|
|
|
248
285
|
|
|
249
286
|
const RunHeader: React.FC<{
|
|
250
287
|
run: { status: RunStatus; startedAt: Date; finishedAt?: Date };
|
|
251
|
-
}> = ({ run }) =>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
288
|
+
}> = ({ run }) => {
|
|
289
|
+
const accent = RUN_TONE_STYLES[RUN_STATUS_TONE[run.status]].accent;
|
|
290
|
+
const duration = formatDuration(run.startedAt, run.finishedAt);
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div className="relative overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)]">
|
|
294
|
+
{/* Status accent stripe, matching the run-status pill's tone. */}
|
|
295
|
+
<span
|
|
296
|
+
className={cn("absolute inset-y-0 left-0 w-1", accent)}
|
|
297
|
+
aria-hidden
|
|
298
|
+
/>
|
|
299
|
+
<div className="flex items-start justify-between gap-4 pl-2">
|
|
300
|
+
<div>
|
|
301
|
+
<p className="text-3xl font-bold leading-none tabular-nums text-foreground">
|
|
302
|
+
{duration}
|
|
303
|
+
</p>
|
|
304
|
+
<p className="mt-1 text-xs text-muted-foreground">duration</p>
|
|
305
|
+
</div>
|
|
306
|
+
<RunStatusPill status={run.status} />
|
|
307
|
+
</div>
|
|
308
|
+
<div className="mt-4 flex flex-col gap-0.5 pl-2 text-xs text-muted-foreground">
|
|
258
309
|
<span>
|
|
259
310
|
Started{" "}
|
|
260
311
|
{formatDistanceToNow(new Date(run.startedAt), { addSuffix: true })}
|
|
@@ -266,9 +317,9 @@ const RunHeader: React.FC<{
|
|
|
266
317
|
</span>
|
|
267
318
|
)}
|
|
268
319
|
</div>
|
|
269
|
-
</
|
|
270
|
-
|
|
271
|
-
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
};
|
|
272
323
|
|
|
273
324
|
const StepRow: React.FC<{
|
|
274
325
|
step: {
|
|
@@ -285,11 +336,17 @@ const StepRow: React.FC<{
|
|
|
285
336
|
}> = ({ step }) => {
|
|
286
337
|
const Icon = STEP_STATUS_ICON[step.status];
|
|
287
338
|
const colorClass = STEP_STATUS_COLOR[step.status];
|
|
339
|
+
const accentClass = STEP_STATUS_ACCENT[step.status];
|
|
288
340
|
|
|
289
341
|
return (
|
|
290
|
-
<div className="rounded border border-border bg-
|
|
291
|
-
|
|
292
|
-
|
|
342
|
+
<div className="relative overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface">
|
|
343
|
+
{/* Step status accent: failed/waiting read down the left edge by hue. */}
|
|
344
|
+
<span
|
|
345
|
+
className={cn("absolute inset-y-0 left-0 w-1", accentClass)}
|
|
346
|
+
aria-hidden
|
|
347
|
+
/>
|
|
348
|
+
<div className="flex items-start gap-2 p-2 pl-4">
|
|
349
|
+
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", colorClass)} />
|
|
293
350
|
<div className="flex-1 min-w-0">
|
|
294
351
|
<div className="flex flex-wrap items-center gap-2">
|
|
295
352
|
<code className="truncate font-mono text-xs">
|
|
@@ -317,11 +374,11 @@ const StepRow: React.FC<{
|
|
|
317
374
|
</div>
|
|
318
375
|
</div>
|
|
319
376
|
{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">
|
|
377
|
+
<details className="border-t border-border/70">
|
|
378
|
+
<summary className="cursor-pointer px-2 py-1 pl-4 text-[10px] text-muted-foreground">
|
|
322
379
|
Result payload
|
|
323
380
|
</summary>
|
|
324
|
-
<pre className="overflow-x-auto p-2 text-xs">
|
|
381
|
+
<pre className="overflow-x-auto p-2 pl-4 text-xs">
|
|
325
382
|
{JSON.stringify(step.resultPayload, null, 2)}
|
|
326
383
|
</pre>
|
|
327
384
|
</details>
|