1"use client";
2
3
13import { useState } from "react";
14import { useQuery, useQueryClient } from "@tanstack/react-query";
15import { Loader2, RefreshCw } from "lucide-react";
16import { apiFetch } from "@/lib/api";
17import { toast } from "@/stores/ui-store";
18import { formatRelativeTime } from "@/lib/utils";
19import { runStatusLabel, runStatusCls, type RunDetail, type RunLog, type RunSummary } from "./lib";
20
21interface RunHistoryProps {
22 automationId: string;
23
24 compact?: boolean;
25}
26
27export function RunHistory({ automationId, compact }: RunHistoryProps) {
28 const [expanded, setExpanded] = useState<string | null>(null);
29
30 const runs = useQuery({
31 queryKey: ["automations", automationId, "runs"],
32 queryFn: async (): Promise<RunSummary[]> => {
33 const res = await apiFetch<{ data: RunSummary[]; nextCursor: string | null }>(
34 `/automations/${automationId}/runs?limit=50`
35 );
36 return res.data;
37 },
38 enabled: !!automationId,
39
40 refetchInterval: (q) => (q.state.data?.some((r) => r.status === "PENDING" || r.status === "RUNNING") ? 4000 : false)
41 });
42
43 const page = runs.data ?? [];
44
45 return (
46 <div className={compact ? "mt-2 border-t border-border pt-2" : ""}>
47 <ul className={compact ? "space-y-1" : "space-y-1.5"}>
48 {runs.isLoading && <li className="text-xs text-muted-foreground">Loading runs…</li>}
49 {!runs.isLoading && page.length === 0 && (
50 <li className="text-xs text-muted-foreground">No runs yet. Use the Run now button to trigger one.</li>
51 )}
52 {page.map((r) => (
53 <li key={r.id} className="rounded-md border border-border">
54 <div
55 data-testid={`automation-run-${r.id}`}
56 className="flex w-full flex-wrap items-center gap-x-3 gap-y-1 px-2.5 py-1.5 text-left text-xs"
57 >
58 <button
59 type="button"
60 className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 text-left hover:opacity-80"
61 onClick={() => setExpanded(expanded === r.id ? null : r.id)}
62 aria-expanded={expanded === r.id}
63 aria-label={`Expand run ${runStatusLabel(r.status)}`}
64 >
65 <span className={`w-24 shrink-0 rounded px-1.5 py-0.5 text-center font-medium ${runStatusCls(r.status)}`}>
66 {runStatusLabel(r.status)}
67 </span>
68 <span className="shrink-0 text-muted-foreground">{r.triggerType ?? "—"}</span>
69 <span
70 className="min-w-0 flex-1 truncate text-muted-foreground"
71 title={`started ${formatRelativeTime(r.startedAt ?? r.createdAt)}`}
72 >
73 {r.startedAt ? formatRelativeTime(r.startedAt) : "not started"}
74 {durationOf(r) ? ` · ${durationOf(r)}` : ""}
75 </span>
76 <span className="shrink-0 text-muted-foreground">retries: {r.retryCount}</span>
77 </button>
78 {(r.status === "FAILED" || r.status === "DEAD_LETTER") && (
79 <RetryButton automationId={automationId} runId={r.id} />
80 )}
81 </div>
82 {expanded === r.id && <RunLogs automationId={automationId} runId={r.id} />}
83 </li>
84 ))}
85 </ul>
86 </div>
87 );
88}
89
90function RetryButton({ automationId, runId }: { automationId: string; runId: string }) {
91 const qc = useQueryClient();
92 const [busy, setBusy] = useState(false);
93 return (
94 <button
95 type="button"
96 data-testid={`automation-retry-run-${runId}`}
97 className="inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-0.5 text-xs disabled:opacity-50"
98 disabled={busy}
99 onClick={async () => {
100 setBusy(true);
101 try {
102 await apiFetch(`/automations/${automationId}/runs/${runId}/retry`, { method: "POST" });
103 toast("success", "Run re-queued");
104 qc.invalidateQueries({ queryKey: ["automations", automationId, "runs"] });
105 qc.invalidateQueries({ queryKey: ["automations", automationId, "run", runId] });
106 } catch (err) {
107 toast("error", "Retry failed", err instanceof Error ? err.message : undefined);
108 } finally {
109 setBusy(false);
110 }
111 }}
112 >
113 {busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
114 Retry
115 </button>
116 );
117}
118
119function RunLogs({ automationId, runId }: { automationId: string; runId: string }) {
120 const detail = useQuery({
121 queryKey: ["automations", automationId, "run", runId],
122 queryFn: async (): Promise<RunDetail> => {
123
124
125 const res = await apiFetch<RunDetail & { run?: RunDetail }>(`/automations/${automationId}/runs/${runId}`);
126 return res.run ? res.run : res;
127 }
128 });
129
130 if (detail.isLoading) return <p className="px-2.5 pb-2 text-xs text-muted-foreground">Loading logs…</p>;
131 if (detail.isError)
132 return <p className="px-2.5 pb-2 text-xs text-destructive">Failed to load logs: {(detail.error as Error).message}</p>;
133
134 const logs: RunLog[] = detail.data?.logs ?? [];
135 return (
136 <div className="px-2.5 pb-2.5">
137 <div
138 data-testid={`automation-run-log-${runId}`}
139 className="max-h-72 overflow-auto rounded-md bg-zinc-950 p-3 font-mono text-[11px] leading-5 text-zinc-100"
140 >
141 {detail.data?.error != null && <p className="mb-1 whitespace-pre-wrap text-red-400">error: {jsonText(detail.data.error)}</p>}
142 {logs.length === 0 && <p className="text-zinc-500">no log lines</p>}
143 {logs.map((l, i) => (
144 <p key={i} className="whitespace-pre-wrap break-all">
145 <span className="text-zinc-500">{new Date(l.createdAt).toISOString()} </span>
146 <span className={levelColor(l.level)}>{String(l.level).toUpperCase().padEnd(5)}</span>{" "}
147 {l.message}
148 {l.data != null ? <span className="text-zinc-400"> {jsonText(l.data)}</span> : null}
149 </p>
150 ))}
151 </div>
152 </div>
153 );
154}
155
156function levelColor(level: string): string {
157 switch (level?.toUpperCase()) {
158 case "ERROR":
159 return "text-red-400";
160 case "WARN":
161 return "text-amber-300";
162 case "INFO":
163 return "text-sky-300";
164 default:
165 return "text-zinc-300";
166 }
167}
168
169function jsonText(v: unknown): string {
170 try {
171 return typeof v === "string" ? v : JSON.stringify(v);
172 } catch {
173 return String(v);
174 }
175}
176
177function durationOf(r: RunSummary): string {
178 if (!r.startedAt || !r.finishedAt) return "";
179 const ms = new Date(r.finishedAt).getTime() - new Date(r.startedAt).getTime();
180 if (!Number.isFinite(ms) || ms < 0) return "";
181 return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
182}
183