diff --git a/web/public/login-earth-night-bright.webp b/web/public/login-earth-night-bright.webp new file mode 100644 index 0000000..b8ddba5 Binary files /dev/null and b/web/public/login-earth-night-bright.webp differ diff --git a/web/src/index.css b/web/src/index.css index 058e890..69606c9 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -7836,13 +7836,11 @@ pre { width: 100%; height: 100%; background-color: rgba(8, 28, 72, 0.2); - background-image: url("/login-earth-night.webp"); + /* Pre-brightened asset — no live CSS filter (that was the GPU thrash). */ + background-image: url("/login-earth-night-bright.webp"); background-repeat: repeat-x; background-size: 200%; - /* Softer map — less crunchy, blends into the limb fade. */ - filter: brightness(2.45) contrast(1.02) saturate(1.08); animation: login-earth-spin 80s linear infinite; - will-change: background-position; } .login-page__rail { @@ -7866,9 +7864,10 @@ pre { padding: 26px 24px 28px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(8, 16, 32, 0.58); - backdrop-filter: blur(16px) saturate(1.12); - -webkit-backdrop-filter: blur(16px) saturate(1.12); + background: rgba(8, 16, 32, 0.82); + /* Lighter blur — full blur over the spinning globe was a steady GPU cost. */ + backdrop-filter: blur(8px) saturate(1.08); + -webkit-backdrop-filter: blur(8px) saturate(1.08); box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32), 0 0 0 1px rgba(120, 170, 255, 0.07), @@ -8129,6 +8128,12 @@ pre { to { opacity: 1; transform: scale(1.08); } } +/* Pause infinite loops when the login tab is backgrounded (class set by LoginShell). */ +html.login-page--paused .login-planet__textures, +html.login-page--paused .login-page__flare { + animation-play-state: paused !important; +} + @media (prefers-reduced-motion: reduce) { /* Keep ambient earth spin; only drop entrance flourishes. */ .login-planet, @@ -8557,6 +8562,19 @@ pre { padding: 12px 14px; } +/* Form + list share one scroll body (no cramped nested table viewport). */ +.ops-detail-modal__scroll--flow { + border: 0; + background: transparent; + border-radius: 0; +} + +.ops-detail-modal__scroll--flow .pt-list-table-wrap { + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + overflow: visible; +} + .ume-entry-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); diff --git a/web/src/pages/LoginShell.tsx b/web/src/pages/LoginShell.tsx index 8a0ce00..36233aa 100644 --- a/web/src/pages/LoginShell.tsx +++ b/web/src/pages/LoginShell.tsx @@ -1,7 +1,21 @@ -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; /** Auth shell: night-earth stage on the left, form rail on the right. */ export function LoginShell({ children }: { children: ReactNode }) { + // Pause ambient CSS loops while the tab is hidden. + useEffect(() => { + const root = document.documentElement; + const sync = () => { + root.classList.toggle("login-page--paused", document.hidden); + }; + sync(); + document.addEventListener("visibilitychange", sync); + return () => { + document.removeEventListener("visibilitychange", sync); + root.classList.remove("login-page--paused"); + }; + }, []); + return (
) : null} {treeRoot ? ( - + <> {isWorldFlatCanvas ? (
@@ -5445,186 +5216,57 @@ export function TopologyPage() { ) : null}
) : null} - { - const isRegion = n.data.kind === "region"; - const lod = isWorldFlatCanvas ? worldVisualLod : "full"; - const sized = - !isRegion && lod === "dot" - ? { ...n, width: 6, height: 6 } - : !isRegion && lod === "pin" - ? { ...n, width: 14, height: 14 } - : n; - return searchHitIds.includes(n.id) - ? { - ...sized, - selected: true, - className: "is-search-hit", - } - : { ...sized, className: undefined }; - }) - } - edges={isWorldFlatCanvas && worldVisualLod !== "full" ? [] : displayEdges} - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - onlyRenderVisibleElements={ - isWorldFlatCanvas ? worldVisualLod === "dot" : true - } - connectionMode={ConnectionMode.Loose} - connectionLineType={ConnectionLineType.Straight} - connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }} - defaultEdgeOptions={{ type: "straight", labelShowBg: false }} - proOptions={{ hideAttribution: true }} - minZoom={isWorldFlatCanvas ? 0.002 : 0.05} - maxZoom={isWorldFlatCanvas ? 12 : 4} - nodesDraggable={ - isWorldFlatCanvas - ? worldVisualLod === "full" && toolBehavior.nodesDraggable - : toolBehavior.nodesDraggable - } - nodesConnectable={toolBehavior.nodesConnectable} - elementsSelectable={toolBehavior.elementsSelectable} - panOnDrag={toolBehavior.panOnDrag} - selectionOnDrag={toolBehavior.selectionOnDrag} - panOnScroll={toolBehavior.panOnScroll} - selectionMode={SelectionMode.Partial} - multiSelectionKeyCode="Shift" - snapToGrid={snapToGrid} - snapGrid={SNAP_GRID} - onNodeDragStart={() => { - pushHistory(); - }} - onNodesChange={(changes) => { - if ( - changes.some( - (c) => c.type === "position" || c.type === "remove" || c.type === "add", - ) - ) { - markDirty(); - } - onNodesChange(changes); - }} - onEdgesChange={(changes) => { - const physical = changes.filter((c) => { - if (c.type === "select") return false; - if ("id" in c && isAggregateEdgeId(String(c.id))) return false; - return true; - }); - if (physical.some((c) => c.type !== "select")) { - markDirty(); - } - if (physical.length) onEdgesChange(physical); - }} - onConnect={onConnect} - isValidConnection={isValidConnection} - onNodeClick={onNodeClick} - onNodeDoubleClick={onNodeDoubleClick} - onEdgeClick={(_e, edge) => { - setCtxMenu(null); - focusEdge(edge.id); - }} - onPaneClick={() => { - setCtxMenu(null); - clearSelection(); - }} - onNodeContextMenu={(e, node) => { - e.preventDefault(); - const multi = selectedNodeIds.length > 1 && selectedNodeIds.includes(node.id); - if (!multi) focusNode(node.id, false); - const pos = placeCtxMenu(e.clientX, e.clientY); - setCtxMenu( - multi ? { kind: "selection", ...pos } : { kind: "node", id: node.id, ...pos }, - ); - }} - onEdgeContextMenu={(e, edge) => { - e.preventDefault(); - focusEdge(edge.id); - const pos = placeCtxMenu(e.clientX, e.clientY, { w: 260, h: 280 }); - setCtxMenu({ kind: "edge", id: edge.id, ...pos }); - }} - onSelectionContextMenu={(e) => { - e.preventDefault(); - const pos = placeCtxMenu(e.clientX, e.clientY); - setCtxMenu({ kind: "selection", ...pos }); - }} - onPaneContextMenu={(e) => { - e.preventDefault(); - if (!rfRef.current) return; - const flow = rfRef.current.screenToFlowPosition({ - x: e.clientX, - y: e.clientY, - }); - const pos = placeCtxMenu(e.clientX, e.clientY, { w: 180, h: 100 }); - setCtxMenu({ - kind: "pane", - ...pos, - flowX: flow.x, - flowY: flow.y, - }); - }} - onMoveStart={closeCtxMenu} - onMove={(_e, vp) => { - if (isWorldFlatCanvas) setCanvasZoom(vp.zoom); - }} - onMoveEnd={(_e, vp) => { - if (!isWorldFlatCanvas) return; - setCanvasZoom(vp.zoom); - scheduleFlatViewportRefresh(); - }} - onInit={(inst) => { - rfRef.current = inst as ReactFlowInstance, Edge>; - if (pendingFitRef.current) { - pendingFitRef.current = false; - window.requestAnimationFrame(() => { - const bounds = isWorldFlatCanvas - ? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform) - : null; - if (bounds) { - inst.fitBounds(bounds, { padding: 0.12, duration: 0 }); - } else if (nodesRef.current.length > 0) { - inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 }); - } - setCanvasZoom(inst.getZoom()); - }); - } - }} - fitView={false} - deleteKeyCode={null} - edgesFocusable - > - - - {toolMode === "connect" ? ( -
- {t("topology.connectHint")} + +
- ) : null} - - void toggleFullscreen()} - title={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")} - aria-label={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")} - > - - - - {!isWorldFlatCanvas ? : null} -
-
+ } + > + + + ) : treeLoading ? (
-
+
+
+
+ + + setKeyAlertMatchValue(e.target.value)} + list="ume-key-alert-match-options" + /> + setKeyAlertLabel(e.target.value)} + /> + + {keyAlertMatchType === "keyword" + ? keyAlertKeywordHints.map((kw) => + ))} + + + +
+ +
{t("ume.keyAlert.neTypesLabel")}
+ {keyAlertNeTypesQuery.isLoading ? ( + {t("common.refreshing")} + ) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? ( + {t("ume.keyAlert.neTypesEmpty")} + ) : ( +
+ {(keyAlertNeTypesQuery.data?.items || []).map((item) => ( + + ))} +
+ )} +

