Files
NetMesh/components/vault/VaultViewLayout.tsx
zhaolei c64e00d835 feat: 新增设备巡检模块实现完整的巡检告警功能
新增设备巡检模块核心功能,包括:
- 巡检数据存储与调度系统
- 华为/H3C交换机及Linux服务器采集器
- 设备扩展配置与告警规则管理
- 主机详情页巡检配置入口
- 产品名称自定义设置功能
- 网络设备SSH exec通道兼容处理
2026-10-09 10:37:48 +08:00

1993 lines
71 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.
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback, lazy, Suspense, useState, useEffect } from "react";
import { AlertTriangle, Bell, Activity, Network } from "lucide-react";
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 { 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"));
// 运行监控 - 3D 拓扑大屏
const MonitoringTopologyView = lazy(() => import("../monitoring/MonitoringTopologyView"));
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,
}: {
ConnectionLogsManager: React.ComponentType<any>;
hosts: any[];
onOpenLogView: (log: any) => void;
}) {
const {
connectionLogs,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
} = useConnectionLogsStore();
return (
<ConnectionLogsManager
logs={connectionLogs}
hosts={hosts}
onToggleSaved={toggleConnectionLogSaved}
onDelete={deleteConnectionLog}
onClearUnsaved={clearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
);
}
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
// 产品名称 - 直接读取 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],
);
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 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 === "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>
{/* 运行监控 - 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={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 === "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}
/>
</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 />
</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>
)}
{/* 运行监控 - 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]);
}}
/>
</div>
);
}