@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.
@@ -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
- const RUN_STATUS_VARIANT: Record<
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} — runs`
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
- <Table>
156
- <TableHeader>
157
- <TableRow>
158
- <TableHead>Status</TableHead>
159
- <TableHead>Trigger</TableHead>
160
- <TableHead>Started</TableHead>
161
- <TableHead>Duration</TableHead>
162
- <TableHead className="w-24 text-right" />
163
- </TableRow>
164
- </TableHeader>
165
- <TableBody>
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
- <TableRow key={run.id}>
168
- <TableCell>
169
- <Badge
170
- variant={RUN_STATUS_VARIANT[run.status] ?? "outline"}
171
- className="capitalize"
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
- automationRoutes.routes.runDetail,
198
- {
199
- automationId,
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
- </TableCell>
210
- </TableRow>
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
- </TableBody>
213
- </Table>
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
+ };