Files
SenMeshNet/components/vault/VaultViewLayout.tsx
zhaolei 4417f62d7e
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
feat: register plan management in vault nav + remote HTTP routes
- VaultViewLayout: lazy import + Wrench icon + sidebar menu + planManagement section render

- remoteHttpServer: loadModules 加载 4 新 store + resetAllCaches 重置 + 6 条预案 GET 路由 (templates/plans/plan/:id/command-catalog/aggregation-rules)
2026-10-30 17:00:38 +08:00

2197 lines
80 KiB
TypeScript
Raw Permalink 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.
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback, lazy, Suspense, useState, useEffect } from "react";
import { AlertTriangle, Bell, Activity, Network, FileText, BarChart3, Wrench } from "lucide-react";
import { WorkbenchView } from "./WorkbenchView";
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
import { usePluginImporterCommit } from "../../application/state/usePluginImporterCommit";
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
import {
collectVaultGroupPathsForSelectAll,
collectVisibleVaultGroupPaths,
collectVisibleVaultHostIds,
retainVisibleVaultGroupSelection,
} from "../../domain/vaultGroupSelection";
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH, STORAGE_KEY_PRODUCT_NAME } from "@/infrastructure/config/storageKeys.ts";
import { localStorageAdapter } from "@/infrastructure/persistence/localStorageAdapter.ts";
import { VaultHostListSection } from "./VaultHostListSection";
import { VaultImportProgressPanel } from "./ImportVaultDialog";
import {
VaultHeaderSearch,
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from "./VaultPageHeader";
import { useConnectionLogsStore } from "../../application/state/connectionLogsStore";
import { useNotesStore } from "../../application/state/notesStore";
import { useSettingsState } from "../../application/state/useSettingsState";
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
import { toast } from "../ui/toast";
import { AppWordmark } from "../AppWordmark";
import { InspectionErrorBoundary } from "../inspection/InspectionErrorBoundary";
// 巡检模块页面(自包含,通过 IPC 直接调用主进程)
const AlertCenterPage = lazy(() => import("../inspection/AlertCenterPage"));
const AlertRulesPage = lazy(() => import("../inspection/AlertRulesPage"));
const InspectionCenterPage = lazy(() => import("../inspection/InspectionCenterPage"));
// 预案管理(v1.3:双轨预案 + AI 生成)
const PlanManagementPage = lazy(() => import("../inspection/PlanManagementPage").then((m) => ({ default: m.PlanManagementPage })));
// 运行监控 - 3D 拓扑大屏
const MonitoringTopologyView = lazy(() => import("../monitoring/MonitoringTopologyView"));
// 巡检报告
const ReportPage = lazy(() => import("../inspection/ReportPage"));
// 设置页面(远程访问降级用)
const SettingsPage = lazy(() => import("../SettingsPage"));
type VaultViewLayoutContext = Record<string, any>;
const VaultSectionLoading = () => (
<div
className="netcatty-lazy-fade-in min-h-[320px] flex-1"
aria-hidden="true"
/>
);
/**
* Notes section subscribes to notesStore instead of taking notes as VaultView
* props, so note edits never invalidate the App vault domain bag.
*
* While inactive (retained but hidden), freeze hosts and drop the store
* subscription so Hosts/Keys churn and note publishes do not reconcile the
* heavy NotesManager + MDXEditor tree.
*/
function VaultNotesSection({
NotesManager,
hosts,
isActive,
openNoteId,
onOpenNoteIdHandled,
onOpenHost,
}: {
NotesManager: React.ComponentType<any>;
hosts: any[];
isActive: boolean;
openNoteId: string | null;
onOpenNoteIdHandled: () => void;
onOpenHost: (host: any, source?: { noteId?: string }) => void;
}) {
const { notes, noteGroups, updateNotes, updateNoteGroups } = useNotesStore({
enabled: isActive,
});
return (
<NotesManager
notes={notes}
noteGroups={noteGroups}
hosts={hosts}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
isActive={isActive}
openNoteId={openNoteId}
onOpenNoteIdHandled={onOpenNoteIdHandled}
onOpenHost={onOpenHost}
/>
);
}
const MemoVaultNotesSection = React.memo(
VaultNotesSection,
(prev, next) => {
if (
prev.isActive !== next.isActive
|| prev.openNoteId !== next.openNoteId
|| prev.NotesManager !== next.NotesManager
|| prev.onOpenNoteIdHandled !== next.onOpenNoteIdHandled
|| prev.onOpenHost !== next.onOpenHost
) {
return false;
}
if (next.isActive && prev.hosts !== next.hosts) return false;
return true;
},
);
/**
* Logs section subscribes to connectionLogsStore so every session start/exit
* append stays out of the App vault/chrome domain bags.
*/
function VaultConnectionLogsSection({
ConnectionLogsManager,
hosts,
onOpenLogView,
initialLogId,
}: {
ConnectionLogsManager: React.ComponentType<any>;
hosts: any[];
onOpenLogView: (log: any) => void;
initialLogId?: string;
}) {
const {
connectionLogs,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
} = useConnectionLogsStore();
// 从工作台跳转时自动打开指定 log 的详情
React.useEffect(() => {
if (!initialLogId) return;
const log = connectionLogs.find((l: any) => l.id === initialLogId);
if (log) onOpenLogView(log);
}, [initialLogId, connectionLogs, onOpenLogView]);
return (
<ConnectionLogsManager
logs={connectionLogs}
hosts={hosts}
onToggleSaved={toggleConnectionLogSaved}
onDelete={deleteConnectionLog}
onClearUnsaved={clearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
);
}
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
// ─── 设置权威源同步(远程浏览器) ───
// 1. HTML 注入脚本里的同步 XHR 已在 React 启动前把权威设置填到 localStorage
// 2. 这里注册写后钩子:每次 localStorageAdapter 写都 POST 回权威源
React.useEffect(() => {
if (!IS_REMOTE) return;
const TOKEN = (window as any).__REMOTE_TOKEN__;
const API_BASE = `${window.location.origin}`;
(globalThis as any).__NETCATTY_SETTINGS_SYNC__ = (key: string, value: string | null) => {
fetch(`${API_BASE}/api/settings?token=${encodeURIComponent(TOKEN)}`, {
method: value === null ? "DELETE" : "POST",
headers: { "Content-Type": "application/json" },
body: value === null ? JSON.stringify({ keys: [key] }) : JSON.stringify({ [key]: value }),
}).catch(() => {});
};
return () => {
if ((globalThis as any).__NETCATTY_SETTINGS_SYNC__) {
(globalThis as any).__NETCATTY_SETTINGS_SYNC__ = null;
}
};
}, [IS_REMOTE]);
const remoteSettings = useSettingsState();
// 工作台跳转时带过来的预选中项(alertId / logId / hostId)
const pendingSelectionRef = React.useRef<{ alertId?: string; logId?: string; hostId?: string } | null>(null);
// 产品名称 - 直接读取 localStorage,监听 storage 事件(来自其他标签页)和自定义事件
const [productName, setProductName] = useState(() => localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh');
// 监听 productName 变化
useEffect(() => {
const handler = () => {
const newName = localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh';
console.log('[VaultViewLayout] productName changed to:', newName);
setProductName(newName);
};
// 监听 storage 事件(来自其他标签页)
window.addEventListener('storage', handler);
// 监听自定义事件(来自同一标签页的设置页面)
window.addEventListener('productNameChanged' as any, handler);
console.log('[VaultViewLayout] productName listener mounted, current:', productName);
return () => {
window.removeEventListener('storage', handler);
window.removeEventListener('productNameChanged' as any, handler);
};
}, []);
const {
Activity,
allGroupPaths,
allTags,
AppLogo,
Array,
Badge,
BookMarked,
Boolean,
bulkDeleteGroupPaths,
Button,
cancelImport,
cancelInlineGroupEdit,
CheckSquare,
ChevronDown,
clearHostSelection,
ClipboardCopy,
Clock,
cn,
commitInlineGroupRename,
connectSelectedHosts,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
Copy,
currentSection,
customGroups,
deleteGroupPaths,
deleteGroupWithHosts,
deleteSelectedHosts,
deleteTargetPath,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
displayedGroups,
displayedHosts,
DistroAvatar,
Download,
Dropdown,
DropdownContent,
DropdownTrigger,
Edit2,
editingGroupPath,
editingHost,
editingHostGroupDefaults,
FileCode,
FileSymlink,
FolderPlus,
FolderTree,
getDropTargetClasses,
getEffectiveHostDistro,
Globe,
groupConfigs,
GroupDetailsPanel,
groupedDisplayHosts,
handleConnectClick,
handleCopyCredentials,
handleCopyHostname,
handleDeleteTag,
handleDuplicateHost,
handleEditGroupConfig,
handleEditHost,
handleEditTag,
handleExportHosts,
handleHostConnect,
handleImportFileSelected,
handleNewHost,
handleProtocolSelect,
handleQuickConnect,
handleQuickConnectSaveHost,
handleSaveGroupConfig,
handleSearchKeyDown,
handleUnmanageGroup,
handleSidebarWidthCommit,
hasHostsSidePanel,
HostDetailsPanel,
hostListScrollRef,
hosts,
HostTreeView,
hotkeyScheme,
identities,
ImportVaultDialog,
Input,
isDeleteGroupOpen,
isGroupPanelOpen,
isHostPanelOpen,
isHostsSectionActive,
isImportOpen,
isMultiSelectMode,
isNewFolderOpen,
isQuickConnectOpen,
isRenameGroupOpen,
isSearchQuickConnect,
isSerialModalOpen,
Key,
keyBindings,
KeychainManager,
keys,
knownHostsManagerElement,
Label,
lastPinnedId,
LayoutGrid,
LazyConnectionLogsManager,
LazyProtocolSelectDialog,
List,
managedGroupPaths,
managedSources,
moveGroup,
moveHostToGroup,
Network,
newFolderName,
newHostGroupPath,
onConnectSerial,
onCreateLocalTerminal,
onDeleteHost,
onImportOrReuseKey,
onOpenLogView,
onOpenSettings,
onRunSnippet,
onUpdateCustomGroups,
onUpdateGroupConfigs,
onUpdateHosts,
onUpdateIdentities,
onUpdateKeys,
onUpdateProxyProfiles,
onUpdateSnippetPackages,
onUpdateSnippets,
Pin,
pinnedHosts,
pinnedRecentIds,
Plug,
Plus,
PortForwarding,
protocolSelectHost,
proxyProfiles,
ProxyProfilesManager,
quickConnectTarget,
quickConnectWarnings,
QuickConnectWizard,
recentHosts,
renameGroupError,
renameGroupName,
renameTargetPath,
reorderGroup,
reorderHost,
RippleButton,
rootRef,
sanitizeHost,
search,
selectedGroupPath,
selectedGroupPaths,
selectedHostIds,
selectedTags,
SerialConnectModal,
SerialHostDetailsPanel,
sessionCount,
Set,
setBulkDeleteGroupPaths,
setCurrentSection,
setDeleteGroupWithHosts,
setDeleteTargetPath,
setDragOverDropTarget,
setEditingGroupPath,
setEditingHost,
setGroupDragOverDropTarget,
setIsDeleteGroupOpen,
setIsGroupPanelOpen,
setIsHostPanelOpen,
setIsImportOpen,
setIsMultiSelectMode,
setIsNewFolderOpen,
setIsQuickConnectOpen,
setIsRenameGroupOpen,
setIsSerialModalOpen,
setLastPinnedId,
setNewFolderName,
setNewHostGroupPath,
setProtocolSelectHost,
setQuickConnectTarget,
setQuickConnectWarnings,
setRenameGroupError,
setRenameGroupName,
setRenameTargetPath,
setSearch,
setSelectedGroupPath,
setSelectedHostIds,
setSelectedGroupPaths,
setSelectedTags,
setSidebarCollapsed,
setSidebarWidth,
setSortMode,
setTargetParentPath,
Settings,
setViewMode,
shouldHideEmptyRootHostsSection,
showRecentHosts,
hostClickBehavior,
sidebarCollapsed,
sidebarWidth,
snippetPackages,
snippets,
SnippetsManager,
SortDropdown,
sortMode,
splitViewGridStyle,
Square,
Star,
startInlineDeleteGroup,
startInlineNewGroup,
startInlineRenameGroup,
submitNewFolder,
submitRenameGroup,
Suspense,
t,
TagFilterDropdown,
targetParentPath,
terminalFontSize,
terminalSettings,
TerminalSquare,
terminalThemeId,
toggleGroupSelection,
toggleHostPinned,
toggleHostSelection,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Trash2,
treeExpandedState,
treeViewGroupTree,
treeViewHosts,
Upload,
upsertHostById,
Usb,
viewMode,
visibleDisplayedHosts,
X,
Zap,
} = ctx;
const {
knownHosts,
NotebookText,
NotesManager,
onOpenHostFromNote,
onOpenNoteIdHandled,
onOpenSnippetIdHandled,
openNoteId,
openSnippetId,
} = ctx;
const { importProgress, onCommitPluginImporterData, resetImportProgress } =
ctx;
const pendingDeleteGroupPaths =
bulkDeleteGroupPaths.length > 0
? bulkDeleteGroupPaths
: deleteTargetPath
? [deleteTargetPath]
: [];
const pendingDeleteHasManagedGroups = managedSources.some((source) =>
pendingDeleteGroupPaths.some(
(path) =>
source.groupName === path || source.groupName.startsWith(path + "/"),
),
);
const handleNotesOpenHost = useCallback((host: any, source?: { noteId?: string }) => {
if (source?.noteId && onOpenHostFromNote) {
onOpenHostFromNote(host, source);
return;
}
handleHostConnect(host);
}, [handleHostConnect, onOpenHostFromNote]);
const visibleTreeGroupPaths = React.useMemo(
() => collectVisibleVaultGroupPaths(treeViewGroupTree),
[treeViewGroupTree],
);
const visibleSelectableGroupPaths = React.useMemo(
() => new globalThis.Set<string>(
viewMode === "tree"
? visibleTreeGroupPaths
: displayedGroups.map((group: { path: string }) => group.path),
),
[displayedGroups, viewMode, visibleTreeGroupPaths],
);
// 当切到 hosts section 且带了 hostId,自动选中
React.useEffect(() => {
if (currentSection !== "hosts") return;
const pending = pendingSelectionRef.current;
if (pending?.hostId) {
setSelectedHostIds(new Set([pending.hostId]));
pendingSelectionRef.current = null; // 消费掉
}
}, [currentSection, setSelectedHostIds]);
React.useEffect(() => {
setSelectedGroupPaths((current: Set<string>) => {
const next = retainVisibleVaultGroupSelection(current, visibleSelectableGroupPaths);
return next.size === current.size ? current : next;
});
}, [setSelectedGroupPaths, visibleSelectableGroupPaths]);
const hasActiveHostFilters = search.trim().length > 0 || selectedTags.length > 0;
const vaultHostPanelResizeProps = {
resizable: true as const,
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
resizeAriaLabel: t("vault.panel.resizeWidth"),
};
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
const [showRemoteSettings, setShowRemoteSettings] = React.useState(false);
const keyListRef = React.useRef(keys);
keyListRef.current = keys;
const newHostActionsRef = React.useRef<HTMLDivElement>(null);
const sessionActionsRef = React.useRef<HTMLDivElement>(null);
const { handlePluginPreviewCommit, getPluginPreviewAnalysis } =
usePluginImporterCommit({
hosts,
identities,
keys,
snippets,
customGroups,
onCommitPluginImporterData,
t,
onCommitSuccess: (addedCount) => {
toast.success(
t("vault.import.plugins.committed", { count: addedCount }),
t("vault.import.toast.completedTitle"),
);
},
});
const sidebarMinWidth = 56;
const sidebarMaxWidth = 320;
const effectiveSidebarWidth = Math.max(
sidebarMinWidth,
Math.min(sidebarMaxWidth, Number(sidebarWidth) || 208),
);
const handleDeleteVaultKey = React.useCallback(
(keyId: string) => {
void deleteVaultKey({
keyId,
getKeys: () => keyListRef.current,
updateKeys: (updatedKeys) => {
keyListRef.current = updatedKeys;
void onUpdateKeys(updatedKeys);
},
}).catch((error) => {
console.error(
"[Vault] Failed to clear a deleted key's remembered passphrase.",
error,
);
});
},
[onUpdateKeys],
);
const handleSidebarResizeStart = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = effectiveSidebarWidth;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
setIsSidebarResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const clampWidth = (value: number) =>
Math.max(sidebarMinWidth, Math.min(sidebarMaxWidth, value));
const handlePointerMove = (moveEvent: PointerEvent) => {
setSidebarWidth(clampWidth(startWidth + moveEvent.clientX - startX));
};
const handlePointerUp = (upEvent: PointerEvent) => {
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
setSidebarWidth(nextWidth);
handleSidebarWidthCommit(nextWidth);
setIsSidebarResizing(false);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
},
[effectiveSidebarWidth, handleSidebarWidthCommit, setSidebarWidth],
);
React.useEffect(() => {
if (!isHostPanelOpen) return;
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) return;
if (
newHostActionsRef.current?.contains(activeElement) ||
sessionActionsRef.current?.contains(activeElement)
) {
activeElement.blur();
}
}, [isHostPanelOpen]);
return (
<div
ref={rootRef}
className="absolute inset-0 min-h-0 flex bg-secondary"
data-section="vault-view"
>
{/* Sidebar */}
<TooltipProvider delayDuration={100}>
<div
className={cn(
"relative shrink-0 bg-secondary flex flex-col",
isSidebarResizing
? "transition-none"
: "transition-[width] duration-200",
)}
style={{ width: effectiveSidebarWidth }}
data-section="vault-sidebar"
>
<div
className={cn(
"pt-5 pb-6 flex items-center",
sidebarCollapsed ? "px-2 justify-center" : "px-4",
)}
>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
aria-label={sidebarCollapsed ? t("vault.sidebar.expand") : t("vault.sidebar.collapse")}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="flex items-center gap-2.5 hover:opacity-80 transition-opacity"
>
<AppLogo className="h-8 w-8 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="text-sm font-medium">{productName}</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{sidebarCollapsed
? t("vault.sidebar.expand")
: t("vault.sidebar.collapse")}
</TooltipContent>
</Tooltip>
</div>
<div
className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
>
{/* 工作台 */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "workbench" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "workbench" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("workbench")}
>
<BarChart3 size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "工作台"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">工作台</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "hosts" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "hosts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("hosts");
setSelectedGroupPath(null);
}}
>
<LayoutGrid size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.hosts")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">
{t("vault.nav.hosts")}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "keys" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "keys" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("keys");
}}
>
<Key size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.keychain")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">
{t("vault.nav.keychain")}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "proxies" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "proxies" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("proxies");
}}
>
<Globe size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.proxies")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">
{t("vault.nav.proxies")}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "port" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "port" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("port")}
>
<Plug size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.portForwarding")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">
{t("vault.nav.portForwarding")}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={
currentSection === "snippets" ? "secondary" : "ghost"
}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "snippets" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("snippets");
}}
>
<FileCode size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.scripts")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">
{t("vault.nav.scripts")}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "notes" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "notes" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("notes");
}}
>
<NotebookText size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.notes")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">
{t("vault.nav.notes")}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={
currentSection === "knownhosts" ? "secondary" : "ghost"
}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "knownhosts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("knownhosts")}
>
<BookMarked size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.knownHosts")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">
{t("vault.nav.knownHosts")}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "logs" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "logs" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("logs")}
>
<Activity size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.logs")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">
{t("vault.nav.logs")}
</TooltipContent>
)}
</Tooltip>
{/* 分隔线 */}
<div className="h-px bg-border/60 my-2" />
{/* 巡检中心 - inspection */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "inspection" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "inspection" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("inspection")}
>
<Activity size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "巡检中心"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">巡检中心</TooltipContent>
)}
</Tooltip>
{/* 告警中心 - alerts */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "alerts" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "alerts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("alerts")}
>
<Bell size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "告警中心"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">告警中心</TooltipContent>
)}
</Tooltip>
{/* 告警规则 - alertRules */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "alertRules" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "alertRules" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("alertRules")}
>
<AlertTriangle size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "告警规则"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">告警规则</TooltipContent>
)}
</Tooltip>
{/* 预案管理 - planManagement(v1.3 双轨预案 + AI 生成) */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "planManagement" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "planManagement" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("planManagement")}
>
<Wrench size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "预案管理"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">预案管理</TooltipContent>
)}
</Tooltip>
{/* 巡检报告 - reports */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "reports" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "reports" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("reports")}
>
<FileText size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "巡检报告"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">巡检报告</TooltipContent>
)}
</Tooltip>
{/* 运行监控 - monitoring (3D 拓扑大屏) */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "monitoring" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "monitoring" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("monitoring")}
>
<Network size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "运行监控"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">运行监控</TooltipContent>
)}
</Tooltip>
</div>
<div
className={cn(
"mt-auto pb-4 space-y-2",
sidebarCollapsed ? "px-1.5" : "px-2.5",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className={cn(
"w-full",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
)}
onClick={() => {
if (IS_REMOTE) {
setShowRemoteSettings(true);
} else {
onOpenSettings();
}
}}
>
<Settings size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("common.settings")}
</Button>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">
{t("common.settings")}
</TooltipContent>
)}
</Tooltip>
</div>
<div
role="separator"
aria-orientation="vertical"
aria-label={t("vault.sidebar.resize")}
className={cn(
"app-no-drag absolute right-0 top-0 z-20 h-full w-2 translate-x-1/2 cursor-col-resize",
"after:absolute after:right-1/2 after:top-2 after:h-[calc(100%-16px)] after:w-px after:translate-x-1/2 after:bg-border/0 after:transition-colors",
"hover:after:bg-border/70",
isSidebarResizing && "after:bg-primary/70",
)}
onPointerDown={handleSidebarResizeStart}
/>
</div>
</TooltipProvider>
<div
className="flex min-w-0 flex-1 py-0 pr-2 pb-2 pl-0"
data-section="vault-stage"
>
<div
className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
data-section="vault-surface"
>
{/* Main Area */}
<div
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
data-section="vault-main"
>
<VaultPageHeader
className={cn(!isHostsSectionActive && "hidden")}
dataSection="vault-hosts-header"
>
<VaultHeaderSearch
placeholder={t("vault.hosts.search.placeholder")}
className="flex-1"
inputClassName={cn(
isSearchQuickConnect &&
"border-primary/50 ring-1 ring-primary/20",
)}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
rightAdornment={
isSearchQuickConnect ? (
<Zap size={14} className="text-primary" />
) : null
}
/>
<Button
variant={isSearchQuickConnect ? "default" : "secondary"}
className={cn(
"h-10 px-4",
!isSearchQuickConnect && vaultHeaderSecondaryButtonClass,
)}
onClick={handleConnectClick}
>
{t("vault.hosts.connect")}
</Button>
{/* View mode, tag filter, and sort controls */}
<div className="flex items-center gap-1 app-no-drag">
<Dropdown>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={vaultHeaderIconButtonClass}
>
{viewMode === "grid" ? (
<LayoutGrid size={16} />
) : viewMode === "list" ? (
<List size={16} />
) : (
<Network size={16} />
)}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</DropdownTrigger>
<DropdownContent className="w-32" align="end">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={14} /> {t("vault.view.grid")}
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<List size={14} /> {t("vault.view.list")}
</Button>
<Button
variant={viewMode === "tree" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("tree")}
>
<Network size={14} /> {t("vault.view.tree")}
</Button>
</DropdownContent>
</Dropdown>
<TagFilterDropdown
allTags={allTags}
selectedTags={selectedTags}
onChange={setSelectedTags}
onEditTag={handleEditTag}
onDeleteTag={handleDeleteTag}
className={vaultHeaderIconButtonClass}
/>
<SortDropdown
value={sortMode}
onChange={setSortMode}
className={vaultHeaderIconButtonClass}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className={vaultHeaderIconButtonClass}
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("vault.hosts.multiSelect")}
</TooltipContent>
</Tooltip>
</div>
{/* New Host split button — collapses with an animation when the
host details / new-host aside panel is open, since the button
would be a no-op in that state. */}
<div
ref={newHostActionsRef}
className={cn(
"flex items-center app-no-drag overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostPanelOpen
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
: "max-w-[260px] opacity-100",
)}
aria-hidden={isHostPanelOpen ? true : undefined}
inert={isHostPanelOpen ? true : undefined}
>
<Dropdown>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
size="sm"
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
onClick={handleNewHost}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Plus size={14} className="mr-2" />{" "}
{t("vault.hosts.newHost")}
</Button>
<DropdownTrigger asChild>
<Button
size="sm"
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
tabIndex={isHostPanelOpen ? -1 : 0}
>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="end" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => {
setTargetParentPath(selectedGroupPath);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
>
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
disabled={importProgress?.status === "running"}
onClick={() => {
if (importProgress?.status === "running") return;
setIsImportOpen(true);
}}
>
<Upload size={14} /> {t("vault.hosts.import")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={handleExportHosts}
>
<Download size={14} /> {t("vault.hosts.export")}
</Button>
</DropdownContent>
</Dropdown>
</div>
{/* Terminal + Serial — collapse together with an animation when
the host details / new-host aside panel is open, freeing
horizontal space for the panel. */}
<div
ref={sessionActionsRef}
className={cn(
"flex items-center gap-3 overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostPanelOpen
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
: "max-w-[320px] opacity-100",
)}
aria-hidden={isHostPanelOpen ? true : undefined}
inert={isHostPanelOpen ? true : undefined}
>
<Button
size="sm"
variant="secondary"
className={vaultHeaderSecondaryButtonClass}
onClick={onCreateLocalTerminal}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<TerminalSquare size={14} className="mr-2" />{" "}
{t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className={vaultHeaderSecondaryButtonClass}
onClick={() => setIsSerialModalOpen(true)}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
</div>
</VaultPageHeader>
{isMultiSelectMode && isHostsSectionActive && (
<div className="px-4 py-1.5 bg-background border-b border-border/40 flex items-center gap-2">
<span className="flex h-7 items-center text-xs leading-4 text-muted-foreground">
{t("vault.hosts.selectedSummary", {
hosts: selectedHostIds.size,
groups: selectedGroupPaths.size,
})}
</span>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
const allIds = new Set(
collectVisibleVaultHostIds({
viewMode,
displayedHosts,
treeHosts: treeViewHosts,
}),
);
setSelectedHostIds(allIds);
setSelectedGroupPaths(
new Set(
collectVaultGroupPathsForSelectAll({
hasActiveFilters: hasActiveHostFilters,
viewMode,
displayedGroupPaths: displayedGroups.map((group) => group.path),
visibleTreeGroupPaths,
}),
),
);
}}
>
{t("vault.hosts.selectAll")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={clearHostSelection}
>
{t("vault.hosts.deselectAll")}
</Button>
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedHostIds.size === 0}
onClick={connectSelectedHosts}
>
<Plug size={12} className="mr-1" />
{t("vault.hosts.connectSelected", {
count: selectedHostIds.size,
})}
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 px-2 text-xs"
disabled={
selectedHostIds.size === 0 && selectedGroupPaths.size === 0
}
onClick={() => {
if (selectedGroupPaths.size === 0) {
deleteSelectedHosts();
return;
}
setDeleteTargetPath(null);
setBulkDeleteGroupPaths(Array.from(selectedGroupPaths));
setIsDeleteGroupOpen(true);
}}
>
<Trash2 size={12} className="mr-1" />
{t("vault.hosts.deleteSelected", {
count: selectedHostIds.size + selectedGroupPaths.size,
})}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={clearHostSelection}
>
<X size={12} />
</Button>
</div>
)}
{/* Keep hosts mounted so switching sections does not reset scroll or remount the list. */}
<VaultHostListSection
ctx={{
Badge,
Boolean,
Button,
cancelInlineGroupEdit,
CheckSquare,
ClipboardCopy,
Clock,
cn,
commitInlineGroupRename,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
Copy,
displayedGroups,
displayedHosts,
DistroAvatar,
Edit2,
FileSymlink,
FolderPlus,
FolderTree,
getDropTargetClasses,
getEffectiveHostDistro,
groupConfigs,
groupedDisplayHosts,
handleCopyCredentials,
handleCopyHostname,
handleDuplicateHost,
handleEditGroupConfig,
handleEditHost,
handleHostConnect,
handleUnmanageGroup,
hasHostsSidePanel,
hostListScrollRef,
HostTreeView,
isHostsSectionActive,
isMultiSelectMode,
lastPinnedId,
LayoutGrid,
managedGroupPaths,
moveGroup,
moveHostToGroup,
onDeleteHost,
Pin,
pinnedHosts,
pinnedRecentIds,
Plug,
recentHosts,
reorderGroup,
reorderHost,
sanitizeHost,
search,
selectedGroupPath,
selectedGroupPaths,
selectedHostIds,
selectedTags,
sessionCount,
setDeleteTargetPath,
setDragOverDropTarget,
setGroupDragOverDropTarget,
setIsDeleteGroupOpen,
setIsNewFolderOpen,
setLastPinnedId,
setNewFolderName,
setSelectedGroupPath,
setTargetParentPath,
shouldHideEmptyRootHostsSection,
showRecentHosts,
hostClickBehavior,
sortMode,
splitViewGridStyle,
Square,
Star,
startInlineDeleteGroup,
startInlineNewGroup,
startInlineRenameGroup,
t,
toggleGroupSelection,
toggleHostPinned,
toggleHostSelection,
Trash2,
treeExpandedState,
treeViewGroupTree,
treeViewHosts,
viewMode,
visibleDisplayedHosts,
}}
/>
{/* 工作台 */}
{currentSection === "workbench" && (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<WorkbenchView
hosts={hosts}
keys={keys}
proxies={[]}
snippetCount={snippets?.length ?? 0}
activePortForwardCount={0}
onNavigate={(section: string, opts?: any) => {
if (opts) pendingSelectionRef.current = opts;
setCurrentSection(section);
}}
onConnectHost={(hostId: string) => {
const h = hosts?.find((x: any) => x.id === hostId);
if (h) handleHostConnect(h);
}}
/>
</div>
)}
{currentSection === "snippets" && (
<LazyLoadBoundary name="Snippets" resetKey="snippets">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<SnippetsManager
snippets={snippets}
packages={snippetPackages}
hosts={hosts}
customGroups={customGroups}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onPackagesChange={onUpdateSnippetPackages}
onSave={(s) =>
onUpdateSnippets(
snippets.find((ex) => ex.id === s.id)
? snippets.map((ex) => (ex.id === s.id ? s : ex))
: [...snippets, s],
)
}
onBulkSave={onUpdateSnippets}
onDelete={(id) =>
onUpdateSnippets(snippets.filter((s) => s.id !== id))
}
onRunSnippet={onRunSnippet}
availableKeys={keys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onUpdateHosts={onUpdateHosts}
onCreateGroup={(groupPath) =>
onUpdateCustomGroups(
Array.from(new Set([...customGroups, groupPath])),
)
}
openSnippetId={openSnippetId ?? null}
onOpenSnippetIdHandled={onOpenSnippetIdHandled}
/>
</div>
</Suspense>
</LazyLoadBoundary>
)}
<div
className={cn(
"min-h-0 flex-1",
currentSection !== "notes" && "hidden",
)}
data-section="vault-notes-retained"
>
<MemoVaultNotesSection
NotesManager={NotesManager}
hosts={hosts}
isActive={currentSection === "notes"}
openNoteId={openNoteId ?? null}
onOpenNoteIdHandled={onOpenNoteIdHandled}
onOpenHost={handleNotesOpenHost}
/>
</div>
{currentSection === "keys" && (
<LazyLoadBoundary name="Keychain" resetKey="keys">
<Suspense fallback={<VaultSectionLoading />}>
<KeychainManager
keys={keys}
identities={identities}
hosts={hosts}
proxyProfiles={proxyProfiles}
customGroups={customGroups}
groupConfigs={groupConfigs}
managedSources={managedSources}
onSave={(k) => onUpdateKeys([...keys, k])}
onUpdate={(k) =>
onUpdateKeys(
keys.map((existing) =>
existing.id === k.id ? k : existing,
),
)
}
onReorderKeys={onUpdateKeys}
onDelete={handleDeleteVaultKey}
onSaveIdentity={(identity) =>
onUpdateIdentities(
identities.find((ex) => ex.id === identity.id)
? identities.map((ex) =>
ex.id === identity.id ? identity : ex,
)
: [...identities, identity],
)
}
onDeleteIdentity={(id) =>
onUpdateIdentities(identities.filter((i) => i.id !== id))
}
onReorderIdentities={onUpdateIdentities}
onSaveHost={(host) => {
// Update existing host or add new one
const existingIndex = hosts.findIndex(
(h) => h.id === host.id,
);
if (existingIndex >= 0) {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),
);
} else {
onUpdateHosts([...hosts, host]);
}
}}
onCreateGroup={(groupPath) =>
onUpdateCustomGroups(
Array.from(new Set([...customGroups, groupPath])),
)
}
/>
</Suspense>
</LazyLoadBoundary>
)}
{currentSection === "proxies" && (
<LazyLoadBoundary name="Proxy profiles" resetKey="proxies">
<Suspense fallback={<VaultSectionLoading />}>
<ProxyProfilesManager
proxyProfiles={proxyProfiles}
hosts={hosts}
groupConfigs={groupConfigs}
identities={identities}
onUpdateProxyProfiles={onUpdateProxyProfiles}
onUpdateHosts={onUpdateHosts}
onUpdateGroupConfigs={onUpdateGroupConfigs}
/>
</Suspense>
</LazyLoadBoundary>
)}
{currentSection === "port" && (
<LazyLoadBoundary
name="Port forwarding"
resetKey="port-forwarding"
>
<Suspense fallback={<VaultSectionLoading />}>
<PortForwarding
hosts={hosts}
keys={keys}
identities={identities}
knownHosts={knownHosts}
proxyProfiles={proxyProfiles}
customGroups={customGroups}
managedSources={managedSources}
groupConfigs={groupConfigs}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
onUpdateCustomGroups(
Array.from(new Set([...customGroups, groupPath])),
)
}
terminalSettings={terminalSettings}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* Always render KnownHostsManager but hide with CSS to prevent unmounting */}
<div
style={{
display: currentSection === "knownhosts" ? "contents" : "none",
}}
>
{knownHostsManagerElement}
</div>
{/* Connection Logs */}
{currentSection === "logs" && (
<LazyLoadBoundary
name="Connection logs"
resetKey="connection-logs"
>
<Suspense fallback={<VaultSectionLoading />}>
<VaultConnectionLogsSection
ConnectionLogsManager={LazyConnectionLogsManager}
hosts={hosts}
onOpenLogView={onOpenLogView}
initialLogId={pendingSelectionRef.current?.logId ?? undefined}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* 巡检中心 */}
{currentSection === "inspection" && (
<LazyLoadBoundary name="Inspection center" resetKey="inspection-center">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<InspectionErrorBoundary>
<InspectionCenterPage hosts={ctx.hosts} keys={ctx.keys} />
</InspectionErrorBoundary>
</div>
</Suspense>
</LazyLoadBoundary>
)}
{/* 告警中心 */}
{currentSection === "alerts" && (
<LazyLoadBoundary name="Alert center" resetKey="alert-center">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<InspectionErrorBoundary>
<AlertCenterPage initialAlertId={pendingSelectionRef.current?.alertId ?? undefined} />
</InspectionErrorBoundary>
</div>
</Suspense>
</LazyLoadBoundary>
)}
{/* 告警规则配置 */}
{currentSection === "alertRules" && (
<LazyLoadBoundary name="Alert rules" resetKey="alert-rules">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<InspectionErrorBoundary>
<AlertRulesPage />
</InspectionErrorBoundary>
</div>
</Suspense>
</LazyLoadBoundary>
)}
{/* 预案管理(v1.3) */}
{currentSection === "planManagement" && (
<LazyLoadBoundary name="Plan management" resetKey="plan-management">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<InspectionErrorBoundary>
<PlanManagementPage />
</InspectionErrorBoundary>
</div>
</Suspense>
</LazyLoadBoundary>
)}
{/* 巡检报告 */}
{currentSection === "reports" && (
<LazyLoadBoundary name="Inspection report" resetKey="inspection-report">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<InspectionErrorBoundary>
<ReportPage />
</InspectionErrorBoundary>
</div>
</Suspense>
</LazyLoadBoundary>
)}
{/* 运行监控 - 3D 拓扑大屏 */}
{currentSection === "monitoring" && (
<LazyLoadBoundary
name="Monitoring topology"
resetKey="monitoring-topology"
fallback={(error) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="text-base font-medium text-foreground">Monitoring topology could not load.</div>
{error && (
<div className="w-full max-w-xl overflow-auto rounded border border-red-500/30 bg-red-500/5 p-3 text-left text-xs text-red-400">
<div className="mb-1 font-semibold">Error: {error.message}</div>
<pre className="whitespace-pre-wrap font-mono text-[10px] opacity-80">
{error.stack}
</pre>
</div>
)}
<button
type="button"
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
)}
>
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<MonitoringTopologyView
hosts={ctx.hosts}
keys={ctx.keys}
/>
</div>
</Suspense>
</LazyLoadBoundary>
)}
</div>
{/* Group Details Panel */}
{currentSection === "hosts" &&
isGroupPanelOpen &&
editingGroupPath && (
<GroupDetailsPanel
key={editingGroupPath}
groupPath={editingGroupPath}
config={groupConfigs.find((c) => c.path === editingGroupPath)}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
allHosts={hosts}
groups={allGroupPaths}
terminalThemeId={terminalThemeId}
groupConfigs={groupConfigs}
terminalFontSize={terminalFontSize}
onSave={handleSaveGroupConfig}
onCancel={() => {
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
}}
layout="inline"
{...vaultHostPanelResizeProps}
/>
)}
{/* Host Details Panel */}
{currentSection === "hosts" &&
isHostPanelOpen &&
editingHost?.protocol !== "serial" && (
<HostDetailsPanel
initialData={editingHost}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={allGroupPaths}
managedSources={managedSources}
allTags={allTags}
allHosts={hosts}
defaultGroup={
editingHost
? undefined
: newHostGroupPath || selectedGroupPath
}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
groupDefaults={editingHostGroupDefaults}
groupConfigs={groupConfigs}
snippets={snippets}
onSnippetsChange={onUpdateSnippets}
onHostsChange={onUpdateHosts}
onImportKey={onImportOrReuseKey}
onSave={(host) => {
const latestHost = hosts.find(
(entry: { id: string }) => entry.id === host.id,
);
const nextHost = preserveConcurrentHostLineTimestampUpdate({
draft: host,
openedHost: editingHost,
latestHost,
});
onUpdateHosts((prevHosts) => upsertHostById(prevHosts, nextHost));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
onCreateGroup={(groupPath) => {
onUpdateCustomGroups(
Array.from(new Set([...customGroups, groupPath])),
);
}}
layout="inline"
{...vaultHostPanelResizeProps}
/>
)}
{/* Serial Host Details Panel - for editing serial port hosts */}
{currentSection === "hosts" &&
isHostPanelOpen &&
editingHost?.protocol === "serial" && (
<SerialHostDetailsPanel
initialData={editingHost}
allTags={allTags}
groups={allGroupPaths}
groupDefaults={editingHostGroupDefaults}
onSave={(host) => {
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
layout="inline"
{...vaultHostPanelResizeProps}
/>
)}
</div>
</div>
<Dialog
open={isNewFolderOpen}
onOpenChange={(open) => {
setIsNewFolderOpen(open);
if (!open) {
setNewFolderName("");
setTargetParentPath(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{targetParentPath
? t("vault.groups.createSubfolder")
: t("vault.groups.createRoot")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("vault.groups.createDialog.desc")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label>{t("vault.groups.field.name")}</Label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t("vault.groups.placeholder.example")}
autoFocus
onKeyDown={(e) => e.key === "Enter" && submitNewFolder()}
/>
{targetParentPath && (
<p className="text-xs text-muted-foreground mt-2">
{t("vault.groups.parentLabel")}:{" "}
<span className="font-mono">{targetParentPath}</span>
</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsNewFolderOpen(false)}>
{t("common.cancel")}
</Button>
<Button onClick={submitNewFolder}>{t("common.create")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isRenameGroupOpen}
onOpenChange={(open) => {
setIsRenameGroupOpen(open);
if (!open) {
setRenameTargetPath(null);
setRenameGroupName("");
setRenameGroupError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("vault.groups.renameDialogTitle")}</DialogTitle>
<DialogDescription className="sr-only">
{t("vault.groups.renameDialog.desc")}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-2">
<Label>{t("vault.groups.field.name")}</Label>
<Input
value={renameGroupName}
onChange={(e) => {
setRenameGroupName(e.target.value);
setRenameGroupError(null);
}}
placeholder={t("vault.groups.placeholder.example")}
autoFocus
onKeyDown={(e) => e.key === "Enter" && submitRenameGroup()}
/>
{renameTargetPath && (
<p className="text-xs text-muted-foreground">
{t("vault.groups.pathLabel")}:{" "}
<span className="font-mono">{renameTargetPath}</span>
</p>
)}
{renameGroupError && (
<p className="text-xs text-destructive">{renameGroupError}</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsRenameGroupOpen(false)}>
{t("common.cancel")}
</Button>
<Button onClick={submitRenameGroup}>{t("common.rename")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isDeleteGroupOpen}
onOpenChange={(open) => {
setIsDeleteGroupOpen(open);
if (!open) {
setDeleteTargetPath(null);
setBulkDeleteGroupPaths([]);
setDeleteGroupWithHosts(false);
}
}}
>
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-lg">
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="truncate">
{t(
pendingDeleteGroupPaths.length > 1
? "vault.groups.deleteDialog.bulkTitle"
: "vault.groups.deleteDialogTitle",
)}
</DialogTitle>
<DialogDescription className="break-words [overflow-wrap:anywhere]">
{pendingDeleteHasManagedGroups
? t("vault.groups.deleteDialog.managedDesc")
: t(
pendingDeleteGroupPaths.length > 1
? "vault.groups.deleteDialog.bulkDesc"
: "vault.groups.deleteDialog.desc",
)}
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-4 py-4">
{pendingDeleteGroupPaths.length > 0 && (
<>
<p className="min-w-0 break-words text-sm text-muted-foreground [overflow-wrap:anywhere]">
{pendingDeleteGroupPaths.length === 1 ? (
<>
{t("vault.groups.pathLabel")}:{" "}
<span className="font-mono">
{pendingDeleteGroupPaths[0]}
</span>
</>
) : (
t("vault.groups.selectedCount", {
count: pendingDeleteGroupPaths.length,
})
)}
</p>
{!pendingDeleteHasManagedGroups && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={deleteGroupWithHosts}
onChange={(e) =>
setDeleteGroupWithHosts(e.target.checked)
}
className="rounded border-border"
/>
<span>
{t(
pendingDeleteGroupPaths.length > 1
? "vault.groups.deleteDialog.bulkDeleteHosts"
: "vault.groups.deleteDialog.deleteHosts",
)}
</span>
</label>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsDeleteGroupOpen(false)}>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={async () => {
if (pendingDeleteGroupPaths.length > 0) {
const isBulkDelete = bulkDeleteGroupPaths.length > 0;
try {
await deleteGroupPaths(
pendingDeleteGroupPaths,
deleteGroupWithHosts,
isBulkDelete ? selectedHostIds : new Set<string>(),
);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.error"));
return;
}
if (isBulkDelete) {
const deletedItemCount =
selectedHostIds.size + selectedGroupPaths.size;
clearHostSelection();
toast.success(
t("vault.groups.deleteMultiple.success", {
count: deletedItemCount,
}),
);
}
}
setIsDeleteGroupOpen(false);
setBulkDeleteGroupPaths([]);
setDeleteGroupWithHosts(false);
}}
>
{t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ImportVaultDialog
open={isImportOpen}
onOpenChange={setIsImportOpen}
onFileSelected={handleImportFileSelected}
onPluginPreviewCommit={handlePluginPreviewCommit}
getPluginPreviewAnalysis={getPluginPreviewAnalysis}
groups={allGroupPaths}
/>
{importProgress && (
<VaultImportProgressPanel
progress={importProgress}
onCancel={cancelImport}
onClose={resetImportProgress}
t={t}
/>
)}
{/* Quick Connect Wizard */}
{isQuickConnectOpen && quickConnectTarget && (
<QuickConnectWizard
open={isQuickConnectOpen}
target={quickConnectTarget}
keys={keys}
identities={identities}
onConnect={handleQuickConnect}
onSaveHost={handleQuickConnectSaveHost}
onClose={() => {
setIsQuickConnectOpen(false);
setQuickConnectTarget(null);
setQuickConnectWarnings([]);
}}
warnings={quickConnectWarnings}
/>
)}
{/* Protocol Select Dialog */}
{protocolSelectHost && (
<LazyLoadBoundary
name="Protocol selector"
resetKey={protocolSelectHost.id}
>
<Suspense fallback={null}>
<LazyProtocolSelectDialog
host={protocolSelectHost}
onSelect={handleProtocolSelect}
onCancel={() => setProtocolSelectHost(null)}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* Serial Connect Modal */}
<SerialConnectModal
open={isSerialModalOpen}
onClose={() => setIsSerialModalOpen(false)}
onConnect={(config, options) => {
if (onConnectSerial) {
onConnectSerial(config, options);
}
}}
onSaveHost={(host) => {
onUpdateHosts([...hosts, host]);
}}
/>
{/* Remote Settings Dialog (远程访问设置面板) */}
{IS_REMOTE && (
<Dialog open={showRemoteSettings} onOpenChange={setShowRemoteSettings}>
<DialogContent className="max-w-5xl max-h-[92vh] overflow-auto p-0 gap-0">
<DialogHeader className="px-6 pt-5 pb-3 border-b border-border">
<DialogTitle>{t("common.settings")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">
<Suspense fallback={<div className="p-8 text-center text-muted-foreground">加载设置中...</div>}>
<SettingsPage
settings={remoteSettings as any}
appLock={{ enabled: false, locked: false } as any}
/>
</Suspense>
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}