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 ? (
<>
{t("topology.treeLoading")}
>
+ ) : treeFailed ? (
+ <>
+ {t("topology.treeLoadFailed")}{" "}
+
+ >
) : (
t("topology.emptyMaps")
)}
@@ -4456,11 +4481,32 @@ export function TopologyPage() {
{!hexBrowseRegion ? (
- treeQuery.isLoading ? (
+ treeLoading ? (
{t("topology.treeLoading")}
+ ) : treeFailed ? (
+
+
{t("topology.treeLoadFailed")}
+
+
+
+
+
) : regions.length === 0 ? (
@@ -4471,7 +4517,7 @@ export function TopologyPage() {
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
- disabled={createRegionMut.isPending || !treeRoot}
+ disabled={createRegionMut.isPending}
>
{t("topology.newRegion")}
@@ -4516,7 +4562,7 @@ export function TopologyPage() {
type="button"
className="topo-region-hex topo-region-hex--add"
onClick={promptNewRegion}
- disabled={createRegionMut.isPending || !treeRoot}
+ disabled={createRegionMut.isPending}
title={t("topology.newRegion")}
>
@@ -5350,11 +5396,44 @@ export function TopologyPage() {
{!isWorldFlatCanvas ? : null}
- ) : (
+ ) : treeLoading ? (
{t("topology.treeLoading")}
+ ) : treeFailed ? (
+
+
{t("topology.treeLoadFailed")}
+
+
+
+
+
+ ) : (
+
+
{t("topology.emptyMaps")}
+
+
)}
{treeRoot && canvasGraphLoading ? (
@@ -5378,6 +5457,62 @@ export function TopologyPage() {
)}
+ {newRootDialog ? (
+
+
setNewRootDialog(null)} />
+
+
+ {t("topology.newRegion")}
+
+
+
{t("topology.folderHint")}
+
+
+
+
+
+
+
+
+
+ ) : null}
+
{createNeDialog && canvasMode ? (