5d2a14e50a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
5.5 KiB
TypeScript
192 lines
5.5 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import {
|
|
Title,
|
|
Text,
|
|
Group,
|
|
Button,
|
|
Stack,
|
|
Paper,
|
|
Alert,
|
|
Loader,
|
|
CopyButton,
|
|
Tooltip,
|
|
ActionIcon,
|
|
Code,
|
|
} from "@mantine/core";
|
|
import { IconArrowLeft, IconCopy, IconCheck } from "@tabler/icons-react";
|
|
import { getRun } from "../api";
|
|
import { StatusBadge } from "../components/StatusBadge";
|
|
import { StepTimeline } from "../components/StepTimeline";
|
|
import type { RunDetail as RunDetailType, DagStepResult, DagRun } from "../types";
|
|
|
|
function buildLogText(run: DagRun, steps: DagStepResult[]): string {
|
|
const lines: string[] = [];
|
|
const started = run.StartedAt ? new Date(run.StartedAt) : null;
|
|
const finished = run.FinishedAt ? new Date(run.FinishedAt) : null;
|
|
const durationMs =
|
|
started && finished ? finished.getTime() - started.getTime() : null;
|
|
|
|
lines.push(`=== DAG run ${run.ID} ===`);
|
|
lines.push(`dag: ${run.DagName}`);
|
|
lines.push(`path: ${run.DagPath}`);
|
|
lines.push(`status: ${run.Status}`);
|
|
lines.push(`trigger: ${run.Trigger}`);
|
|
lines.push(`started: ${started ? started.toISOString() : "-"}`);
|
|
lines.push(`finished: ${finished ? finished.toISOString() : "-"}`);
|
|
lines.push(
|
|
`duration: ${durationMs !== null ? `${durationMs} ms` : "running..."}`
|
|
);
|
|
if (run.Error) {
|
|
lines.push("");
|
|
lines.push("run error:");
|
|
lines.push(run.Error);
|
|
}
|
|
lines.push("");
|
|
lines.push(`--- steps (${steps.length}) ---`);
|
|
for (const s of steps) {
|
|
lines.push("");
|
|
lines.push(
|
|
`[${s.Status}] ${s.StepName} exit=${s.ExitCode} ${s.DurationMs}ms`
|
|
);
|
|
if (s.StartedAt) lines.push(` started: ${s.StartedAt}`);
|
|
if (s.FinishedAt) lines.push(` finished: ${s.FinishedAt}`);
|
|
if (s.Error) {
|
|
lines.push(" error:");
|
|
lines.push(s.Error.split("\n").map((l) => " " + l).join("\n"));
|
|
}
|
|
if (s.Stdout) {
|
|
lines.push(" stdout:");
|
|
lines.push(s.Stdout.split("\n").map((l) => " " + l).join("\n"));
|
|
}
|
|
if (s.Stderr) {
|
|
lines.push(" stderr:");
|
|
lines.push(s.Stderr.split("\n").map((l) => " " + l).join("\n"));
|
|
}
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function RunDetail() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [data, setData] = useState<RunDetailType | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = async () => {
|
|
if (!id) return;
|
|
try {
|
|
setData(await getRun(id));
|
|
setError(null);
|
|
} catch (e) {
|
|
setError((e as Error).message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
// Auto-refresh while running.
|
|
const interval = setInterval(() => {
|
|
if (data?.run.Status === "running") {
|
|
load();
|
|
}
|
|
}, 2000);
|
|
return () => clearInterval(interval);
|
|
}, [id, data?.run.Status]);
|
|
|
|
if (loading) return <Loader />;
|
|
if (error) return <Alert color="red">{error}</Alert>;
|
|
if (!data) return <Text>Not found</Text>;
|
|
|
|
const { run, steps } = data;
|
|
const duration = run.FinishedAt
|
|
? `${Math.round((new Date(run.FinishedAt).getTime() - new Date(run.StartedAt).getTime()) / 1000)}s`
|
|
: "running...";
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<Group>
|
|
<Button
|
|
variant="subtle"
|
|
size="xs"
|
|
leftSection={<IconArrowLeft size={14} />}
|
|
onClick={() => navigate(`/dags/${run.DagName}`)}
|
|
>
|
|
Back to {run.DagName}
|
|
</Button>
|
|
</Group>
|
|
|
|
<Group justify="space-between">
|
|
<div>
|
|
<Title order={2}>Run {run.ID.substring(0, 16)}...</Title>
|
|
<Text size="sm" c="dimmed">
|
|
{run.DagName} · {run.Trigger} ·{" "}
|
|
{new Date(run.StartedAt).toLocaleString()}
|
|
</Text>
|
|
</div>
|
|
<Group gap="xs">
|
|
<StatusBadge status={run.Status} />
|
|
<Text size="sm">{duration}</Text>
|
|
</Group>
|
|
</Group>
|
|
|
|
{run.Error && (
|
|
<Alert color="red" title="Error">
|
|
{run.Error}
|
|
</Alert>
|
|
)}
|
|
|
|
<Paper p="md" withBorder>
|
|
<Title order={4} mb="md">
|
|
Steps ({steps?.length || 0})
|
|
</Title>
|
|
{steps?.length ? (
|
|
<StepTimeline steps={steps} />
|
|
) : (
|
|
<Text size="sm" c="dimmed">
|
|
No steps recorded
|
|
</Text>
|
|
)}
|
|
</Paper>
|
|
|
|
<Paper p="md" withBorder>
|
|
<Group justify="space-between" mb="sm">
|
|
<Title order={4}>Logs</Title>
|
|
<CopyButton value={buildLogText(run, steps || [])} timeout={1500}>
|
|
{({ copied, copy }) => (
|
|
<Tooltip
|
|
label={copied ? "Copiado" : "Copiar log completo"}
|
|
withArrow
|
|
position="left"
|
|
>
|
|
<ActionIcon
|
|
variant={copied ? "filled" : "light"}
|
|
color={copied ? "teal" : "blue"}
|
|
onClick={copy}
|
|
aria-label="Copiar logs"
|
|
>
|
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</CopyButton>
|
|
</Group>
|
|
<Code
|
|
block
|
|
style={{
|
|
maxHeight: 480,
|
|
overflow: "auto",
|
|
whiteSpace: "pre",
|
|
fontSize: 12,
|
|
}}
|
|
>
|
|
{buildLogText(run, steps || [])}
|
|
</Code>
|
|
</Paper>
|
|
</Stack>
|
|
);
|
|
}
|