diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index d94c9a7..1f150b9 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -165,7 +165,7 @@ def ensure_region_physical_view(db: Session, folder_id: str, *, commit: bool = T def bootstrap_topology_tree(db: Session) -> dict[str, str]: - """Ensure hidden system root; flatten legacy nesting.""" + """Ensure hidden system root; flatten legacy nesting (once when needed).""" now = _utcnow() root = ( db.query(TopoFolder) @@ -186,8 +186,10 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]: ) db.add(root) db.flush() + db.commit() + return {"root_id": root.id} - # Drop legacy auto-created Unassigned region when empty; otherwise demote to normal region. + # Hot path: root already exists. Only touch legacy Unassigned rows when present. legacy = ( db.query(TopoFolder) .filter( @@ -196,6 +198,9 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]: ) .all() ) + if not legacy: + return {"root_id": root.id} + for folder in legacy: view_cnt = db.query(TopoView).filter(TopoView.folder_id == folder.id).count() if view_cnt == 0: @@ -204,7 +209,7 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]: folder.is_system = False folder.updated_at = now - # Flatten nesting + normalize kind for all views. + # One-time-ish normalize for views hanging under cleaned legacy folders. for v in db.query(TopoView).all(): changed = False if v.parent_view_id: @@ -493,42 +498,58 @@ def get_topology_tree(db: Session) -> TopologyTreeOut: .all() ) } - ume_ne_by_folder: dict[str, int] = { - str(fid): int(cnt or 0) - for fid, cnt in ( - db.query(TopoFabricNode.region_folder_id, func.count(TopoFabricNode.id)) - .filter( - TopoFabricNode.ume_ne_id.isnot(None), - TopoFabricNode.ume_ne_id != "", - TopoFabricNode.region_folder_id.isnot(None), - TopoFabricNode.region_folder_id != "", - ) - .group_by(TopoFabricNode.region_folder_id) - .all() + # Skip heavy UME rollups on fresh installs (no UME folders / level views yet). + has_ume_dirs = any( + str(getattr(f, "external_ref", None) or "").strip() + for f in folders + if str(f.kind or "") == "region" + ) or any( + bool( + dict(v.filter or {}).get("ume_level") + or dict(v.filter or {}).get("world") + or dict(v.filter or {}).get("world_flat") ) - if str(fid or "").strip() - } - # Direct ME counts by UME parent SBN (level-by-level; not full subtree rollup). - ume_ne_by_sbn: dict[str, int] = { - str(parent): int(cnt or 0) - for parent, cnt in ( - db.query(UmeTopoNode.parent_node, func.count(UmeTopoNode.node_id)) - .filter( - UmeTopoNode.node_type == "TOPO_NODE_ME", - UmeTopoNode.parent_node.isnot(None), - UmeTopoNode.parent_node != "", - ) - .group_by(UmeTopoNode.parent_node) - .all() - ) - if str(parent or "").strip() - } - ume_ne_total = int( - db.query(func.count(TopoFabricNode.id)) - .filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "") - .scalar() - or 0 + for v in views ) + ume_ne_by_folder: dict[str, int] = {} + ume_ne_by_sbn: dict[str, int] = {} + ume_ne_total = 0 + if has_ume_dirs: + ume_ne_by_folder = { + str(fid): int(cnt or 0) + for fid, cnt in ( + db.query(TopoFabricNode.region_folder_id, func.count(TopoFabricNode.id)) + .filter( + TopoFabricNode.ume_ne_id.isnot(None), + TopoFabricNode.ume_ne_id != "", + TopoFabricNode.region_folder_id.isnot(None), + TopoFabricNode.region_folder_id != "", + ) + .group_by(TopoFabricNode.region_folder_id) + .all() + ) + if str(fid or "").strip() + } + ume_ne_by_sbn = { + str(parent): int(cnt or 0) + for parent, cnt in ( + db.query(UmeTopoNode.parent_node, func.count(UmeTopoNode.node_id)) + .filter( + UmeTopoNode.node_type == "TOPO_NODE_ME", + UmeTopoNode.parent_node.isnot(None), + UmeTopoNode.parent_node != "", + ) + .group_by(UmeTopoNode.parent_node) + .all() + ) + if str(parent or "").strip() + } + ume_ne_total = int( + db.query(func.count(TopoFabricNode.id)) + .filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "") + .scalar() + or 0 + ) def _view_node_count(v: TopoView) -> int: raw = int(nc_map.get(v.id, 0) or 0) diff --git a/web/src/App.tsx b/web/src/App.tsx index 8cdbc50..f0cc8e7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -75,11 +75,8 @@ function LegacyPortTrafficWallRedirect() { } function PageFallback() { - return ( -
- Loading… -
- ); + // Delayed fade-in so fast chunk loads do not flash a full-page "Loading…". + return
; } function ProtectedApp() { diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ad5dc43..6441840 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1394,6 +1394,8 @@ const en = { maps: "Maps", tree: "Topology tree", treeLoading: "Loading tree…", + treeLoadFailed: "Failed to load topology tree", + treeRetry: "Retry", graphLoading: "Loading topology…", graphRefreshing: "Refreshing…", canvasEmpty: "No NEs on this map yet", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index a4c3e1a..3a1c420 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1385,6 +1385,8 @@ const zh = { maps: "拓扑图", tree: "拓扑树", treeLoading: "加载拓扑树…", + treeLoadFailed: "拓扑树加载失败", + treeRetry: "重试", graphLoading: "正在加载拓扑图…", graphRefreshing: "刷新中…", canvasEmpty: "这张图上还没有网元", diff --git a/web/src/index.css b/web/src/index.css index 1bf3b24..e1d25c0 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -4894,6 +4894,13 @@ pre { .topo-browser__empty p { margin: 0; } +.topo-browser__empty-actions { + display: inline-flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 8px; +} .topo-browser__empty-icon { display: grid; place-items: center; @@ -9895,6 +9902,16 @@ pre { color: #94a3b8; font-size: 13px; } +.page-loading--soft { + min-height: 120px; + opacity: 0; + animation: page-loading-soft-in 160ms ease 180ms forwards; +} +@keyframes page-loading-soft-in { + to { + opacity: 1; + } +} .app-main option, option { diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx index 21610ee..83d6179 100644 --- a/web/src/layout/AppLayout.tsx +++ b/web/src/layout/AppLayout.tsx @@ -25,8 +25,10 @@ export function AppLayout({ children }: Props) { queryKey: ["opsTasks"], queryFn: fetchOpsTasks, enabled: Boolean(user), - refetchInterval: 4000, - staleTime: 1500, + // Idle installs: poll slowly; speed up only while tasks are active. + refetchInterval: (q) => ((q.state.data?.active ?? 0) > 0 ? 4000 : 20_000), + staleTime: 10_000, + refetchOnWindowFocus: false, }); const activeTasks = opsTasksQuery.data?.active ?? 0; diff --git a/web/src/main.tsx b/web/src/main.tsx index 1777501..dca15d3 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -9,7 +9,16 @@ import { I18nProvider } from "./i18n"; import { ToastProvider } from "./hooks/useToast"; import { AuthProvider } from "./auth/AuthContext"; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Avoid remount flash/jank on every navigation (default staleTime is 0). + staleTime: 30_000, + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); createRoot(document.getElementById("root")!).render( diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index aa7833e..7d9c8a8 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -1189,6 +1189,7 @@ export function TopologyPage() { ip_address: string; } | null>(null); const [createNeBusy, setCreateNeBusy] = useState(false); + const [newRootDialog, setNewRootDialog] = useState<{ name: string } | null>(null); const [discoverOpen, setDiscoverOpen] = useState(false); const [discovering, setDiscovering] = useState(false); const [discoverReport, setDiscoverReport] = useState(null); @@ -1511,6 +1512,8 @@ export function TopologyPage() { const treeRoot = treeQuery.data?.root || null; const regions = useMemo(() => treeRoot?.children || [], [treeRoot]); + const treeLoading = treeQuery.isPending && !treeQuery.data; + const treeFailed = treeQuery.isError && !treeQuery.data; useEffect(() => { if (!treeRoot || !regions.length) return; @@ -1999,13 +2002,17 @@ export function TopologyPage() { ); const createRegionMut = useMutation({ - mutationFn: (input: { name: string; parent_id?: string }) => - createTopologyFolder({ - name: input.name, - kind: "region", - parent_id: input.parent_id || treeQuery.data?.root?.id, - }), + mutationFn: (input: { name: string; parent_id?: string }) => { + const parentId = String(input.parent_id || "").trim(); + // Top-level root: omit parent_id so API bootstraps system root even if tree query failed. + return createTopologyFolder( + parentId + ? { name: input.name, kind: "region", parent_id: parentId } + : { name: input.name, kind: "region" }, + ); + }, onSuccess: async (folder, input) => { + setNewRootDialog(null); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); const tree = await fetchTopologyTree(); const regionsList = tree.root?.children || []; @@ -2095,10 +2102,17 @@ export function TopologyPage() { }); const promptNewRegion = useCallback(() => { - const name = window.prompt(t("topology.newRegionPrompt"), t("topology.newRegionName")); - if (!name?.trim()) return; - createRegionMut.mutate({ name: name.trim() }); - }, [createRegionMut, t]); + setNewRootDialog({ name: t("topology.newRegionName") }); + }, [t]); + + const submitNewRoot = useCallback(() => { + const name = String(newRootDialog?.name || "").trim(); + if (!name) { + showError(t("topology.newRegionPrompt")); + return; + } + createRegionMut.mutate({ name }); + }, [newRootDialog, createRegionMut, showError, t]); const promptNewSubRegion = useCallback(() => { if (isWorldFlatViewName(activeView?.name)) { @@ -3999,7 +4013,7 @@ export function TopologyPage() { type="button" className="topo-sidebar__icon-btn" onClick={promptNewRegion} - disabled={createRegionMut.isPending || !treeRoot} + disabled={createRegionMut.isPending} title={t("topology.newRegion")} aria-label={t("topology.newRegion")} > @@ -4095,11 +4109,22 @@ export function TopologyPage() {
{!treeRoot ? (

- {treeQuery.isLoading ? ( + {treeLoading ? ( <>

+ ) : treeFailed ? ( +
+

{t("topology.treeLoadFailed")}

+
+ + +
+
) : regions.length === 0 ? (