sing-ui/app/pipelines/new/page.tsx

315 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 { 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 default function NewPipelinePage() {
const router = useRouter();
const [connections, setConnections] = useState<Connection[]>([]);
const [connError, setConnError] = useState<string | null>(null);
const [name, setName] = useState("");
const [sourceId, setSourceId] = useState("");
const [targetId, setTargetId] = useState("");
const [mode, setMode] = useState("full-refresh");
const [schemaSync, setSchemaSync] = useState(true);
const [streamList, setStreamList] = useState<StreamInfo[] | null>(null);
const [streamsLoading, setStreamsLoading] = useState(false);
const [selected, setSelected] = useState<Record<string, StreamConfig>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
api<Connection[]>("/api/connections")
.then(setConnections)
.catch((e) => setConnError(e instanceof Error ? e.message : "加载失败"));
}, []);
// 切换源连接时同时清空已加载的表(在 onValueChange 里处理)
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] = { 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);
try {
const p = await api<Pipeline>("/api/pipelines", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
source_conn_id: Number(sourceId),
target_conn_id: Number(targetId),
schema_sync: schemaSync ? 1 : 0,
mode,
streams: JSON.stringify(selectedList),
}),
});
toast.success("流水线已创建");
router.push(`/pipelines/${p.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "创建失败");
setSaving(false);
}
}
return (
<div className="flex max-w-3xl flex-col gap-6">
<h1 className="text-xl font-semibold">线</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>
</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 ? 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 ? "创建中…" : "创建流水线"}
</Button>
<Button variant="outline" onClick={() => router.push("/pipelines")}>
</Button>
</div>
</div>
);
}