{t("ume.keyAlert.neTypesHint")}

+ +
+ +
+ {keyAlertOpError ? ( +

+ {t("common.opFailed")}: {keyAlertOpError} +

+ ) : null} +
+
- - { + setKeyAlertFilterMatchType(e.target.value as "" | "notification_id" | "keyword"); + setKeyAlertPage(1); }} > + - setKeyAlertMatchValue(e.target.value)} - list="ume-key-alert-match-options" - /> - setKeyAlertLabel(e.target.value)} - /> - - {keyAlertMatchType === "keyword" - ? keyAlertKeywordHints.map((kw) => - ))} - -
-
{t("ume.keyAlert.neTypesLabel")}
- {keyAlertNeTypesQuery.isLoading ? ( - {t("common.refreshing")} - ) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? ( - {t("ume.keyAlert.neTypesEmpty")} - ) : ( -
- {(keyAlertNeTypesQuery.data?.items || []).map((item) => ( - - ))} -
- )} -

{t("ume.keyAlert.neTypesHint")}

- -
- -
- {keyAlertOpError ? ( -

- {t("common.opFailed")}: {keyAlertOpError} -

- ) : null} -
- -
- { - setKeyAlertFilterKeyword(e.target.value); - setKeyAlertPage(1); - }} - /> - - -
- -
@@ -1239,43 +1249,43 @@ export function UmePage() {
-
-
- - {t("common.pagerMeta", { - total: String(keyAlertTotal), - page: String(keyAlertPage), - pages: String(keyAlertPages), - })} - -
- - - +
+ + {t("common.pagerMeta", { + total: String(keyAlertTotal), + page: String(keyAlertPage), + pages: String(keyAlertPages), + })} + +
+ + + +
diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 4bbbc3f..3d2c0b2 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -1,4 +1,6 @@ import { + lazy, + Suspense, useCallback, useEffect, useMemo, @@ -10,7 +12,7 @@ import { } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; -import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal"; +import type { WebTerminalHandle } from "../components/WebTerminal"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { @@ -44,6 +46,10 @@ import { type KeywordRule, } from "../utils/webcrtKeywordHighlight"; +/** xterm is large — only pull it when a session pane actually mounts. */ +const WebTerminal = lazy(() => + import("../components/WebTerminal").then((m) => ({ default: m.WebTerminal })), +); const PAGE_SIZE = 50; const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions"; const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs"; @@ -2385,6 +2391,13 @@ export function WebcrtPage() {
) : null} {mountTerminal ? ( + + {t("common.refreshing")} + + } + > { @@ -2500,6 +2513,7 @@ export function WebcrtPage() { } }} /> + ) : null} ) : null} diff --git a/web/src/pages/WorkbenchPage.tsx b/web/src/pages/WorkbenchPage.tsx index 0bce01c..4c5e275 100644 --- a/web/src/pages/WorkbenchPage.tsx +++ b/web/src/pages/WorkbenchPage.tsx @@ -89,15 +89,18 @@ export function WorkbenchPage() { const metricsQuery = useQuery({ queryKey: ["runtimeMetrics"], queryFn: fetchRuntimeMetrics, - refetchInterval: 5000, - staleTime: 2000, + staleTime: 10_000, + refetchInterval: 15_000, + refetchIntervalInBackground: false, }); const opsQuery = useQuery({ queryKey: ["opsTasks"], queryFn: fetchOpsTasks, - refetchInterval: 4000, - staleTime: 1500, + staleTime: 10_000, + // Share AppLayout cache; speed up only while something is active. + refetchInterval: (q) => ((q.state.data?.active ?? 0) > 0 ? 4000 : 20_000), + refetchIntervalInBackground: false, }); const visibleBySection = useMemo(() => { diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/pages/network/PortTrafficPage.tsx index 99e7f1e..63588ae 100644 --- a/web/src/pages/network/PortTrafficPage.tsx +++ b/web/src/pages/network/PortTrafficPage.tsx @@ -159,15 +159,25 @@ export function PortTrafficPage() { const dashQuery = useQuery({ queryKey: queryKeys.portTrafficDashboard, queryFn: fetchPortTrafficDashboard, - staleTime: 2000, - refetchInterval: POLL_MS, + staleTime: 5000, + refetchInterval: (q) => { + const d = q.state.data; + const running = Number(d?.running_device_count ?? d?.running_task_count ?? 0); + return running > 0 ? POLL_MS : 15_000; + }, + refetchIntervalInBackground: false, }); const devicesQuery = useQuery({ queryKey: queryKeys.portTrafficDevices(listPage), queryFn: () => fetchPortTrafficDevices({ page: listPage, pageSize: 10 }), - staleTime: 2000, - refetchInterval: POLL_MS, + staleTime: 5000, + refetchInterval: (q) => { + const items = q.state.data?.items || []; + const running = items.some((x) => x.status === "running" || Boolean(x.collect_running)); + return running ? POLL_MS : 15_000; + }, + refetchIntervalInBackground: false, }); const nesQuery = useQuery({ diff --git a/web/src/pages/topology/TopologyReactFlowView.tsx b/web/src/pages/topology/TopologyReactFlowView.tsx new file mode 100644 index 0000000..7aec26b --- /dev/null +++ b/web/src/pages/topology/TopologyReactFlowView.tsx @@ -0,0 +1,503 @@ +import { createContext, memo, useContext, type Dispatch, type SetStateAction } from "react"; +import { + ReactFlow, + Background, + BackgroundVariant, + Controls, + ControlButton, + MiniMap, + Handle, + Position, + ConnectionLineType, + ConnectionMode, + SelectionMode, + applyNodeChanges, + applyEdgeChanges, + type Connection, + type Edge, + type Node, + type NodeChange, + type EdgeChange, + type NodeProps, + type ReactFlowInstance, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { WorldScatterLayer, type WorldScatterPoint } from "./WorldScatterLayer"; +import { ParallelEdge } from "./ParallelEdge"; +import { isAggregateEdgeId } from "./linkDisplay"; +import type { ToolMode, ToolModeBehavior } from "./toolMode"; +import type { TopologyViewRole, TopologyWorldTransform } from "../../types"; + +export type NeNodeData = { + label: string; + managed_ne_id: string; + ume_ne_id: string; + ne_ip: string; + vendor: string; + connect_status: string; + managed_source?: string; + kind?: "ne" | "region" | "layer"; + folder_id?: string; + view_id?: string; + role?: TopologyViewRole | string; + subtitle?: string; + node_count?: number; +}; + +export type TopoDisplayOpts = { + hideIp: boolean; + hideVendor: boolean; + hidePorts: boolean; + connectMode: boolean; + showPlaceholderBadge: boolean; + worldVisualLod: "dot" | "pin" | "full"; +}; + +const TopoDisplayContext = createContext({ + hideIp: true, + hideVendor: true, + hidePorts: true, + connectMode: false, + showPlaceholderBadge: false, + worldVisualLod: "full", +}); + +const SEP = " / "; + +function nodeIconTone(vendor: string, managedNeId: string, umeNeId = ""): string { + const hasManaged = Boolean(String(managedNeId || "").trim()); + const hasUme = Boolean(String(umeNeId || "").trim()); + if (!hasManaged && !hasUme) return "gray"; + const v = String(vendor || "").trim().toLowerCase(); + if (!v || v === "other" || v === "unknown" || v === "generic") { + return hasUme ? "zte" : "gray"; + } + if (v.includes("cisco")) return "cisco"; + if (v.includes("huawei")) return "huawei"; + if (v.includes("zte")) return "zte"; + if (v.includes("juniper")) return "juniper"; + if (v.includes("nokia") || v.includes("alcatel")) return "nokia"; + if (v.includes("ericsson")) return "ericsson"; + if (v.includes("h3c") || v.includes("comware")) return "h3c"; + if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie"; + if (v.includes("mikrotik")) return "mikrotik"; + return hasUme ? "zte" : "gray"; +} + +function isPlaceholderSource(source: string | undefined, neIp: string): boolean { + const src = String(source || "").trim().toLowerCase(); + if (src === "lldp" || src === "topology") return true; + return !String(neIp || "").trim() && Boolean(src); +} + +export { isPlaceholderSource }; + +function RouterIcon() { + return ( +