sing-ui/app/connections/page.tsx

217 lines
7.7 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 { 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>
);
}