Files
deerflow-code/frontend-web/src/pages/CheckpointMigrationPage.tsx
T

258 lines
9.0 KiB
TypeScript

"use client";
import { DatabaseZap, Loader2Icon, RefreshCwIcon } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
interface CheckpointMigrationSources {
source_paths: string[];
}
interface CheckpointMigrationTask {
task_id: string;
status: "queued" | "running" | "completed" | "failed";
start_at: string;
end_at: string;
source_paths: string[];
total_threads: number;
copied_threads: number;
skipped_threads: number;
failed_threads: number;
skipped_ranges: number;
message: string;
error?: string | null;
created_at: string;
updated_at: string;
}
async function loadSources(): Promise<CheckpointMigrationSources> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/sources`,
);
if (!response.ok) throw new Error("加载 SQLite 来源失败");
return (await response.json()) as CheckpointMigrationSources;
}
async function startMigration(body: {
start_at: string;
end_at: string;
}): Promise<CheckpointMigrationTask> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: "启动迁移失败" }));
throw new Error(detail.detail ?? "启动迁移失败");
}
return (await response.json()) as CheckpointMigrationTask;
}
async function loadTask(taskId: string): Promise<CheckpointMigrationTask> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks/${taskId}`,
);
if (!response.ok) throw new Error("加载迁移任务失败");
return (await response.json()) as CheckpointMigrationTask;
}
function toLocalInputValue(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function statusVariant(status?: string) {
if (status === "failed") return "destructive" as const;
if (status === "completed") return "default" as const;
if (status === "queued" || status === "running") return "secondary" as const;
return "outline" as const;
}
export default function CheckpointMigrationPage() {
const now = useMemo(() => new Date(), []);
const weekAgo = useMemo(
() => new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
[now],
);
const [startAt, setStartAt] = useState(toLocalInputValue(weekAgo));
const [endAt, setEndAt] = useState(toLocalInputValue(now));
const [taskId, setTaskId] = useState<string | null>(null);
const { data: sources, isLoading: sourcesLoading, refetch } = useQuery({
queryKey: ["checkpointMigration", "sources"],
queryFn: loadSources,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const startMutation = useMutation({ mutationFn: startMigration });
const { data: task } = useQuery({
queryKey: ["checkpointMigration", "task", taskId],
queryFn: () => loadTask(taskId as string),
enabled: !!taskId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "queued" || status === "running" ? 2000 : false;
},
refetchOnWindowFocus: false,
});
const running =
startMutation.isPending ||
task?.status === "queued" ||
task?.status === "running";
async function handleStart() {
const start = new Date(startAt);
const end = new Date(endAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
toast.error("请选择有效的开始和结束时间");
return;
}
if (end <= start) {
toast.error("结束时间必须晚于开始时间");
return;
}
try {
const created = await startMutation.mutateAsync({
start_at: start.toISOString(),
end_at: end.toISOString(),
});
setTaskId(created.task_id);
toast.success("迁移任务已启动");
} catch (err) {
toast.error(err instanceof Error ? err.message : "启动迁移失败");
}
}
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
<div>
<h1 className="text-xl font-semibold">Checkpoint 迁移</h1>
<p className="text-muted-foreground mt-1 text-sm">
按时间段把 SQLite 旧聊天 checkpoint 同步到 Postgres。同一来源、同一时间段完成后再次执行会跳过。
</p>
</div>
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<DatabaseZap className="size-4" />
SQLite 来源
</div>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={sourcesLoading}
title="刷新"
>
<RefreshCwIcon className="size-4" />
</Button>
</div>
{sourcesLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
加载中
</div>
) : (sources?.source_paths.length ?? 0) > 0 ? (
<div className="space-y-2">
{sources?.source_paths.map((path) => (
<div
key={path}
className="rounded border bg-muted/40 px-3 py-2 font-mono text-xs break-all"
>
{path}
</div>
))}
</div>
) : (
<div className="text-muted-foreground rounded border border-dashed px-3 py-6 text-center text-sm">
未配置 SQLite 来源路径
</div>
)}
</section>
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">开始时间</span>
<Input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">结束时间</span>
<Input
type="datetime-local"
value={endAt}
onChange={(e) => setEndAt(e.target.value)}
/>
</label>
</div>
<div className="mt-5 flex justify-end">
<Button
onClick={handleStart}
disabled={running || !(sources?.source_paths.length ?? 0)}
>
{running && <Loader2Icon className="size-4 animate-spin" />}
开始同步
</Button>
</div>
</section>
{task && (
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm font-medium">任务状态</div>
<Badge variant={statusVariant(task.status)}>{task.status}</Badge>
</div>
<div className="grid gap-3 text-sm sm:grid-cols-4">
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">总线程</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.total_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">已复制</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.copied_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">已跳过</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.skipped_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">跳过时间段</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.skipped_ranges}
</div>
</div>
</div>
{(task.message || task.error) && (
<div className="mt-4 rounded border bg-muted/40 px-3 py-2 text-sm">
{task.error || task.message}
</div>
)}
</section>
)}
</div>
);
}