sing-ui/components/pipeline-form.tsx

441 lines
16 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 { 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 { keyValueLinesFromJson, parseKeyValueLines } from "@/components/kv";
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";
import { Textarea } from "@/components/ui/textarea";
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 [envText, setEnvText] = useState(() =>
initial ? keyValueLinesFromJson(initial.env) : ""
);
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),
env: parseKeyValueLines(envText),
};
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="选择源连接">
{(v) => {
const c = connections.find((x) => String(x.id) === String(v));
return c ? `${c.name}${c.database}` : String(v ?? "");
}}
</SelectValue>
</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="选择目标连接">
{(v) => {
const c = connections.find((x) => String(x.id) === String(v));
return c ? `${c.name}${c.database}` : String(v ?? "");
}}
</SelectValue>
</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>
{(v) => MODES.find((m) => m.value === v)?.label ?? String(v ?? "")}
</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>
{(v) => (v === "all" ? "整个库" : "仅选中的表")}
</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>
<div className="grid gap-1.5">
<Label htmlFor="p-env"></Label>
<Textarea
id="p-env"
className="font-mono text-xs"
rows={3}
value={envText}
onChange={(e) => setEnvText(e.target.value)}
placeholder={"SLING_THREADS=4"}
/>
<p className="text-xs text-muted-foreground">
key=value sling replication envsling
SLING_THREADS=4
</p>
</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>
);
}