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 (
diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx
index 91be60e..dc73764 100644
--- a/web/src/pages/TopologyPage.tsx
+++ b/web/src/pages/TopologyPage.tsx
@@ -1,9 +1,8 @@
import {
Fragment,
- createContext,
- memo,
+ lazy,
+ Suspense,
useCallback,
- useContext,
useEffect,
useMemo,
useRef,
@@ -14,28 +13,13 @@ import {
import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- ReactFlow,
- Background,
- BackgroundVariant,
- Controls,
- ControlButton,
- MiniMap,
- addEdge,
- useEdgesState,
- useNodesState,
- Handle,
+import type {
+ Connection,
+ Edge,
+ Node,
Position,
- ConnectionLineType,
- ConnectionMode,
- SelectionMode,
- type Connection,
- type Edge,
- type Node,
- type NodeProps,
- type ReactFlowInstance,
+ ReactFlowInstance,
} from "@xyflow/react";
-import "@xyflow/react/dist/style.css";
import {
addTopologyViewNodes,
createFabricManualEdge,
@@ -70,24 +54,19 @@ import { HelpHint } from "../components/HelpHint";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { openOrFocusModule } from "../utils/moduleWindows";
-import { WorldScatterLayer } from "./topology/WorldScatterLayer";
import type {
FabricNodeSearchHit,
TopologyDiscoverJob,
TopologyDiscoverNeResult,
TopologyDiscoverOut,
- TopologyEdgeItem,
- TopologyNodeItem,
TopologyTreeFolderItem,
TopologyTreeViewItem,
TopologyViewEdgeItem,
TopologyViewGraph,
TopologyViewNodeItem,
- TopologyViewRole,
TopologyWorldTransform,
} from "../types";
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
-import { ParallelEdge } from "./topology/ParallelEdge";
import {
buildLinkDisplayEdges,
formatPortPairLabel,
@@ -97,6 +76,16 @@ import {
type LinkMember,
} from "./topology/linkDisplay";
import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode";
+import type { NeNodeData } from "./topology/TopologyReactFlowView";
+
+/** xyflow + canvas nodes — only loaded when a map canvas is open. */
+const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowView"));
+
+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);
+}
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
@@ -308,17 +297,6 @@ function formatNeCount(count: number): string {
return `${n}N`;
}
-function formatUpdatedAt(value?: string | null): string {
- if (!value) return "";
- try {
- return new Date(value).toLocaleString();
- } catch {
- return String(value);
- }
-}
-
-const SNAP_GRID: [number, number] = [16, 16];
-
/**
* onlyRenderVisibleElements leaves off-screen nodes unmeasured; default fitView
* skips those. includeHiddenNodes uses declared width/height so fit still works.
@@ -402,27 +380,6 @@ function mergeFlatWorldGraph(
const UNDO_MAX = 40;
const PALETTE_DND = "application/x-netx-topo-palette";
-function FullscreenIcon({ exit }: { exit?: boolean }) {
- if (exit) {
- return (
-
- );
- }
- return (
-
- );
-}
-
/** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */
const SEP = " / ";
@@ -526,23 +483,6 @@ function LayerGlyph({ role, size = 16 }: { role?: string; size?: number }) {
);
}
-type NeNodeData = {
- label: string;
- managed_ne_id: string;
- ume_ne_id: string;
- ne_ip: string;
- vendor: string;
- connect_status: string;
- managed_source?: string;
- /** Canvas navigation node (not a fabric NE). */
- kind?: "ne" | "region" | "layer";
- folder_id?: string;
- view_id?: string;
- role?: TopologyViewRole | string;
- subtitle?: string;
- node_count?: number;
-};
-
type HistorySnap = {
nodes: Node
[];
edges: Edge[];
@@ -564,32 +504,12 @@ type PaletteItem = {
connect_status: string;
};
-type TopoDisplayOpts = {
- hideIp: boolean;
- hideVendor: boolean;
- hidePorts: boolean;
- connectMode: boolean;
- /** Show TOPO/LLDP placeholder corner badge on nodes. */
- showPlaceholderBadge: boolean;
- /** Flat world map visual LOD (Google-Earth style). */
- worldVisualLod: "dot" | "pin" | "full";
-};
-
type CtxMenu =
| { kind: "node"; id: string; x: number; y: number }
| { kind: "edge"; id: string; x: number; y: number }
| { kind: "selection"; x: number; y: number }
| { kind: "pane"; x: number; y: number; flowX: number; flowY: number };
-const TopoDisplayContext = createContext({
- hideIp: true,
- hideVendor: true,
- hidePorts: true,
- connectMode: false,
- showPlaceholderBadge: false,
- worldVisualLod: "full",
-});
-
function newId(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID().replace(/-/g, "");
@@ -606,31 +526,6 @@ function isLocalPendingEdgeId(id: string): boolean {
return String(id || "").startsWith("local:");
}
-/**
- * Icon tone for /topo/ne-router.png (base = blue).
- * Unmanaged (no managed/ume) and unknown vendors → gray.
- * UME-linked NEs default to ZTE when vendor is empty/unknown.
- */
-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 discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" {
if (!r.ok) return "fail";
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
@@ -670,33 +565,6 @@ function nodeMatchesQuery(n: Node, query: string): boolean {
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
}
-/**
- * Keep the PNG’s white texture/highlights; tint brand color via mix-blend-mode.
- * Color comes from --topo-icon-color (vendor palette in Display settings).
- */
-function RouterIcon() {
- return (
-
-
-
-
- );
-}
-
-/** Canvas sub-region building art (UME-style); no vendor tint. */
-function RegionCanvasIcon() {
- return (
-
-
-
- );
-}
-
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set).
* Caption is absolutely positioned under the glyph so the RF box is icon-only. */
const TOPO_NODE_W = 80;
@@ -745,99 +613,6 @@ function neApiPosition(flowX: number, flowY: number): { x: number; y: number } {
return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
}
-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);
-}
-
-const NeNode = memo(function NeNode({ data, selected }: NodeProps>) {
- const { hideIp, hideVendor, connectMode, showPlaceholderBadge, worldVisualLod } =
- useContext(TopoDisplayContext);
- const isRegion = data.kind === "region";
- const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
- const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip);
- const showBadge = placeholder && showPlaceholderBadge;
- const name = data.label || (!hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
- // Region count stays in the title "(N)"; do not repeat under the icon.
- const secondary = isRegion
- ? []
- : [
- hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
- hideVendor || !data.vendor ? "" : data.vendor,
- ].filter(Boolean);
-
- if (!isRegion && worldVisualLod === "dot") {
- return (
-
-
-
-
-
- );
- }
- if (!isRegion && worldVisualLod === "pin") {
- return (
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
- {!isRegion ? : }
- {showBadge ? (
-
- {String(data.managed_source || "ph").slice(0, 4)}
-
- ) : null}
-
-
- {name}
- {secondary.length ? (
- {secondary.join(SEP)}
- ) : null}
-
-
- );
-});
-
-const nodeTypes = { neNode: NeNode };
-const edgeTypes = { topoParallel: ParallelEdge };
-
type EdgeStyleData = LinkEdgeData;
type EdgeLineStyle = "solid" | "dashed" | "dotted";
@@ -972,16 +747,6 @@ function persistVendorColors(value: VendorColors) {
}
}
-function canvasDotColor(bg: string): string {
- const hex = String(bg || "").replace("#", "");
- if (hex.length !== 6) return "#93c5fd";
- const r = parseInt(hex.slice(0, 2), 16);
- const g = parseInt(hex.slice(2, 4), 16);
- const b = parseInt(hex.slice(4, 6), 16);
- const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
- return lum > 0.55 ? "#93c5fd" : "#64748b";
-}
-
function loadBoolFlag(key: string, defaultValue: boolean): boolean {
try {
const raw = localStorage.getItem(key);
@@ -1138,8 +903,8 @@ function graphToFlow(
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
// Predetermined handles must match DOM anchors (icon center).
handles: [
- { type: "target", position: Position.Left, x: hx, y: hy },
- { type: "source", position: Position.Right, x: hx, y: hy },
+ { type: "target", position: "left" as Position, x: hx, y: hy },
+ { type: "source", position: "right" as Position, x: hx, y: hy },
],
data: {
label: n.label || n.name || n.ip || n.fabric_node_id,
@@ -1328,8 +1093,8 @@ export function TopologyPage() {
const displayMenuRef = useRef(null);
const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState(null);
const findJustLocatedRef = useRef(false);
- const [nodes, setNodes, onNodesChange] = useNodesState>([]);
- const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+ const [nodes, setNodes] = useState[]>([]);
+ const [edges, setEdges] = useState([]);
const rfRef = useRef, Edge> | null>(null);
const canvasRef = useRef(null);
const dirtyRef = useRef(false);
@@ -1672,6 +1437,12 @@ export function TopologyPage() {
const canvasMode = Boolean(mapId);
+ useEffect(() => {
+ if (!canvasMode) return;
+ void import("./topology/TopologyReactFlowView");
+ }, [canvasMode]);
+
+
const activeRegion = useMemo(() => {
if (!selectedFolderId) return null;
return findFolderInTree(regions, selectedFolderId);
@@ -2102,7 +1873,7 @@ export function TopologyPage() {
return;
}
pushHistory();
- const next = layoutGraph(nodes, edges, kind, { onlyIds });
+ const next = await layoutGraph(nodes, edges, kind, { onlyIds });
setNodes(next);
markDirty();
if (opts?.persist && mapId) {
@@ -2765,7 +2536,7 @@ export function TopologyPage() {
});
let didAutoLayout = false;
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
- rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
+ rfNodes = await layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
didAutoLayout = true;
}
historyLockRef.current = true;
@@ -5430,7 +5201,7 @@ export function TopologyPage() {
) : 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")}
+
+
+ {t("topology.treeLoading")}
- ) : null}
-
- void toggleFullscreen()}
- title={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
- aria-label={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
- >
-
-
-
- {!isWorldFlatCanvas ? : null}
-
-
+ }
+ >
+
+
+ >
) : treeLoading ? (
diff --git a/web/src/pages/UmePage.tsx b/web/src/pages/UmePage.tsx
index 8d4d63f..5d5994b 100644
--- a/web/src/pages/UmePage.tsx
+++ b/web/src/pages/UmePage.tsx
@@ -70,14 +70,22 @@ export function UmePage() {
const syncStatusQuery = useQuery({
queryKey: queryKeys.umeSyncStatus(syncPage, syncPageSize),
queryFn: () => fetchUmeSyncStatus({ page: syncPage, pageSize: syncPageSize }),
- staleTime: 5000,
- refetchInterval: 5000,
+ staleTime: 10_000,
+ // Panel open or active sync → keep snappy; otherwise idle cards only need a slow heartbeat.
+ refetchInterval: (q) => {
+ const items = q.state.data?.items || [];
+ const running = items.some((x) => String(x.status || "").toLowerCase() === "running");
+ if (syncStatusPanelOpen || running) return 5000;
+ return 20_000;
+ },
+ refetchIntervalInBackground: false,
});
const tokenStatusQuery = useQuery({
queryKey: queryKeys.umeTokenStatus,
queryFn: fetchUmeTokenStatus,
- staleTime: 3000,
- refetchInterval: 5000,
+ staleTime: 15_000,
+ refetchInterval: 20_000,
+ refetchIntervalInBackground: false,
});
const tokenRefreshMutation = useMutation({
mutationFn: refreshUmeToken,
@@ -103,8 +111,9 @@ export function UmePage() {
const subscriptionStatusQuery = useQuery({
queryKey: queryKeys.umeAlarmSubscription,
queryFn: fetchUmeAlarmSubscriptionStatus,
- staleTime: 3000,
- refetchInterval: 5000,
+ staleTime: 10_000,
+ refetchInterval: 15_000,
+ refetchIntervalInBackground: false,
});
const keyAlertMonitorQuery = useQuery({
queryKey: queryKeys.umeKeyAlertMonitor(
@@ -122,8 +131,9 @@ export function UmePage() {
enabled: keyAlertFilterEnabled,
matchType: keyAlertFilterMatchType,
}),
- staleTime: 3000,
- refetchInterval: 5000,
+ staleTime: keyAlertPanelOpen ? 3000 : 15_000,
+ refetchInterval: keyAlertPanelOpen ? 5000 : 20_000,
+ refetchIntervalInBackground: false,
});
const keyAlertNeTypesQuery = useQuery({
queryKey: queryKeys.umeInventoryNeTypes,
@@ -981,174 +991,174 @@ export function UmePage() {
-
+
+
+
+
+
+ setKeyAlertMatchValue(e.target.value)}
+ list="ume-key-alert-match-options"
+ />
+ setKeyAlertLabel(e.target.value)}
+ />
+
+
+
+
+
+
{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}
+
+
-
-
-
{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);
- }}
- />
- {
- setKeyAlertFilterMatchType(e.target.value as "" | "notification_id" | "keyword");
- setKeyAlertPage(1);
- }}
- >
-
-
-
-
- {
- setKeyAlertFilterEnabled(e.target.value as "" | "true" | "false");
- setKeyAlertPage(1);
- }}
- >
-
-
-
-
-
-
-
@@ -1239,43 +1249,43 @@ export function UmePage() {
-
-
-
- {t("common.pagerMeta", {
- total: String(keyAlertTotal),
- page: String(keyAlertPage),
- pages: String(keyAlertPages),
- })}
-
-
-
-
-
{
- setKeyAlertPageSize(Number(e.target.value) || 20);
- setKeyAlertPage(1);
- }}
- >
-
-
-
-
+
+
+ {t("common.pagerMeta", {
+ total: String(keyAlertTotal),
+ page: String(keyAlertPage),
+ pages: String(keyAlertPages),
+ })}
+
+
+
+
+ {
+ setKeyAlertPageSize(Number(e.target.value) || 20);
+ setKeyAlertPage(1);
+ }}
+ >
+
+
+
+
+
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 ? (
+