258 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|