feat(web): workflow run detail page with live logs
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, ServerRun, StepRun } from "@/lib/api";
|
||||
import { Button, Badge, Card } from "@/components/ui";
|
||||
|
||||
type BadgeVariant = "success" | "warning" | "danger" | "neutral" | "accent";
|
||||
|
||||
const statusVariant: Record<string, BadgeVariant> = {
|
||||
success: "success",
|
||||
failed: "danger",
|
||||
running: "accent",
|
||||
queued: "neutral",
|
||||
skipped: "neutral",
|
||||
cancelled: "warning",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant={statusVariant[status] ?? "neutral"}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export default function RunDetail() {
|
||||
const { runId } = useParams<{ runId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: run, isLoading } = useQuery({
|
||||
queryKey: ["run", runId],
|
||||
queryFn: () => api.getRun(runId),
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||
});
|
||||
|
||||
const cancel = async () => {
|
||||
await api.cancelRun(runId);
|
||||
queryClient.invalidateQueries({ queryKey: ["run", runId] });
|
||||
};
|
||||
|
||||
if (isLoading || !run) {
|
||||
return <div className="p-8 text-text-secondary">Loading…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">{run.name}</h1>
|
||||
<p className="mt-1 flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>Run {run.run_id.slice(0, 8)}</span>
|
||||
<StatusBadge status={run.status} />
|
||||
</p>
|
||||
</div>
|
||||
{run.status === "running" && (
|
||||
<Button variant="danger" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{run.server_runs.map((sr: ServerRun) => (
|
||||
<Card key={sr.server_id}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-text-primary">{sr.hostname}</span>
|
||||
<StatusBadge status={sr.status} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sr.steps.map((st: StepRun) => (
|
||||
<details
|
||||
key={st.order}
|
||||
className="rounded-lg border border-border bg-surface-2 p-2"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center justify-between gap-2">
|
||||
<span className="text-sm text-text-primary">{st.name}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">
|
||||
attempts: {st.attempts}
|
||||
{st.status === "failed" ? ` · exit ${st.exit_code}` : ""}
|
||||
</span>
|
||||
<StatusBadge status={st.status} />
|
||||
</span>
|
||||
</summary>
|
||||
{(st.stdout || st.stderr) && (
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-black/40 p-2 font-mono text-xs text-text-secondary">
|
||||
{st.stdout}
|
||||
{st.stderr ? `\n${st.stderr}` : ""}
|
||||
</pre>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
{sr.steps.length === 0 && (
|
||||
<p className="text-xs text-text-secondary">No steps yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{run.server_runs.length === 0 && (
|
||||
<p className="text-text-secondary">No servers targeted by this run.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user