diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index f02a8a0..c01d801 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1468,6 +1468,12 @@ const en = { folderHint: "Creating a root auto-creates its unique root map. Add sub-regions on that canvas.", folderHintShort: "Root folder", outsidePeers: "{{count}} outside peer(s) linked but not on this map", + outsidePeersView: "Off-map NEs {{count}}", + outsidePeersTitle: "Outside peers", + outsidePeersHint: "Linked to this map but not placed. Select and add only what you need.", + outsidePeersVia: "via {{name}}", + outsidePeersFilterPh: "Filter: name / IP / via", + outsidePeersEmpty: "No matching outside peers", projectNeighbors: "Project neighbors", projectedNeighbors: "Projected {{count}} neighbor(s) onto the map", localBrowserOnly: "this browser only", @@ -1602,7 +1608,7 @@ const en = { fit: "Fit view", liveSync: "Live sync", liveSyncOn: "Syncing…", - liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default. No need to open a map first", + liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default", fullscreen: "Fullscreen", exitFullscreen: "Exit fullscreen", display: "Display", @@ -1634,6 +1640,7 @@ const en = { edgeFlow: "Edge flow", collapseSidebar: "Collapse sidebar", expandSidebar: "Expand sidebar", + resizeSidebar: "Drag to resize sidebar", hideAdded: "Hide added", paletteEmpty: "No matching NEs.", paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index fd465e2..d4782ad 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1457,6 +1457,12 @@ const zh = { folderHint: "新建根时自动创建唯一「根图」。子区域请在根图画布上创建。", folderHintShort: "根目录", outsidePeers: "图外对端 {{count}} 个(有链路未上图)", + outsidePeersView: "未上图网元 {{count}}", + outsidePeersTitle: "图外对端", + outsidePeersHint: "与画布有链路但未上图。勾选后按需添加,不必一次全部投影。", + outsidePeersVia: "经 {{name}}", + outsidePeersFilterPh: "筛选:名称 / IP / 经谁", + outsidePeersEmpty: "没有匹配的图外对端", projectNeighbors: "投影邻居上图", projectedNeighbors: "已投影 {{count}} 个邻居上图", localBrowserOnly: "仅本浏览器", @@ -1587,7 +1593,7 @@ const zh = { fit: "适应画布", liveSync: "实时同步", liveSyncOn: "同步中…", - liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新已打开的图(观看 MCP 建图/画拓扑);默认关闭。不需要先打开画布", + liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新当前画布(观看 MCP 建图/画拓扑);默认关闭", fullscreen: "全屏显示", exitFullscreen: "退出全屏", display: "显示", @@ -1619,6 +1625,7 @@ const zh = { edgeFlow: "链路流动", collapseSidebar: "折叠侧栏", expandSidebar: "展开侧栏", + resizeSidebar: "拖动调整侧栏宽度", hideAdded: "隐藏已添加", paletteEmpty: "没有匹配的网元。", paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。", diff --git a/web/src/index.css b/web/src/index.css index 445fad8..a6b4481 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -2,6 +2,35 @@ box-sizing: border-box; } +html { + color-scheme: dark; + scrollbar-color: #475569 #0f172a; + scrollbar-width: thin; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: #0f172a; +} + +*::-webkit-scrollbar-thumb { + background: #475569; + border: 2px solid #0f172a; + border-radius: 999px; +} + +*::-webkit-scrollbar-thumb:hover { + background: #64748b; +} + +*::-webkit-scrollbar-corner { + background: #0f172a; +} + body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; @@ -4305,8 +4334,9 @@ pre { /* ---- Topology — dark sidebar + canvas (shared chrome tokens) ---- */ .topo-page { + --topo-sidebar-w: 260px; display: grid; - grid-template-columns: 260px 1fr; + grid-template-columns: var(--topo-sidebar-w) 1fr; gap: 12px; height: calc(100vh - 48px); min-height: 520px; @@ -4320,7 +4350,13 @@ pre { grid-template-columns: 36px 1fr; } +.topo-page.is-sidebar-resizing { + transition: none; + cursor: col-resize; +} + .topo-sidebar { + position: relative; display: flex; flex-direction: column; gap: 10px; @@ -4334,6 +4370,35 @@ pre { box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06); } +.topo-sidebar__resizer { + position: absolute; + top: 0; + right: -8px; + z-index: 6; + width: 12px; + height: 100%; + cursor: col-resize; + touch-action: none; +} + +.topo-sidebar__resizer::after { + content: ""; + position: absolute; + top: 12%; + bottom: 12%; + left: 50%; + width: 2px; + transform: translateX(-50%); + border-radius: 1px; + background: transparent; + transition: background 0.12s ease; +} + +.topo-sidebar__resizer:hover::after, +.topo-sidebar__resizer.is-dragging::after { + background: rgba(59, 130, 246, 0.75); +} + .topo-page.is-sidebar-collapsed .topo-sidebar { padding: 4px; align-items: stretch; @@ -4366,29 +4431,6 @@ pre { flex: 1; } -.topo-sidebar__rail--live { - flex: 0 0 auto; - padding: 10px 0; -} - -.topo-sidebar__rail--live.is-on { - color: #93c5fd; - background: rgba(37, 99, 235, 0.2); -} - -.topo-sidebar__rail-live-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: currentColor; - opacity: 0.45; -} - -.topo-sidebar__rail--live.is-on .topo-sidebar__rail-live-dot { - opacity: 1; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25); -} - .topo-sidebar__rail .topo-sidebar__rail-icon { width: 28px; height: 28px; @@ -4479,17 +4521,6 @@ pre { min-width: 0; } -.topo-sidebar__live { - display: flex; - align-items: center; - margin-top: 2px; -} - -.topo-sidebar__live .btn { - width: 100%; - justify-content: center; -} - .topo-tree-search__bar > .input { flex: 1 1 auto; min-width: 0; @@ -6028,36 +6059,29 @@ pre { justify-content: flex-start; flex-wrap: wrap; gap: 8px; - padding-top: 6px; - border-top: 1px solid rgba(148, 163, 184, 0.16); + padding-top: 4px; + border-top: 1px solid rgba(148, 163, 184, 0.12); } .topo-toolbar__group + .topo-toolbar__group, -.topo-toolbar__panel + .topo-toolbar__group, +.topo-toolbar__cluster + .topo-toolbar__group, .topo-toolbar__group + .topo-toolbar__group--find, -.topo-toolbar__panel + .topo-toolbar__group--find { +.topo-toolbar__cluster + .topo-toolbar__group--find { margin-left: 2px; padding-left: 10px; border-left: 1px solid rgba(148, 163, 184, 0.2); } -.topo-toolbar__panel { +/* Flat cluster: no nested chrome — only a separator from tool modes. */ +.topo-toolbar__cluster { display: inline-flex; align-items: center; - gap: 6px; + gap: 8px; flex-shrink: 0; - padding: 3px 6px; - background: rgba(2, 8, 23, 0.55); - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; -} - -.topo-toolbar__panel .topo-tools { - background: rgba(15, 23, 42, 0.8); -} - -.topo-toolbar__panel .topo-toolbar__display > summary { - background: #0b1220; + min-width: 0; + margin-left: 2px; + padding-left: 10px; + border-left: 1px solid rgba(148, 163, 184, 0.2); } .topo-toolbar__group--find { @@ -6196,11 +6220,8 @@ pre { .topo-tools { display: inline-flex; + align-items: center; gap: 2px; - padding: 2px; - background: rgba(2, 8, 23, 0.55); - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; flex-shrink: 0; } @@ -6213,8 +6234,8 @@ pre { color: #94a3b8; font-size: 12px; font-weight: 600; - padding: 4px 8px; - border-radius: 4px; + padding: 5px 9px; + border-radius: 6px; cursor: pointer; line-height: 1.2; } @@ -6881,6 +6902,24 @@ pre { background: var(--topo-canvas-bg, #0f172a); --xy-edge-label-background-color: transparent; --xy-edge-label-color: var(--topo-edge-label-color, #e2e8f0); + /* Defaults = dark canvas; TopologyPage overrides via canvasFloatChromeVars. */ + --topo-float-bg: rgba(15, 23, 42, 0.92); + --topo-float-border: rgba(148, 163, 184, 0.22); + --topo-float-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + --topo-float-fg: #e2e8f0; + --topo-float-muted: #94a3b8; + --topo-float-btn-bg: #0b1220; + --topo-float-btn-border: rgba(148, 163, 184, 0.28); + --topo-float-btn-hover: #152033; + --topo-float-active-bg: rgba(37, 99, 235, 0.28); + --topo-float-active-fg: #93c5fd; + --topo-float-active-border: #3b82f6; + --topo-float-control-bg: #0f172a; + --topo-float-control-hover: #1e293b; + --topo-float-control-fill: #cbd5e1; + --topo-float-input-bg: #0b1220; + --topo-float-input-fg: #e2e8f0; + --topo-float-input-border: rgba(148, 163, 184, 0.28); } .topo-canvas:fullscreen, @@ -6894,31 +6933,39 @@ pre { .topo-canvas .react-flow__controls { display: flex; flex-direction: column; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); - border: 1px solid rgba(148, 163, 184, 0.28); + box-shadow: var(--topo-float-shadow); + border: 0; border-radius: 6px; overflow: hidden; + background: var(--topo-float-bg); } .topo-canvas .react-flow__controls-button { - background: #0f172a; - border-bottom-color: rgba(148, 163, 184, 0.22); - fill: #cbd5e1; + background: transparent; + border: 0; + border-bottom: 1px solid var(--topo-float-border); + color: var(--topo-float-control-fill); + fill: var(--topo-float-control-fill); +} + +.topo-canvas .react-flow__controls-button:last-child { + border-bottom: 0; } .topo-canvas .react-flow__controls-button:hover { - background: #1e293b; + background: var(--topo-float-control-hover); } -.topo-canvas .react-flow__controls-button svg { - fill: #cbd5e1; +.topo-canvas .react-flow__controls-button svg, +.topo-canvas .react-flow__controls-button svg path { + fill: currentColor; } .topo-canvas .react-flow__minimap { - background: #0b1220; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); + border: 0; + border-radius: 8px; + overflow: hidden; + box-shadow: var(--topo-float-shadow); } .topo-canvas .react-flow__controls-button.topo-fs-control { @@ -6928,16 +6975,13 @@ pre { .topo-canvas .react-flow__controls-button.topo-fs-control svg { max-width: 14px; max-height: 14px; + width: 14px; + height: 14px; + display: block; + color: inherit; } -.topo-toolbar__panel--view { - margin-left: 2px; - padding-left: 10px; - border-left: 1px solid rgba(148, 163, 184, 0.2); - gap: 8px; -} - -.topo-toolbar__panel--view .topo-toolbar__group--find { +.topo-toolbar__cluster .topo-toolbar__group--find { margin-left: 0; padding-left: 0; border-left: 0; @@ -6953,9 +6997,10 @@ pre { gap: 6px; padding: 8px; border-radius: 10px; - background: rgba(15, 23, 42, 0.92); - border: 1px solid rgba(148, 163, 184, 0.22); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + background: var(--topo-float-bg); + border: 1px solid var(--topo-float-border); + box-shadow: var(--topo-float-shadow); + color: var(--topo-float-fg); pointer-events: auto; } @@ -6970,9 +7015,10 @@ pre { gap: 6px; padding: 6px 8px; border-radius: 10px; - background: rgba(15, 23, 42, 0.92); - border: 1px solid rgba(148, 163, 184, 0.22); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + background: var(--topo-float-bg); + border: 1px solid var(--topo-float-border); + box-shadow: var(--topo-float-shadow); + color: var(--topo-float-fg); pointer-events: auto; max-width: min(520px, calc(100% - 24px)); } @@ -7006,32 +7052,71 @@ pre { right: 12px; } +/* Beat `.app-main button { background:#0b1220 }` so inactive chips stay ghost. */ +.app-main .topo-fs-toolbar__btn, +.topo-canvas .topo-fs-toolbar__btn, .topo-fs-toolbar__btn { display: inline-flex; align-items: center; gap: 6px; - padding: 6px 10px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - background: #0b1220; - color: #e2e8f0; + height: auto; + padding: 6px 10px !important; + border-radius: 8px !important; + border: 1px solid transparent !important; + background: transparent !important; + color: var(--topo-float-muted) !important; + box-shadow: none !important; font-size: 12px; + font-weight: 600; + line-height: 1.2; cursor: pointer; } +.app-main .topo-fs-toolbar__btn:hover, +.topo-canvas .topo-fs-toolbar__btn:hover, .topo-fs-toolbar__btn:hover { - background: #152033; + background: var(--topo-float-btn-hover) !important; + color: var(--topo-float-fg) !important; } +.app-main .topo-fs-toolbar__btn.is-active, +.topo-canvas .topo-fs-toolbar__btn.is-active, .topo-fs-toolbar__btn.is-active { - border-color: #3b82f6; - background: rgba(37, 99, 235, 0.28); - color: #93c5fd; + border-color: var(--topo-float-active-border) !important; + background: var(--topo-float-active-bg) !important; + color: var(--topo-float-active-fg) !important; } +.app-main .topo-fs-toolbar__btn kbd, +.topo-canvas .topo-fs-toolbar__btn kbd, .topo-fs-toolbar__btn kbd { font-size: 10px; opacity: 0.7; + border-color: currentColor; + background: transparent; + color: inherit; + padding: 0 3px; +} + +/* Beat global dark toolbar overrides when canvas bg is light. */ +.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__find, +.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__select, +.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__display > summary { + background: var(--topo-float-input-bg) !important; + border-color: var(--topo-float-input-border) !important; + color: var(--topo-float-input-fg) !important; +} + +.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__find::placeholder { + color: var(--topo-float-muted); +} + +.topo-canvas .topo-view-tools .topo-toolbar__find, +.topo-canvas .topo-view-tools .topo-toolbar__select, +.topo-canvas .topo-view-tools .topo-toolbar__display > summary { + background: var(--topo-float-input-bg); + border-color: var(--topo-float-input-border); + color: var(--topo-float-input-fg); } .topo-canvas__empty { @@ -7330,15 +7415,36 @@ pre { pointer-events: none; } -.topo-outside-peers { - display: flex; - flex-direction: column; - gap: 6px; - padding: 0 10px 10px; +.app-main .topo-toolbar__outside-peers, +.topo-toolbar__outside-peers { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + width: auto; + height: 30px; + padding: 0 10px; + font-size: 12px; + font-weight: 600; + line-height: 1; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: #0b1220; + color: #94a3b8; } -.topo-outside-peers .panel__hint { - margin: 0; +.app-main .topo-toolbar__outside-peers:hover, +.topo-toolbar__outside-peers:hover { + background: #152033; + color: #e2e8f0; +} + +.topo-modal__panel--wide { + width: min(560px, calc(100vw - 32px)); +} + +.topo-modal__count { + font-weight: 500; + color: #94a3b8; } .topo-node--cisco { @@ -7659,6 +7765,10 @@ pre { height: auto; } + .topo-sidebar__resizer { + display: none; + } + .topo-sidebar { max-height: 40vh; } diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 55df30c..f266680 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -8,6 +8,7 @@ import { useRef, useState, type CSSProperties, + type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; @@ -89,6 +90,39 @@ function isPlaceholderSource(source: string | undefined, neIp: string): boolean const LAST_LEAF_KEY = "netx.topology.lastLeafViewId"; const TREE_EXPAND_KEY = "netx.topology.treeExpanded"; +const SIDEBAR_WIDTH_KEY = "netx.topology.sidebarWidth"; +const SIDEBAR_WIDTH_DEFAULT = 260; +const SIDEBAR_WIDTH_MIN = 180; +const SIDEBAR_WIDTH_MAX = 560; + +function loadSidebarWidth(): number { + try { + const n = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY)); + if (Number.isFinite(n) && n >= SIDEBAR_WIDTH_MIN) { + return Math.min(Math.round(n), SIDEBAR_WIDTH_MAX); + } + } catch { + /* ignore */ + } + return SIDEBAR_WIDTH_DEFAULT; +} + +function saveSidebarWidth(width: number): void { + try { + localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(width))); + } catch { + /* ignore */ + } +} + +function clampSidebarWidth(width: number, pageWidth?: number): number { + const maxByPage = + pageWidth && pageWidth > 0 + ? Math.max(SIDEBAR_WIDTH_MIN, Math.floor(pageWidth * 0.55)) + : SIDEBAR_WIDTH_MAX; + const maxW = Math.min(SIDEBAR_WIDTH_MAX, maxByPage); + return Math.max(SIDEBAR_WIDTH_MIN, Math.min(maxW, Math.round(width))); +} function findViewInRegion( regions: TopologyTreeFolderItem[], @@ -677,6 +711,61 @@ function isHexColor(value: string): boolean { return /^#[0-9a-fA-F]{6}$/.test(value); } +function canvasBgLuminance(bg: string): number { + const hex = String(bg || "").replace("#", "").trim(); + if (hex.length !== 6) return 0.1; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + if (![r, g, b].every((n) => Number.isFinite(n))) return 0.1; + return (0.299 * r + 0.587 * g + 0.114 * b) / 255; +} + +/** Floating chrome (fs toolbar / view tools / controls) follows canvas bg like MiniMap. */ +function canvasFloatChromeVars(bg: string): CSSProperties { + const light = canvasBgLuminance(bg) > 0.55; + if (light) { + return { + "--topo-float-bg": "rgba(255, 255, 255, 0.92)", + "--topo-float-border": "rgba(15, 23, 42, 0.08)", + "--topo-float-shadow": "0 8px 24px rgba(15, 23, 42, 0.12)", + "--topo-float-fg": "#0f172a", + "--topo-float-muted": "#64748b", + "--topo-float-btn-bg": "transparent", + "--topo-float-btn-border": "transparent", + "--topo-float-btn-hover": "rgba(15, 23, 42, 0.06)", + "--topo-float-active-bg": "rgba(37, 99, 235, 0.16)", + "--topo-float-active-fg": "#1d4ed8", + "--topo-float-active-border": "#3b82f6", + "--topo-float-control-bg": "transparent", + "--topo-float-control-hover": "rgba(15, 23, 42, 0.06)", + "--topo-float-control-fill": "#334155", + "--topo-float-input-bg": "#ffffff", + "--topo-float-input-fg": "#0f172a", + "--topo-float-input-border": "rgba(15, 23, 42, 0.16)", + } as CSSProperties; + } + return { + "--topo-float-bg": "rgba(15, 23, 42, 0.92)", + "--topo-float-border": "rgba(148, 163, 184, 0.18)", + "--topo-float-shadow": "0 8px 24px rgba(0, 0, 0, 0.35)", + "--topo-float-fg": "#e2e8f0", + "--topo-float-muted": "#94a3b8", + "--topo-float-btn-bg": "transparent", + "--topo-float-btn-border": "transparent", + "--topo-float-btn-hover": "rgba(30, 41, 59, 0.9)", + "--topo-float-active-bg": "rgba(37, 99, 235, 0.28)", + "--topo-float-active-fg": "#93c5fd", + "--topo-float-active-border": "#3b82f6", + "--topo-float-control-bg": "transparent", + "--topo-float-control-hover": "rgba(30, 41, 59, 0.9)", + "--topo-float-control-fill": "#cbd5e1", + "--topo-float-input-bg": "#0b1220", + "--topo-float-input-fg": "#e2e8f0", + "--topo-float-input-border": "rgba(148, 163, 184, 0.28)", + } as CSSProperties; +} + function loadCanvasBg(): string { try { const raw = String(localStorage.getItem(CANVAS_BG_KEY) || "").trim().toLowerCase(); @@ -1054,7 +1143,15 @@ export function TopologyPage() { loadBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, true), ); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(() => loadSidebarWidth()); + const [sidebarResizing, setSidebarResizing] = useState(false); + const sidebarDragRef = useRef<{ startX: number; startW: number } | null>(null); + const topoPageRef = useRef(null); const [addNeOpen, setAddNeOpen] = useState(false); + const [outsidePeersOpen, setOutsidePeersOpen] = useState(false); + const [outsidePeerSelectedIds, setOutsidePeerSelectedIds] = useState([]); + const [outsidePeerQuery, setOutsidePeerQuery] = useState(""); + const [outsidePeersAdding, setOutsidePeersAdding] = useState(false); const [paletteSource, setPaletteSource] = useState("managed"); const [paletteSelectedKeys, setPaletteSelectedKeys] = useState([]); const [paletteAdding, setPaletteAdding] = useState(false); @@ -3066,38 +3163,88 @@ export function TopologyPage() { }); }, [staleEdgeIds, queueDeleteEdges]); - const projectOutsidePeers = useCallback(async () => { - if (!mapId) return; - try { - const before = nodes.length; - const projected = await projectTopologyNeighbors(mapId, { dry_run: true }); - if (projected.truncate_reason === "membership_frozen") { - showError(t("topology.truncatedFrozen")); - return; + const addOutsidePeers = useCallback( + async (fabricNodeIds: string[]): Promise => { + if (!mapId) return false; + const ids = [...new Set(fabricNodeIds.map((x) => String(x || "").trim()).filter(Boolean))]; + if (!ids.length || outsidePeersAdding) return false; + setOutsidePeersAdding(true); + try { + const onCanvas = new Set(nodes.map((n) => n.id)); + const toAdd = ids.filter((id) => !onCanvas.has(id)); + if (!toAdd.length) { + setOutsidePeersOpen(false); + setOutsidePeerSelectedIds([]); + return false; + } + const graph = await addTopologyViewNodes(mapId, { + fabric_node_ids: toAdd, + layout: "grid", + }); + if (graph.truncate_reason === "membership_frozen") { + showError(t("topology.truncatedFrozen")); + return false; + } + const addedSet = new Set(toAdd); + const added = (graph.nodes || []).filter((n) => addedSet.has(n.fabric_node_id)); + if (added.length > 0) { + const peerList = graphQuery.data?.outside_peers || []; + const viaAnchors = new Map( + peerList + .filter((p) => addedSet.has(p.fabric_node_id)) + .map((p) => [p.fabric_node_id, p.via_node_id] as const), + ); + const cols = Math.max(1, Math.ceil(Math.sqrt(added.length))); + await patchTopologyPositions( + mapId, + added.map((n, i) => { + const viaId = viaAnchors.get(n.fabric_node_id); + const viaNode = viaId ? nodes.find((x) => x.id === viaId) : undefined; + const base = viaNode + ? { x: viaNode.position.x + 200, y: viaNode.position.y } + : { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 }; + return { + fabric_node_id: n.fabric_node_id, + x: base.x + (i % cols) * 180, + y: base.y + Math.floor(i / cols) * 120, + label: n.label || n.name || "", + }; + }), + ); + } + const refreshed = await fetchTopologyGraph(mapId); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed); + historyLockRef.current = true; + applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges); + historyLockRef.current = false; + clearDirty(); + const count = added.length || toAdd.length; + showOk(t("topology.projectedNeighbors").replace("{{count}}", String(count))); + setOutsidePeerSelectedIds([]); + setOutsidePeersOpen(false); + return count > 0; + } catch (err) { + showError(String(err)); + return false; + } finally { + setOutsidePeersAdding(false); } - const localPos = new Map(nodes.map((n) => [n.id, n.position])); - historyLockRef.current = true; - applyViewGraph(projected, edgeDefaults, setNodes, setEdges, localPos); - historyLockRef.current = false; - const added = Math.max(0, projected.nodes.length - before); - if (added > 0) { - markDirty(); - } - showOk(t("topology.projectedNeighbors").replace("{{count}}", String(added))); - } catch (err) { - showError(String(err)); - } - }, [ - mapId, - nodes, - edgeDefaults, - setNodes, - setEdges, - markDirty, - showOk, - showError, - t, - ]); + }, + [ + mapId, + nodes, + graphQuery.data?.outside_peers, + outsidePeersAdding, + edgeDefaults, + setNodes, + setEdges, + clearDirty, + queryClient, + showOk, + showError, + t, + ], + ); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -3816,6 +3963,23 @@ export function TopologyPage() { ); const outsidePeers = graphQuery.data?.outside_peers || []; + const outsidePeerNameById = useMemo(() => { + const m = new Map(); + for (const n of nodes) { + const label = String(n.data?.label || n.data?.subtitle || "").trim(); + m.set(n.id, label || n.id.slice(0, 8)); + } + return m; + }, [nodes]); + const outsidePeersVisible = useMemo(() => { + const q = outsidePeerQuery.trim().toLowerCase(); + if (!q) return outsidePeers; + return outsidePeers.filter((p) => { + const viaName = outsidePeerNameById.get(p.via_node_id) || p.via_node_id; + const bits = [p.name, p.ip, viaName, p.fabric_node_id]; + return bits.some((b) => String(b || "").toLowerCase().includes(q)); + }); + }, [outsidePeers, outsidePeerQuery, outsidePeerNameById]); const graphTruncated = Boolean(graphQuery.data?.truncated); const truncateReason = String(graphQuery.data?.truncate_reason || "").trim(); const worldScatter = graphQuery.data?.scatter || []; @@ -3924,8 +4088,53 @@ export function TopologyPage() { (paletteSource === "managed" && neQuery.isLoading) || (paletteSource === "ume" && umeQuery.isLoading); + const onSidebarResizeDown = useCallback( + (e: ReactMouseEvent) => { + if (sidebarCollapsed) return; + e.preventDefault(); + sidebarDragRef.current = { startX: e.clientX, startW: sidebarWidth }; + setSidebarResizing(true); + const onMove = (ev: MouseEvent) => { + const drag = sidebarDragRef.current; + if (!drag) return; + const pageW = topoPageRef.current?.clientWidth || window.innerWidth; + const next = clampSidebarWidth(drag.startW + (ev.clientX - drag.startX), pageW); + setSidebarWidth(next); + }; + const onUp = () => { + sidebarDragRef.current = null; + setSidebarResizing(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + setSidebarWidth((w) => { + const pageW = topoPageRef.current?.clientWidth || window.innerWidth; + const next = clampSidebarWidth(w, pageW); + saveSidebarWidth(next); + return next; + }); + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [sidebarCollapsed, sidebarWidth], + ); + return ( -
+
-
- -
{treeSearchOpen && treeNeQuery.trim() ? (
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? ( @@ -4102,23 +4290,19 @@ export function TopologyPage() {
)} - {mapId && outsidePeers.length > 0 && ( -
-

- {t("topology.outsidePeers").replace("{{count}}", String(outsidePeers.length))} -

- -
- )}
)} + {!sidebarCollapsed ? ( +
+ ) : null}
@@ -4190,6 +4374,15 @@ export function TopologyPage() {
+
-
-
- {( - [ - ["select", t("topology.toolSelect"), "V"], - ["pan", t("topology.toolPan"), "H"], - ["connect", t("topology.toolConnect"), "C"], - ] as const - ).map(([mode, label, key]) => ( - - ))} -
+
+ {( + [ + ["select", t("topology.toolSelect"), "V"], + ["pan", t("topology.toolPan"), "H"], + ["connect", t("topology.toolConnect"), "C"], + ] as const + ).map(([mode, label, key]) => ( + + ))}
{!fullscreen ? (
) : null} + {outsidePeers.length > 0 ? ( + + ) : null}
) : null} @@ -4465,15 +4670,6 @@ export function TopologyPage() { )}

- {!hexBrowseRegion ? ( treeLoading ? ( @@ -4699,7 +4895,9 @@ export function TopologyPage() { ) : (
0.55 ? " is-canvas-light" : ""}`} ref={canvasRef} style={ { @@ -4716,6 +4914,7 @@ export function TopologyPage() { "--topo-vendor-ruijie": vendorColors.ruijie, "--topo-vendor-mikrotik": vendorColors.mikrotik, "--topo-vendor-gray": vendorColors.gray, + ...canvasFloatChromeVars(canvasBg), } as CSSProperties } onDragOver={onCanvasDragOver} @@ -5502,6 +5701,164 @@ export function TopologyPage() {
) : null} + {outsidePeersOpen && canvasMode ? ( +
+
{ + if (outsidePeersAdding) return; + setOutsidePeersOpen(false); + }} + /> +
+
+ + {t("topology.outsidePeersTitle")} + · {outsidePeers.length} + + +
+

{t("topology.outsidePeersHint")}

+ { + setOutsidePeerQuery(e.target.value); + }} + placeholder={t("topology.outsidePeersFilterPh")} + disabled={outsidePeersAdding} + autoFocus + /> + {outsidePeersVisible.length > 0 ? ( +
+ + + {t("topology.selectedCount").replace( + "{{count}}", + String(outsidePeerSelectedIds.length), + )} + +
+ ) : null} +
    + {outsidePeersVisible.length === 0 ? ( +
  • + {t("topology.outsidePeersEmpty")} +
  • + ) : ( + outsidePeersVisible.map((peer) => { + const checked = outsidePeerSelectedIds.includes(peer.fabric_node_id); + const viaName = + outsidePeerNameById.get(peer.via_node_id) || peer.via_node_id.slice(0, 8); + const title = peer.name || peer.ip || peer.fabric_node_id.slice(0, 8); + return ( +
  • +
    + + +
    +
  • + ); + }) + )} +
+
+ + +
+
+
+ ) : null} + {addNeOpen && canvasMode ? (
Number.isFinite(n))) return null; + return { r, g, b }; +} + +function canvasDotColor(bg: string): string { + const rgb = canvasBgRgb(bg); + if (!rgb) return "#93c5fd"; + const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; return lum > 0.55 ? "#93c5fd" : "#64748b"; } +/** MiniMap colors follow the canvas bg so a light map doesn't get a dark inset. */ +function minimapTheme(bg: string): { + bgColor: string; + nodeColor: string; + nodeStrokeColor: string; + maskColor: string; + maskStrokeColor: string; +} { + const rgb = canvasBgRgb(bg) || { r: 11, g: 18, b: 32 }; + const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; + const light = lum > 0.55; + return { + bgColor: bg || (light ? "#e8eef6" : "#0b1220"), + nodeColor: "#3b82f6", + nodeStrokeColor: light ? "#93c5fd" : "#1e3a5f", + maskColor: light ? "rgba(15, 23, 42, 0.14)" : "rgba(2, 8, 23, 0.62)", + maskStrokeColor: light ? "rgba(59, 130, 246, 0.55)" : "rgba(148, 163, 184, 0.45)", + }; +} + function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): { x: number; y: number; @@ -497,7 +524,15 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) - {!isWorldFlatCanvas ? : null} + {!isWorldFlatCanvas ? ( + + ) : null} );