Files
SenMeshNet/components/vault/VaultViewLayout.tsx
zhaolei 2e71e03d71
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: 巡检增强 / 拓扑大屏 / 远程 HTTP / portable 绿色打包
巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立
Store、设备 Store 解耦,_executeSnapshot 补齐 DEBUG 声明,巡检中心
与告警中心页增强。

拓扑大屏:MonitoringTopologyView 扩展分组/布局控制,
useMonitoringData 重构数据流。

远程 HTTP:新增 electron/remoteHttpServer.cjs,preload/api.cjs 暴露
IPC,registerBridges 注册 netcatty:http:open 桥。

portable 绿色打包:
- electron-builder.config.cjs 恢复原始白名单(61MB app.asar),
  收集器自动追踪运行时依赖;
- portableData.cjs 重写为 .portable 标记检测;
- scripts/migratePortableLocalStorage.cjs 离线脚本把 Local Storage
  的 http://localhost:5173 origin 批量迁移为 app://netcatty;
- scripts/scanWorkspaceDeps.cjs 扫描 workspace 运行时依赖。
2026-10-19 21:11:57 +08:00

2034 lines
73 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, FileText } 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"));
// 巡检报告
const ReportPage = lazy(() => import("../inspection/ReportPage"));
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 }) {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
// 产品名称 - 直接读取 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>
{/* 巡检报告 - 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={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>
)}
{/* 巡检报告 */}
{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]);
}}
/>
</div>
);
}