406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { toast } from "sonner";
|
||
import {
|
||
api,
|
||
type Connection,
|
||
type Pipeline,
|
||
type StreamConfig,
|
||
type StreamInfo,
|
||
} from "@/components/api";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Switch } from "@/components/ui/switch";
|
||
|
||
const MODES = [
|
||
{ value: "full-refresh", label: "full-refresh(全量覆盖,默认)" },
|
||
{ value: "truncate", label: "truncate(清空后写入)" },
|
||
{ value: "incremental", label: "incremental(增量)" },
|
||
{ value: "snapshot", label: "snapshot(快照)" },
|
||
];
|
||
|
||
export function PipelineForm({ initial }: { initial?: Pipeline }) {
|
||
const router = useRouter();
|
||
const isEdit = !!initial;
|
||
const [connections, setConnections] = useState<Connection[]>([]);
|
||
const [connError, setConnError] = useState<string | null>(null);
|
||
|
||
const [name, setName] = useState(initial?.name ?? "");
|
||
const [sourceId, setSourceId] = useState(
|
||
initial ? String(initial.source_conn_id) : ""
|
||
);
|
||
const [targetId, setTargetId] = useState(
|
||
initial ? String(initial.target_conn_id) : ""
|
||
);
|
||
const [mode, setMode] = useState(initial?.mode ?? "full-refresh");
|
||
const [schemaSync, setSchemaSync] = useState(
|
||
initial ? !!initial.schema_sync : true
|
||
);
|
||
const [schemaScope, setSchemaScope] = useState(
|
||
initial?.schema_scope ?? "selected"
|
||
);
|
||
const [includeFk, setIncludeFk] = useState(initial ? !!initial.include_fk : false);
|
||
|
||
const [streamList, setStreamList] = useState<StreamInfo[] | null>(null);
|
||
const [streamsLoading, setStreamsLoading] = useState(false);
|
||
const [selected, setSelected] = useState<Record<string, StreamConfig>>(() => {
|
||
if (!initial) return {};
|
||
try {
|
||
const streams = JSON.parse(initial.streams || "[]") as StreamConfig[];
|
||
return Object.fromEntries(streams.map((s) => [s.name, s]));
|
||
} catch {
|
||
return {};
|
||
}
|
||
});
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
api<Connection[]>("/api/connections")
|
||
.then(setConnections)
|
||
.catch((e) => setConnError(e instanceof Error ? e.message : "加载失败"));
|
||
}, []);
|
||
|
||
// 编辑模式:加载现有流水线后自动拉取表列表,回填勾选状态
|
||
useEffect(() => {
|
||
if (isEdit && sourceId) void loadStreams();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isEdit, sourceId]);
|
||
|
||
const selectedList = useMemo(() => Object.values(selected), [selected]);
|
||
|
||
async function loadStreams() {
|
||
if (!sourceId) return;
|
||
setStreamsLoading(true);
|
||
try {
|
||
const list = await api<StreamInfo[]>(
|
||
`/api/connections/${sourceId}/streams`
|
||
);
|
||
setStreamList(list);
|
||
if (list.length === 0) toast.info("该连接没有可同步的表");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "加载表失败");
|
||
} finally {
|
||
setStreamsLoading(false);
|
||
}
|
||
}
|
||
|
||
function toggleStream(s: StreamInfo, checked: boolean) {
|
||
setSelected((m) => {
|
||
const next = { ...m };
|
||
if (checked) next[s.name] = m[s.name] ?? { name: s.name };
|
||
else delete next[s.name];
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function selectAll(on: boolean) {
|
||
if (!streamList) return;
|
||
setSelected(
|
||
on
|
||
? Object.fromEntries(
|
||
streamList.map((s) => [s.name, selected[s.name] ?? { name: s.name }])
|
||
)
|
||
: {}
|
||
);
|
||
}
|
||
|
||
function setStreamField(
|
||
name: string,
|
||
field: "primary_key" | "update_key",
|
||
value: string
|
||
) {
|
||
setSelected((m) => ({
|
||
...m,
|
||
[name]: { ...m[name], name, [field]: value || undefined },
|
||
}));
|
||
}
|
||
|
||
async function submit() {
|
||
if (!name.trim()) return toast.error("请填写流水线名称");
|
||
if (!sourceId || !targetId) return toast.error("请选择源连接和目标连接");
|
||
if (sourceId === targetId) return toast.error("源连接和目标连接不能相同");
|
||
if (selectedList.length === 0) return toast.error("请至少选择一张表");
|
||
|
||
setSaving(true);
|
||
const payload = {
|
||
name: name.trim(),
|
||
source_conn_id: Number(sourceId),
|
||
target_conn_id: Number(targetId),
|
||
schema_sync: schemaSync ? 1 : 0,
|
||
schema_scope: schemaScope,
|
||
include_fk: includeFk ? 1 : 0,
|
||
mode,
|
||
streams: JSON.stringify(selectedList),
|
||
};
|
||
try {
|
||
const p = isEdit
|
||
? await api<Pipeline>(`/api/pipelines/${initial.id}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
: await api<Pipeline>("/api/pipelines", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
toast.success(isEdit ? "流水线已保存" : "流水线已创建");
|
||
router.push(`/pipelines/${p.id}`);
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : isEdit ? "保存失败" : "创建失败");
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
const cancelHref = isEdit ? `/pipelines/${initial.id}` : "/pipelines";
|
||
|
||
return (
|
||
<div className="flex max-w-3xl flex-col gap-6">
|
||
<h1 className="text-xl font-semibold">
|
||
{isEdit ? `编辑流水线:${initial.name}` : "新建流水线"}
|
||
</h1>
|
||
|
||
{connError ? (
|
||
<div className="text-sm text-red-500">连接列表加载失败:{connError}</div>
|
||
) : null}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>基本信息</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4">
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="p-name">名称</Label>
|
||
<Input
|
||
id="p-name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="例如:测试库 → 本地 dev"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="grid gap-1.5">
|
||
<Label>源连接</Label>
|
||
<Select
|
||
value={sourceId}
|
||
onValueChange={(v) => {
|
||
setSourceId(String(v));
|
||
setStreamList(null);
|
||
setSelected({});
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue placeholder="选择源连接" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{connections.map((c) => (
|
||
<SelectItem key={c.id} value={String(c.id)}>
|
||
{c.name}({c.database})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="grid gap-1.5">
|
||
<Label>目标连接</Label>
|
||
<Select value={targetId} onValueChange={(v) => setTargetId(String(v))}>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue placeholder="选择目标连接" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{connections.map((c) => (
|
||
<SelectItem key={c.id} value={String(c.id)}>
|
||
{c.name}({c.database})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-1.5">
|
||
<Label>同步模式</Label>
|
||
<Select value={mode} onValueChange={(v) => setMode(String(v))}>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{MODES.map((m) => (
|
||
<SelectItem key={m.value} value={m.value}>
|
||
{m.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-md border p-3">
|
||
<div className="flex flex-col gap-0.5">
|
||
<Label>Schema 同步</Label>
|
||
<span className="text-xs text-muted-foreground">
|
||
先用 Atlas 同步 schema(表/索引/约束),再迁移数据
|
||
</span>
|
||
</div>
|
||
<Switch checked={schemaSync} onCheckedChange={(c) => setSchemaSync(!!c)} />
|
||
</div>
|
||
<div
|
||
className={`flex items-center justify-between rounded-md border p-3 ${
|
||
schemaSync ? "" : "opacity-50"
|
||
}`}
|
||
>
|
||
<div className="flex flex-col gap-0.5">
|
||
<Label>Schema 同步范围</Label>
|
||
<span className="text-xs text-muted-foreground">
|
||
整库模式会同步源库所有表,且目标库独有的表可能被删除
|
||
</span>
|
||
</div>
|
||
<Select
|
||
value={schemaScope}
|
||
onValueChange={(v) => setSchemaScope(String(v))}
|
||
disabled={!schemaSync}
|
||
>
|
||
<SelectTrigger className="w-36">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="selected">仅选中的表</SelectItem>
|
||
<SelectItem value="all">整个库</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div
|
||
className={`flex items-center justify-between rounded-md border p-3 ${
|
||
schemaSync ? "" : "opacity-50"
|
||
}`}
|
||
>
|
||
<div className="flex flex-col gap-0.5">
|
||
<Label>包含外键约束</Label>
|
||
<span className="text-xs text-muted-foreground">
|
||
关闭可避免选中表引用了未迁移表导致的失败;排除父表时引用它的外键会自动跳过
|
||
</span>
|
||
</div>
|
||
<Switch
|
||
checked={includeFk}
|
||
onCheckedChange={(c) => setIncludeFk(!!c)}
|
||
disabled={!schemaSync}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>选择要同步的表</CardTitle>
|
||
<CardDescription>
|
||
选择源连接后点击「加载表」,勾选需要同步的表。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={loadStreams}
|
||
disabled={!sourceId || streamsLoading}
|
||
>
|
||
{streamsLoading ? "加载中…" : "加载表"}
|
||
</Button>
|
||
{streamList && streamList.length > 0 ? (
|
||
<>
|
||
<Button variant="ghost" size="sm" onClick={() => selectAll(true)}>
|
||
全选
|
||
</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => selectAll(false)}>
|
||
全不选
|
||
</Button>
|
||
<span className="text-xs text-muted-foreground">
|
||
已选 {selectedList.length} / {streamList.length}
|
||
</span>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
{streamList === null ? (
|
||
selectedList.length > 0 ? (
|
||
<div className="text-sm text-muted-foreground">
|
||
当前已选 {selectedList.length} 张表(
|
||
{selectedList.map((s) => s.name).join("、")}),点击「加载表」可修改。
|
||
</div>
|
||
) : null
|
||
) : streamList.length === 0 ? (
|
||
<div className="text-sm text-muted-foreground">没有可同步的表。</div>
|
||
) : (
|
||
<div className="max-h-96 overflow-auto rounded-md border">
|
||
{streamList.map((s) => {
|
||
const checked = !!selected[s.name];
|
||
return (
|
||
<div
|
||
key={s.name}
|
||
className="flex items-center gap-3 border-b px-3 py-2 last:border-b-0"
|
||
>
|
||
<Checkbox
|
||
checked={checked}
|
||
onCheckedChange={(c) => toggleStream(s, !!c)}
|
||
/>
|
||
<span className="flex-1 font-mono text-sm">{s.name}</span>
|
||
{mode === "incremental" && checked ? (
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
className="h-7 w-36 font-mono text-xs"
|
||
placeholder="primary_key"
|
||
value={selected[s.name].primary_key ?? ""}
|
||
onChange={(e) =>
|
||
setStreamField(s.name, "primary_key", e.target.value)
|
||
}
|
||
/>
|
||
<Input
|
||
className="h-7 w-36 font-mono text-xs"
|
||
placeholder="update_key"
|
||
value={selected[s.name].update_key ?? ""}
|
||
onChange={(e) =>
|
||
setStreamField(s.name, "update_key", e.target.value)
|
||
}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex gap-2">
|
||
<Button onClick={submit} disabled={saving}>
|
||
{saving
|
||
? isEdit
|
||
? "保存中…"
|
||
: "创建中…"
|
||
: isEdit
|
||
? "保存修改"
|
||
: "创建流水线"}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => router.push(cancelHref)}>
|
||
取消
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|