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