- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、 packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件 - 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源) - 构建产物(bin/target/dist)与签名私钥仍按规则排除
358 lines
16 KiB
TypeScript
358 lines
16 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { closeSession, openSession } from "./api";
|
||
import { driverById } from "./catalog";
|
||
import { askInput } from "./dialogs";
|
||
import { DriverIcon } from "./DriverIcon";
|
||
import { ObjectTree } from "./ObjectTree";
|
||
import { ContextMenu } from "./ContextMenu";
|
||
import { TreeChevron } from "./TreeChevron";
|
||
import { connectionEndpoint, connectionUri } from "./contextActions";
|
||
import {
|
||
DEFAULT_CONN_GROUP,
|
||
useWorkspaceStore,
|
||
type SavedConnection,
|
||
} from "./store";
|
||
|
||
function cx(...xs: Array<string | false | null | undefined>) {
|
||
return xs.filter(Boolean).join(" ");
|
||
}
|
||
|
||
type DropHint = { groupId: string; index: number } | null;
|
||
|
||
export function ConnectionSidebar({
|
||
onSelect,
|
||
onDelete,
|
||
}: {
|
||
onSelect: (id: string) => void;
|
||
onDelete: (id: string) => void;
|
||
}) {
|
||
const connections = useWorkspaceStore((s) => s.connections);
|
||
const groups = useWorkspaceStore((s) => s.connGroups);
|
||
const expanded = useWorkspaceStore((s) => s.expandedConnGroups);
|
||
const activeId = useWorkspaceStore((s) => s.activeConnectionId);
|
||
const addConnGroup = useWorkspaceStore((s) => s.addConnGroup);
|
||
const renameConnGroup = useWorkspaceStore((s) => s.renameConnGroup);
|
||
const removeConnGroup = useWorkspaceStore((s) => s.removeConnGroup);
|
||
const moveConnection = useWorkspaceStore((s) => s.moveConnection);
|
||
const toggleConnGroup = useWorkspaceStore((s) => s.toggleConnGroup);
|
||
const notify = useWorkspaceStore((s) => s.notify);
|
||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||
const editConnection = useWorkspaceStore((s) => s.editConnection);
|
||
const connState = useWorkspaceStore((s) => s.connState);
|
||
const set = useWorkspaceStore((s) => s.set);
|
||
|
||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; conn: SavedConnection } | null>(null);
|
||
/** 断开连接:关会话 + 清树 + 清激活 */
|
||
const disconnect = (c: SavedConnection) => {
|
||
const st = useWorkspaceStore.getState();
|
||
const sid = st.sessions[c.id];
|
||
if (sid) {
|
||
void closeSession(sid).catch(() => {});
|
||
}
|
||
st.setSession(c.id, null);
|
||
if (st.activeConnectionId === c.id) {
|
||
st.set("activeConnectionId", null);
|
||
st.set("tree", []);
|
||
st.set("connState", "down");
|
||
st.set("status", `已断开 ${c.name}`);
|
||
}
|
||
notify({ kind: "warning", title: "已断开", message: c.name, mode: "toast", durationMs: 1800 });
|
||
};
|
||
|
||
const reconnect = async (c: SavedConnection) => {
|
||
set("connState", "reconnecting");
|
||
set("status", "重连中…");
|
||
try {
|
||
useWorkspaceStore.getState().setSession(c.id, null);
|
||
const { sessionId } = await openSession(c);
|
||
useWorkspaceStore.getState().setSession(c.id, sessionId);
|
||
useWorkspaceStore.getState().selectConnection(c.id);
|
||
set("connState", "ok");
|
||
set("status", `已重连 ${c.name}` + (c.database ? ` · ${c.database}` : ""));
|
||
notify({ kind: "success", title: "重连成功", message: c.name, mode: "toast", durationMs: 1600 });
|
||
} catch (e) {
|
||
set("connState", "down");
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
set("status", `重连失败:${msg}`);
|
||
notifyError(e, "modal");
|
||
}
|
||
};
|
||
|
||
const [dragId, setDragId] = useState<string | null>(null);
|
||
const [dropHint, setDropHint] = useState<DropHint>(null);
|
||
const dragIdRef = useRef<string | null>(null);
|
||
|
||
const byGroup = useMemo(() => {
|
||
const map = new Map<string, SavedConnection[]>();
|
||
for (const g of groups) map.set(g.id, []);
|
||
for (const c of connections) {
|
||
const gid = c.groupId || DEFAULT_CONN_GROUP;
|
||
if (!map.has(gid)) map.set(gid, []);
|
||
map.get(gid)!.push(c);
|
||
}
|
||
for (const list of map.values()) {
|
||
list.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||
}
|
||
return map;
|
||
}, [connections, groups]);
|
||
|
||
const onAddGroup = () => {
|
||
void (async () => {
|
||
// C6:askInput 替代 window.prompt(WKWebView 下 prompt 返回 null)
|
||
const name = await askInput({ title: "分组名称", defaultValue: "新分组" });
|
||
if (name == null) return;
|
||
const id = addConnGroup(name);
|
||
notify({ kind: "info", title: "分组", message: `已创建「${name.trim() || "新分组"}」`, mode: "toast" });
|
||
void id;
|
||
})();
|
||
};
|
||
|
||
const onRenameGroup = (id: string, cur: string) => {
|
||
if (id === DEFAULT_CONN_GROUP) return;
|
||
void (async () => {
|
||
const name = await askInput({ title: "重命名分组", defaultValue: cur });
|
||
if (name == null || !name.trim()) return;
|
||
renameConnGroup(id, name);
|
||
})();
|
||
};
|
||
|
||
const onRemoveGroup = (id: string, name: string) => {
|
||
if (id === DEFAULT_CONN_GROUP) return;
|
||
void (async () => {
|
||
const ok = await useWorkspaceStore.getState().askConfirm({
|
||
title: "删除分组",
|
||
message: `删除分组「${name}」?连接将移回默认。`,
|
||
okLabel: "删除",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
removeConnGroup(id);
|
||
})();
|
||
};
|
||
|
||
const setDrop = (groupId: string, index: number) => {
|
||
const id = dragIdRef.current;
|
||
if (!id) return;
|
||
setDropHint({ groupId, index });
|
||
};
|
||
|
||
const commitDrop = () => {
|
||
const id = dragIdRef.current;
|
||
const hint = dropHint;
|
||
dragIdRef.current = null;
|
||
setDragId(null);
|
||
setDropHint(null);
|
||
if (!id || !hint) return;
|
||
moveConnection(id, hint.groupId, hint.index);
|
||
};
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
<div className="flex items-center justify-between px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||
<span>连接</span>
|
||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
|
||
+ 分组
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-auto p-1">
|
||
{connections.length === 0 && (
|
||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||
暂无连接
|
||
</div>
|
||
)}
|
||
{groups.map((g) => {
|
||
const list = byGroup.get(g.id) ?? [];
|
||
const open = expanded.includes(g.id);
|
||
return (
|
||
<div
|
||
key={g.id}
|
||
className="mb-0.5"
|
||
data-group-id={g.id}
|
||
onDragOver={(e) => {
|
||
if (!dragIdRef.current) return;
|
||
e.preventDefault();
|
||
setDrop(g.id, list.length);
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
commitDrop();
|
||
}}
|
||
>
|
||
<div className="ops-tree-item group/g cursor-pointer" onClick={() => toggleConnGroup(g.id)}>
|
||
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-50">{open ? "▾" : "▸"}</span>
|
||
<span
|
||
className="mr-1 inline-block h-3 w-3 shrink-0 rounded-[2px] border"
|
||
style={{ borderColor: "var(--ops-muted)", background: "color-mix(in srgb, var(--ops-muted) 25%, transparent)" }}
|
||
aria-hidden
|
||
/>
|
||
<span className="min-w-0 flex-1 truncate font-medium">{g.name}</span>
|
||
<span className="text-[10px] opacity-40">{list.length}</span>
|
||
{g.id !== DEFAULT_CONN_GROUP && (
|
||
<span className="ml-1 hidden gap-0.5 group-hover/g:inline-flex" onClick={(e) => e.stopPropagation()}>
|
||
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" title="重命名" onClick={() => onRenameGroup(g.id, g.name)}>
|
||
✎
|
||
</button>
|
||
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" style={{ color: "var(--ops-danger)" }} title="删除分组" onClick={() => onRemoveGroup(g.id, g.name)}>
|
||
×
|
||
</button>
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{open && (
|
||
<div className="ml-1 border-l pl-0.5" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
{list.length === 0 && (
|
||
<div
|
||
className={cx("ops-drop-slot mx-0.5 my-0.5 rounded px-1.5 py-1 text-[10px]", dropHint?.groupId === g.id ? "ops-drop-slot-active" : "")}
|
||
style={{ color: "var(--ops-muted)" }}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setDrop(g.id, 0);
|
||
}}
|
||
>
|
||
拖到此处
|
||
</div>
|
||
)}
|
||
{list.map((c, i) => (
|
||
<div key={c.id}>
|
||
{dropHint?.groupId === g.id && dropHint.index === i && <div className="ops-drop-line" />}
|
||
<div
|
||
className={cx(
|
||
"group flex items-center gap-0.5 rounded",
|
||
activeId === c.id ? "ops-tree-item-active" : "",
|
||
dragId === c.id ? "opacity-45" : "",
|
||
)}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
dragIdRef.current = c.id;
|
||
setDragId(c.id);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", c.id);
|
||
}}
|
||
onDragEnd={() => {
|
||
dragIdRef.current = null;
|
||
setDragId(null);
|
||
setDropHint(null);
|
||
}}
|
||
onDragOver={(e) => {
|
||
if (!dragIdRef.current || dragIdRef.current === c.id) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||
const before = e.clientY < rect.top + rect.height / 2;
|
||
setDrop(g.id, before ? i : i + 1);
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
commitDrop();
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="ops-tree-item min-w-0 flex-1"
|
||
onClick={() => onSelect(c.id)}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setCtxMenu({ x: e.clientX, y: e.clientY, conn: c });
|
||
}}
|
||
>
|
||
{/* 实例级展开/收起箭头(行首树形 chevron,SVG 保证方向清晰) */}
|
||
<span
|
||
className="grid w-3.5 shrink-0 cursor-pointer place-items-center opacity-60 hover:opacity-100"
|
||
title={activeId === c.id ? "点击收起对象树" : "点击展开对象树"}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (activeId === c.id) {
|
||
// 已激活 → 收起(清空树)
|
||
useWorkspaceStore.getState().set("tree", []);
|
||
useWorkspaceStore.getState().set("activeConnectionId", null);
|
||
} else {
|
||
// 未激活 → 展开(激活并加载树)
|
||
onSelect(c.id);
|
||
}
|
||
}}
|
||
>
|
||
<TreeChevron open={activeId === c.id} size={10} />
|
||
</span>
|
||
{c.color && (
|
||
<span className="h-3 w-1 shrink-0 rounded-sm" style={{ background: c.color }} />
|
||
)}
|
||
<DriverIcon driver={c.driver} size={14} />
|
||
<span className="min-w-0 flex-1 truncate" title={`${driverById(c.driver)?.name ?? c.driver} · ${c.name} · ${connectionEndpoint(c)}`}>
|
||
<span className="truncate">{c.name}</span>
|
||
{(c.host || c.redisNodes || c.database) && (
|
||
<span className="ml-1 text-[10px] opacity-45">
|
||
{c.host || c.redisNodes ? connectionEndpoint(c) : c.database}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span
|
||
className="ml-1 h-1.5 w-1.5 shrink-0 rounded-full"
|
||
style={{
|
||
background:
|
||
activeId === c.id
|
||
? connState === "down"
|
||
? "var(--ops-danger)"
|
||
: connState === "reconnecting"
|
||
? "var(--ops-warning)"
|
||
: "#22c55e"
|
||
: "var(--ops-muted)",
|
||
opacity: activeId === c.id ? 1 : 0.4,
|
||
}}
|
||
title={
|
||
activeId === c.id
|
||
? connState === "down"
|
||
? "已断开"
|
||
: connState === "reconnecting"
|
||
? "重连中…"
|
||
: "已连接"
|
||
: "未激活"
|
||
}
|
||
/>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="hidden px-0.5 text-[10px] opacity-50 group-hover:inline hover:opacity-100"
|
||
title="编辑"
|
||
onClick={() => editConnection(c)}
|
||
>
|
||
✎
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="mr-0.5 hidden px-1 text-[11px] opacity-50 group-hover:inline hover:opacity-100"
|
||
style={{ color: "var(--ops-danger)" }}
|
||
onClick={() => onDelete(c.id)}
|
||
title="删除连接"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
{activeId === c.id && <ObjectTree />}
|
||
{dropHint?.groupId === g.id && dropHint.index === list.length && i === list.length - 1 && (
|
||
<div className="ops-drop-line" />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{ctxMenu && <ContextMenu x={ctxMenu.x} y={ctxMenu.y} title={ctxMenu.conn.name} onClose={() => setCtxMenu(null)} items={[
|
||
{ label: "连接", hidden: activeId === ctxMenu.conn.id, action: () => onSelect(ctxMenu.conn.id) },
|
||
{ label: "断开连接", hidden: activeId !== ctxMenu.conn.id, action: () => disconnect(ctxMenu.conn) },
|
||
{ label: "重新连接", action: () => void reconnect(ctxMenu.conn) },
|
||
{ label: "编辑连接…", action: () => editConnection(ctxMenu.conn) },
|
||
{ label: "复制连接地址(无凭据)", separator: true, action: () => { void navigator.clipboard.writeText(connectionUri(ctxMenu.conn)).catch((e) => notifyError(e, "toast")); } },
|
||
{ label: "复制主机端口", action: () => { void navigator.clipboard.writeText(connectionEndpoint(ctxMenu.conn)).catch((e) => notifyError(e, "toast")); } },
|
||
{ label: "删除连接…", separator: true, danger: true, action: () => onDelete(ctxMenu.conn.id) },
|
||
]} />}
|
||
</div>
|
||
);
|
||
}
|