217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
"use client";
|
||
|
||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { Plus, Trash2, Pencil, PlugZap } from "lucide-react";
|
||
import { api, type Connection } from "@/components/api";
|
||
import { fmtTime } from "@/components/format";
|
||
import { ConnectionDialog } from "@/components/connection-dialog";
|
||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
|
||
interface TestResult {
|
||
ok: boolean;
|
||
output: string;
|
||
}
|
||
|
||
export default function ConnectionsPage() {
|
||
const [list, setList] = useState<Connection[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [editing, setEditing] = useState<Connection | null>(null);
|
||
const [deleting, setDeleting] = useState<Connection | null>(null);
|
||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||
|
||
const [testingId, setTestingId] = useState<number | null>(null);
|
||
const [testResults, setTestResults] = useState<Record<number, TestResult>>({});
|
||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||
|
||
const load = useCallback(() => {
|
||
api<Connection[]>("/api/connections")
|
||
.then(setList)
|
||
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
useEffect(load, [load]);
|
||
|
||
async function testConn(c: Connection) {
|
||
setTestingId(c.id);
|
||
try {
|
||
const res = await api<TestResult>(`/api/connections/${c.id}/test`, {
|
||
method: "POST",
|
||
});
|
||
setTestResults((m) => ({ ...m, [c.id]: res }));
|
||
setExpandedId(c.id);
|
||
if (res.ok) toast.success(`连接「${c.name}」测试成功`);
|
||
else toast.error(`连接「${c.name}」测试失败`);
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "测试失败");
|
||
} finally {
|
||
setTestingId(null);
|
||
}
|
||
}
|
||
|
||
async function doDelete() {
|
||
if (!deleting) return;
|
||
setDeleteLoading(true);
|
||
try {
|
||
await api(`/api/connections/${deleting.id}`, { method: "DELETE" });
|
||
toast.success(`已删除连接「${deleting.name}」`);
|
||
setDeleting(null);
|
||
load();
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||
} finally {
|
||
setDeleteLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-xl font-semibold">Connections</h1>
|
||
<Button
|
||
onClick={() => {
|
||
setEditing(null);
|
||
setDialogOpen(true);
|
||
}}
|
||
>
|
||
<Plus className="size-4" />
|
||
新建连接
|
||
</Button>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="text-sm text-muted-foreground">加载中…</div>
|
||
) : error ? (
|
||
<div className="text-sm text-red-500">加载失败:{error}</div>
|
||
) : list.length === 0 ? (
|
||
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||
还没有连接,点击右上角「新建连接」添加 MySQL / MariaDB / TiDB 数据源。
|
||
</div>
|
||
) : (
|
||
<div className="rounded-md border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>名称</TableHead>
|
||
<TableHead>类型</TableHead>
|
||
<TableHead>Host</TableHead>
|
||
<TableHead>数据库</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead className="w-56">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{list.map((c) => (
|
||
<Fragment key={c.id}>
|
||
<TableRow>
|
||
<TableCell className="font-medium">
|
||
{c.name}
|
||
{c.params && c.params !== "{}" ? (
|
||
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||
{Object.entries(
|
||
JSON.parse(c.params) as Record<string, string>
|
||
)
|
||
.map(([k, v]) => `${k}=${v}`)
|
||
.join(" ")}
|
||
</div>
|
||
) : null}
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs">{c.type}</TableCell>
|
||
<TableCell className="font-mono text-xs">
|
||
{c.host}:{c.port}
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs">{c.database}</TableCell>
|
||
<TableCell className="text-xs text-muted-foreground">
|
||
{fmtTime(c.created_at)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={testingId === c.id}
|
||
onClick={() => testConn(c)}
|
||
>
|
||
<PlugZap className="size-3.5" />
|
||
{testingId === c.id ? "测试中…" : "测试"}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setEditing(c);
|
||
setDialogOpen(true);
|
||
}}
|
||
>
|
||
<Pencil className="size-3.5" />
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setDeleting(c)}
|
||
>
|
||
<Trash2 className="size-3.5" />
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
{expandedId === c.id && testResults[c.id] ? (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="bg-muted/40">
|
||
<pre
|
||
className={
|
||
"max-h-48 overflow-auto whitespace-pre-wrap break-all rounded p-2 font-mono text-xs " +
|
||
(testResults[c.id].ok
|
||
? "text-green-600 dark:text-green-400"
|
||
: "text-red-600 dark:text-red-400")
|
||
}
|
||
>
|
||
{testResults[c.id].output ||
|
||
(testResults[c.id].ok ? "连接成功" : "连接失败")}
|
||
</pre>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : null}
|
||
</Fragment>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
|
||
{dialogOpen ? (
|
||
<ConnectionDialog
|
||
key={editing?.id ?? "new"}
|
||
open={dialogOpen}
|
||
onOpenChange={setDialogOpen}
|
||
initial={editing}
|
||
onSaved={load}
|
||
/>
|
||
) : null}
|
||
<ConfirmDialog
|
||
open={!!deleting}
|
||
onOpenChange={(o) => !o && setDeleting(null)}
|
||
title={`删除连接「${deleting?.name ?? ""}」?`}
|
||
description="删除后引用该连接的流水线将无法运行。"
|
||
loading={deleteLoading}
|
||
onConfirm={doDelete}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|