@checkstack/automation-frontend 0.7.1 → 0.9.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 +187 -0
- package/package.json +16 -16
- package/src/components/RunAsServiceAccountPicker.tsx +12 -12
- package/src/components/SaveBlockersSummary.tsx +75 -0
- package/src/editor/ConditionEditor.tsx +2 -0
- package/src/editor/RunReplayPicker.tsx +1 -1
- package/src/editor/TriggersEditor.tsx +4 -2
- package/src/editor/action-composite-cards.tsx +3 -2
- package/src/editor/action-leaf-cards.tsx +3 -2
- 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
package/src/pages/RunsPage.tsx
CHANGED
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
CardContent,
|
|
20
20
|
CardHeader,
|
|
21
21
|
CardTitle,
|
|
22
|
-
Badge,
|
|
23
22
|
Button,
|
|
24
23
|
Table,
|
|
25
24
|
TableHeader,
|
|
@@ -30,22 +29,13 @@ import {
|
|
|
30
29
|
LoadingSpinner,
|
|
31
30
|
QueryErrorState,
|
|
32
31
|
EmptyState,
|
|
32
|
+
ResponsiveTable,
|
|
33
|
+
MobileCardList,
|
|
33
34
|
} from "@checkstack/ui";
|
|
34
35
|
import { resolveRoute } from "@checkstack/common";
|
|
35
36
|
import { formatDistanceToNow } from "date-fns";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
RunStatus,
|
|
39
|
-
"default" | "secondary" | "outline" | "destructive" | "success" | "warning"
|
|
40
|
-
> = {
|
|
41
|
-
pending: "outline",
|
|
42
|
-
running: "secondary",
|
|
43
|
-
waiting: "warning",
|
|
44
|
-
success: "success",
|
|
45
|
-
failed: "destructive",
|
|
46
|
-
cancelled: "outline",
|
|
47
|
-
skipped: "outline",
|
|
48
|
-
};
|
|
37
|
+
import { RunStatusPill } from "./run-status-pill";
|
|
38
|
+
import { formatDuration } from "./run-duration";
|
|
49
39
|
|
|
50
40
|
/**
|
|
51
41
|
* Run history for a single automation. Status filter pinned to the top;
|
|
@@ -87,7 +77,7 @@ const RunsPageContent: React.FC = () => {
|
|
|
87
77
|
<PageLayout
|
|
88
78
|
title={
|
|
89
79
|
automationQuery.data
|
|
90
|
-
? `${automationQuery.data.name}
|
|
80
|
+
? `${automationQuery.data.name} - runs`
|
|
91
81
|
: "Run history"
|
|
92
82
|
}
|
|
93
83
|
subtitle="Past executions of this automation"
|
|
@@ -152,65 +142,107 @@ const RunsPageContent: React.FC = () => {
|
|
|
152
142
|
description="Manually trigger the automation from the edit page to generate a run."
|
|
153
143
|
/>
|
|
154
144
|
) : (
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
<
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
145
|
+
<>
|
|
146
|
+
<ResponsiveTable>
|
|
147
|
+
<Table>
|
|
148
|
+
<TableHeader>
|
|
149
|
+
<TableRow>
|
|
150
|
+
<TableHead>Status</TableHead>
|
|
151
|
+
<TableHead>Trigger</TableHead>
|
|
152
|
+
<TableHead>Started</TableHead>
|
|
153
|
+
<TableHead>Duration</TableHead>
|
|
154
|
+
<TableHead className="w-24 text-right" />
|
|
155
|
+
</TableRow>
|
|
156
|
+
</TableHeader>
|
|
157
|
+
<TableBody>
|
|
158
|
+
{runs.map((run) => (
|
|
159
|
+
<TableRow key={run.id}>
|
|
160
|
+
<TableCell>
|
|
161
|
+
<RunStatusPill status={run.status} />
|
|
162
|
+
</TableCell>
|
|
163
|
+
<TableCell>
|
|
164
|
+
<code className="font-mono text-xs">
|
|
165
|
+
{run.triggerEventId || "manual"}
|
|
166
|
+
</code>
|
|
167
|
+
</TableCell>
|
|
168
|
+
<TableCell>
|
|
169
|
+
<span className="text-xs text-muted-foreground">
|
|
170
|
+
{formatDistanceToNow(new Date(run.startedAt), {
|
|
171
|
+
addSuffix: true,
|
|
172
|
+
})}
|
|
173
|
+
</span>
|
|
174
|
+
</TableCell>
|
|
175
|
+
<TableCell>
|
|
176
|
+
<span className="text-xs text-muted-foreground">
|
|
177
|
+
{formatDuration(run.startedAt, run.finishedAt)}
|
|
178
|
+
</span>
|
|
179
|
+
</TableCell>
|
|
180
|
+
<TableCell className="text-right">
|
|
181
|
+
{automationId && (
|
|
182
|
+
<Link
|
|
183
|
+
to={resolveRoute(
|
|
184
|
+
automationRoutes.routes.runDetail,
|
|
185
|
+
{
|
|
186
|
+
automationId,
|
|
187
|
+
runId: run.id,
|
|
188
|
+
},
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
<Button variant="ghost" size="sm">
|
|
192
|
+
Open
|
|
193
|
+
</Button>
|
|
194
|
+
</Link>
|
|
195
|
+
)}
|
|
196
|
+
</TableCell>
|
|
197
|
+
</TableRow>
|
|
198
|
+
))}
|
|
199
|
+
</TableBody>
|
|
200
|
+
</Table>
|
|
201
|
+
</ResponsiveTable>
|
|
202
|
+
|
|
203
|
+
<MobileCardList className="p-4">
|
|
166
204
|
{runs.map((run) => (
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
{run.status}
|
|
174
|
-
</Badge>
|
|
175
|
-
</TableCell>
|
|
176
|
-
<TableCell>
|
|
177
|
-
<code className="font-mono text-xs">
|
|
178
|
-
{run.triggerEventId || "manual"}
|
|
179
|
-
</code>
|
|
180
|
-
</TableCell>
|
|
181
|
-
<TableCell>
|
|
182
|
-
<span className="text-xs text-muted-foreground">
|
|
183
|
-
{formatDistanceToNow(new Date(run.startedAt), {
|
|
184
|
-
addSuffix: true,
|
|
185
|
-
})}
|
|
186
|
-
</span>
|
|
187
|
-
</TableCell>
|
|
188
|
-
<TableCell>
|
|
189
|
-
<span className="text-xs text-muted-foreground">
|
|
190
|
-
{formatDuration(run.startedAt, run.finishedAt)}
|
|
191
|
-
</span>
|
|
192
|
-
</TableCell>
|
|
193
|
-
<TableCell className="text-right">
|
|
205
|
+
<div
|
|
206
|
+
key={run.id}
|
|
207
|
+
className="rounded-md border bg-surface p-4"
|
|
208
|
+
>
|
|
209
|
+
<div className="flex items-start justify-between gap-2">
|
|
210
|
+
<RunStatusPill status={run.status} />
|
|
194
211
|
{automationId && (
|
|
195
212
|
<Link
|
|
196
|
-
to={resolveRoute(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
runId: run.id,
|
|
201
|
-
},
|
|
202
|
-
)}
|
|
213
|
+
to={resolveRoute(automationRoutes.routes.runDetail, {
|
|
214
|
+
automationId,
|
|
215
|
+
runId: run.id,
|
|
216
|
+
})}
|
|
203
217
|
>
|
|
204
218
|
<Button variant="ghost" size="sm">
|
|
205
219
|
Open
|
|
206
220
|
</Button>
|
|
207
221
|
</Link>
|
|
208
222
|
)}
|
|
209
|
-
</
|
|
210
|
-
|
|
223
|
+
</div>
|
|
224
|
+
<div className="mt-2 flex flex-col gap-1 text-xs text-muted-foreground">
|
|
225
|
+
<div className="flex items-center gap-1">
|
|
226
|
+
<span>Trigger:</span>
|
|
227
|
+
<code className="font-mono">
|
|
228
|
+
{run.triggerEventId || "manual"}
|
|
229
|
+
</code>
|
|
230
|
+
</div>
|
|
231
|
+
<div>
|
|
232
|
+
Started{" "}
|
|
233
|
+
{formatDistanceToNow(new Date(run.startedAt), {
|
|
234
|
+
addSuffix: true,
|
|
235
|
+
})}
|
|
236
|
+
</div>
|
|
237
|
+
<div>
|
|
238
|
+
Duration:{" "}
|
|
239
|
+
{formatDuration(run.startedAt, run.finishedAt)}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
211
243
|
))}
|
|
212
|
-
</
|
|
213
|
-
|
|
244
|
+
</MobileCardList>
|
|
245
|
+
</>
|
|
214
246
|
)}
|
|
215
247
|
</CardContent>
|
|
216
248
|
</Card>
|
|
@@ -218,16 +250,4 @@ const RunsPageContent: React.FC = () => {
|
|
|
218
250
|
);
|
|
219
251
|
};
|
|
220
252
|
|
|
221
|
-
function formatDuration(
|
|
222
|
-
startedAt: Date | string,
|
|
223
|
-
finishedAt: Date | string | null | undefined,
|
|
224
|
-
): string {
|
|
225
|
-
if (!finishedAt) return "—";
|
|
226
|
-
const ms =
|
|
227
|
-
new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
228
|
-
if (ms < 1000) return `${ms}ms`;
|
|
229
|
-
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
230
|
-
return `${Math.round(ms / 60_000)}m`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
253
|
export const RunsPage = wrapInSuspense(RunsPageContent);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { formatDuration } from "./run-duration";
|
|
3
|
+
|
|
4
|
+
describe("formatDuration", () => {
|
|
5
|
+
it("returns the placeholder when the run has not finished", () => {
|
|
6
|
+
expect(formatDuration("2026-01-01T00:00:00Z", null)).toBe("—");
|
|
7
|
+
expect(formatDuration("2026-01-01T00:00:00Z", undefined)).toBe("—");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("reports sub-second durations in milliseconds", () => {
|
|
11
|
+
expect(
|
|
12
|
+
formatDuration("2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.450Z"),
|
|
13
|
+
).toBe("450ms");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("reports sub-minute durations as single-decimal seconds", () => {
|
|
17
|
+
expect(
|
|
18
|
+
formatDuration("2026-01-01T00:00:00.000Z", "2026-01-01T00:00:12.300Z"),
|
|
19
|
+
).toBe("12.3s");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("reports longer durations as rounded whole minutes", () => {
|
|
23
|
+
expect(
|
|
24
|
+
formatDuration("2026-01-01T00:00:00.000Z", "2026-01-01T00:02:30.000Z"),
|
|
25
|
+
).toBe("3m");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("accepts Date instances as well as strings", () => {
|
|
29
|
+
expect(
|
|
30
|
+
formatDuration(
|
|
31
|
+
new Date("2026-01-01T00:00:00.000Z"),
|
|
32
|
+
new Date("2026-01-01T00:00:05.000Z"),
|
|
33
|
+
),
|
|
34
|
+
).toBe("5.0s");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable elapsed time between a run's start and finish.
|
|
3
|
+
*
|
|
4
|
+
* Returns an em-dash placeholder while the run has not finished yet. Below a
|
|
5
|
+
* second it reports raw milliseconds, below a minute a single-decimal seconds
|
|
6
|
+
* value, and otherwise whole minutes. Shared by the run history list and the
|
|
7
|
+
* run-detail hero so both surface the same figure.
|
|
8
|
+
*/
|
|
9
|
+
export function formatDuration(
|
|
10
|
+
startedAt: Date | string,
|
|
11
|
+
finishedAt: Date | string | null | undefined,
|
|
12
|
+
): string {
|
|
13
|
+
if (!finishedAt) return "—";
|
|
14
|
+
const ms = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
15
|
+
if (ms < 1000) return `${ms}ms`;
|
|
16
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
17
|
+
return `${Math.round(ms / 60_000)}m`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@checkstack/ui";
|
|
3
|
+
import type { RunStatus } from "@checkstack/automation-common";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Colorblind-safe status tone for a run. Only the terminal health outcomes map
|
|
7
|
+
* to the status triad; in-flight and neutral lifecycle states (`pending`,
|
|
8
|
+
* `running`, `cancelled`, `skipped`) read as `unknown` so color is never the
|
|
9
|
+
* sole carrier of a "good vs bad" signal.
|
|
10
|
+
*/
|
|
11
|
+
export type RunStatusTone = "ok" | "warn" | "down" | "unknown";
|
|
12
|
+
|
|
13
|
+
export const RUN_STATUS_TONE: Record<RunStatus, RunStatusTone> = {
|
|
14
|
+
pending: "unknown",
|
|
15
|
+
running: "unknown",
|
|
16
|
+
waiting: "warn",
|
|
17
|
+
success: "ok",
|
|
18
|
+
failed: "down",
|
|
19
|
+
cancelled: "unknown",
|
|
20
|
+
skipped: "unknown",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Per-tone pill + dot + left-accent classes. Spelled out as full literal
|
|
25
|
+
* strings (not interpolated) so Tailwind's JIT keeps them, driven by the
|
|
26
|
+
* colorblind-safe status triad. Status reads as hue + dot + text label, never
|
|
27
|
+
* color alone. The `accent` class drives the left status stripe on cards and
|
|
28
|
+
* timeline rows so the stripe always matches this pill.
|
|
29
|
+
*/
|
|
30
|
+
export const RUN_TONE_STYLES: Record<
|
|
31
|
+
RunStatusTone,
|
|
32
|
+
{ pill: string; dot: string; accent: string }
|
|
33
|
+
> = {
|
|
34
|
+
ok: {
|
|
35
|
+
pill: "bg-status-ok/10 text-status-ok",
|
|
36
|
+
dot: "bg-status-ok",
|
|
37
|
+
accent: "bg-status-ok",
|
|
38
|
+
},
|
|
39
|
+
warn: {
|
|
40
|
+
pill: "bg-status-warn/10 text-status-warn",
|
|
41
|
+
dot: "bg-status-warn",
|
|
42
|
+
accent: "bg-status-warn",
|
|
43
|
+
},
|
|
44
|
+
down: {
|
|
45
|
+
pill: "bg-status-down/10 text-status-down",
|
|
46
|
+
dot: "bg-status-down",
|
|
47
|
+
accent: "bg-status-down",
|
|
48
|
+
},
|
|
49
|
+
unknown: {
|
|
50
|
+
pill: "bg-status-unknown/10 text-status-unknown",
|
|
51
|
+
dot: "bg-status-unknown",
|
|
52
|
+
accent: "bg-status-unknown",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Multi-encoded run-status pill: a tinted chip carrying a status dot plus the
|
|
58
|
+
* run status word. The visible label keeps the raw `run.status` value so it
|
|
59
|
+
* matches the status filter buttons one-to-one.
|
|
60
|
+
*/
|
|
61
|
+
export const RunStatusPill: React.FC<{
|
|
62
|
+
status: RunStatus;
|
|
63
|
+
className?: string;
|
|
64
|
+
}> = ({ status, className }) => {
|
|
65
|
+
const styles = RUN_TONE_STYLES[RUN_STATUS_TONE[status]];
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
className={cn(
|
|
69
|
+
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium capitalize",
|
|
70
|
+
styles.pill,
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
<span className={cn("size-1.5 rounded-full", styles.dot)} aria-hidden />
|
|
75
|
+
{status}
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
};
|