sing-ui/app/pipelines/[id]/page.tsx

350 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import { Play, GitCompareArrows, Pencil } from "lucide-react";
import {
api,
ApiError,
type Connection,
type Pipeline,
type RunWithPipeline,
type StreamConfig,
} from "@/components/api";
import { LogViewer } from "@/components/log-viewer";
import { StatusBadge } from "@/components/status-badge";
import { fmtTime, fmtDuration } from "@/components/format";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function PipelineDetailPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const [pipeline, setPipeline] = useState<Pipeline | null>(null);
const [connMap, setConnMap] = useState<Record<number, string>>({});
const [runs, setRuns] = useState<RunWithPipeline[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [runLoading, setRunLoading] = useState(false);
const [diffLoading, setDiffLoading] = useState(false);
const [diffSql, setDiffSql] = useState<string | null>(null);
const loadRuns = useCallback(() => {
api<RunWithPipeline[]>("/api/runs")
.then((all) =>
setRuns(all.filter((r) => r.pipeline_id === id))
)
.catch(() => {});
}, [id]);
useEffect(() => {
Promise.all([
api<Pipeline>(`/api/pipelines/${id}`),
api<Connection[]>("/api/connections"),
])
.then(([p, conns]) => {
setPipeline(p);
setConnMap(Object.fromEntries(conns.map((c) => [c.id, c.name])));
})
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
.finally(() => setLoading(false));
loadRuns();
}, [id, loadRuns]);
// 有运行中的任务时轮询刷新历史列表
const hasActive = runs.some(
(r) => r.status === "running" || r.status === "queued"
);
useEffect(() => {
if (!hasActive) return;
const t = setInterval(loadRuns, 4000);
return () => clearInterval(t);
}, [hasActive, loadRuns]);
const streams = useMemo<StreamConfig[]>(() => {
if (!pipeline) return [];
try {
const v = JSON.parse(pipeline.streams);
return Array.isArray(v) ? v : [];
} catch {
return [];
}
}, [pipeline]);
const envEntries = useMemo<[string, string][]>(() => {
if (!pipeline) return [];
try {
const v = JSON.parse(pipeline.env || "{}") as unknown;
if (v && typeof v === "object" && !Array.isArray(v)) {
return Object.entries(v as Record<string, unknown>).map(([k, val]) => [
k,
String(val),
]);
}
} catch {
// fall through
}
return [];
}, [pipeline]);
const latestRun = runs[0] ?? null;
async function runNow() {
setRunLoading(true);
try {
await api(`/api/pipelines/${id}/run`, { method: "POST" });
toast.success("已开始运行");
loadRuns();
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
toast.info("该流水线正在运行中,请等待当前运行结束");
} else {
toast.error(e instanceof Error ? e.message : "启动失败");
}
} finally {
setRunLoading(false);
}
}
async function previewDiff() {
setDiffLoading(true);
setDiffSql(null);
try {
const res = await api<{ ok: boolean; sql: string }>(
`/api/pipelines/${id}/diff`
);
setDiffSql(res.sql || "-- 无差异schema 已一致");
if (!res.ok) toast.info("Atlas 返回了非零状态,请检查 SQL 内容");
} catch (e) {
toast.error(e instanceof Error ? e.message : "获取 diff 失败");
} finally {
setDiffLoading(false);
}
}
if (loading) return <div className="text-sm text-muted-foreground"></div>;
if (error || !pipeline)
return (
<div className="text-sm text-red-500">
{error ? `加载失败:${error}` : "流水线不存在"}
</div>
);
const connName = (cid: number) => connMap[cid] ?? `#${cid}`;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">{pipeline.name}</h1>
<div className="flex gap-2">
<Button variant="outline">
<Link href={`/pipelines/${id}/edit`} className="flex items-center gap-1">
<Pencil className="size-4" />
</Link>
</Button>
<Button variant="outline" onClick={previewDiff} disabled={diffLoading}>
<GitCompareArrows className="size-4" />
{diffLoading ? "Diff 计算中…" : "Schema Diff 预览"}
</Button>
<Button onClick={runNow} disabled={runLoading}>
<Play className="size-4" />
{runLoading ? "启动中…" : "运行"}
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground"> </dt>
<dd className="font-medium">
{connName(pipeline.source_conn_id)} {" "}
{connName(pipeline.target_conn_id)}
</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-mono">{pipeline.mode}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd>{streams.length}</dd>
</div>
<div>
<dt className="text-muted-foreground">Schema </dt>
<dd>
{pipeline.schema_sync ? (
<Badge variant="outline" className="border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400">
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">
{pipeline.schema_scope === "all" ? "整个库" : "仅选中的表"}
</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{pipeline.include_fk ? "是" : "否"}</dd>
</div>
{envEntries.length > 0 ? (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-mono text-xs">
{envEntries.map(([k, v]) => `${k}=${v}`).join(" ")}
</dd>
</div>
) : null}
<div>
<dt className="text-muted-foreground"></dt>
<dd>{fmtTime(pipeline.created_at)}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd>{fmtTime(pipeline.updated_at)}</dd>
</div>
</dl>
{streams.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1">
{streams.map((s) => (
<Badge key={s.name} variant="secondary" className="font-mono">
{s.name}
</Badge>
))}
</div>
) : null}
</CardContent>
</Card>
{diffLoading || diffSql !== null ? (
<Card>
<CardHeader>
<CardTitle>Schema DiffAtlas dry-run</CardTitle>
<CardDescription>
DDL
</CardDescription>
</CardHeader>
<CardContent>
{diffLoading ? (
<div className="text-sm text-muted-foreground">
diff
</div>
) : (
<Textarea
readOnly
value={diffSql ?? ""}
className="h-64 font-mono text-xs"
/>
)}
</CardContent>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{latestRun ? (
<>
<span className="font-mono text-sm text-muted-foreground">
#{latestRun.id}
</span>
<StatusBadge status={latestRun.status} />
</>
) : null}
</CardTitle>
</CardHeader>
<CardContent>
{latestRun ? (
<LogViewer
key={latestRun.id}
runId={latestRun.id}
live
emptyText="暂无日志输出"
onDone={loadRuns}
/>
) : (
<div className="text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono">#{r.id}</TableCell>
<TableCell>
<StatusBadge status={r.status} />
</TableCell>
<TableCell>{fmtTime(r.started_at)}</TableCell>
<TableCell>{fmtTime(r.finished_at)}</TableCell>
<TableCell>{fmtDuration(r.started_at, r.finished_at)}</TableCell>
<TableCell>
<Button variant="link" size="sm">
<Link href={`/runs/${r.id}`}></Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}