mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 03:20:48 +08:00
Fix empty topology create-root and cut navigation loading flash.
Keep the hidden system root built-in; speed tree bootstrap, unblock new-root UI, and soften query/Suspense refetch jank. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c4911b6ddc
commit
990798987b
8 changed files with 246 additions and 61 deletions
|
|
@ -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]:
|
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()
|
now = _utcnow()
|
||||||
root = (
|
root = (
|
||||||
db.query(TopoFolder)
|
db.query(TopoFolder)
|
||||||
|
|
@ -186,8 +186,10 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
||||||
)
|
)
|
||||||
db.add(root)
|
db.add(root)
|
||||||
db.flush()
|
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 = (
|
legacy = (
|
||||||
db.query(TopoFolder)
|
db.query(TopoFolder)
|
||||||
.filter(
|
.filter(
|
||||||
|
|
@ -196,6 +198,9 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
if not legacy:
|
||||||
|
return {"root_id": root.id}
|
||||||
|
|
||||||
for folder in legacy:
|
for folder in legacy:
|
||||||
view_cnt = db.query(TopoView).filter(TopoView.folder_id == folder.id).count()
|
view_cnt = db.query(TopoView).filter(TopoView.folder_id == folder.id).count()
|
||||||
if view_cnt == 0:
|
if view_cnt == 0:
|
||||||
|
|
@ -204,7 +209,7 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
||||||
folder.is_system = False
|
folder.is_system = False
|
||||||
folder.updated_at = now
|
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():
|
for v in db.query(TopoView).all():
|
||||||
changed = False
|
changed = False
|
||||||
if v.parent_view_id:
|
if v.parent_view_id:
|
||||||
|
|
@ -493,42 +498,58 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ume_ne_by_folder: dict[str, int] = {
|
# Skip heavy UME rollups on fresh installs (no UME folders / level views yet).
|
||||||
str(fid): int(cnt or 0)
|
has_ume_dirs = any(
|
||||||
for fid, cnt in (
|
str(getattr(f, "external_ref", None) or "").strip()
|
||||||
db.query(TopoFabricNode.region_folder_id, func.count(TopoFabricNode.id))
|
for f in folders
|
||||||
.filter(
|
if str(f.kind or "") == "region"
|
||||||
TopoFabricNode.ume_ne_id.isnot(None),
|
) or any(
|
||||||
TopoFabricNode.ume_ne_id != "",
|
bool(
|
||||||
TopoFabricNode.region_folder_id.isnot(None),
|
dict(v.filter or {}).get("ume_level")
|
||||||
TopoFabricNode.region_folder_id != "",
|
or dict(v.filter or {}).get("world")
|
||||||
)
|
or dict(v.filter or {}).get("world_flat")
|
||||||
.group_by(TopoFabricNode.region_folder_id)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
if str(fid or "").strip()
|
for v in views
|
||||||
}
|
|
||||||
# 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
|
|
||||||
)
|
)
|
||||||
|
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:
|
def _view_node_count(v: TopoView) -> int:
|
||||||
raw = int(nc_map.get(v.id, 0) or 0)
|
raw = int(nc_map.get(v.id, 0) or 0)
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,8 @@ function LegacyPortTrafficWallRedirect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageFallback() {
|
function PageFallback() {
|
||||||
return (
|
// Delayed fade-in so fast chunk loads do not flash a full-page "Loading…".
|
||||||
<div className="page-loading" role="status" aria-live="polite">
|
return <div className="page-loading page-loading--soft" role="status" aria-busy="true" />;
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProtectedApp() {
|
function ProtectedApp() {
|
||||||
|
|
|
||||||
|
|
@ -1394,6 +1394,8 @@ const en = {
|
||||||
maps: "Maps",
|
maps: "Maps",
|
||||||
tree: "Topology tree",
|
tree: "Topology tree",
|
||||||
treeLoading: "Loading tree…",
|
treeLoading: "Loading tree…",
|
||||||
|
treeLoadFailed: "Failed to load topology tree",
|
||||||
|
treeRetry: "Retry",
|
||||||
graphLoading: "Loading topology…",
|
graphLoading: "Loading topology…",
|
||||||
graphRefreshing: "Refreshing…",
|
graphRefreshing: "Refreshing…",
|
||||||
canvasEmpty: "No NEs on this map yet",
|
canvasEmpty: "No NEs on this map yet",
|
||||||
|
|
|
||||||
|
|
@ -1385,6 +1385,8 @@ const zh = {
|
||||||
maps: "拓扑图",
|
maps: "拓扑图",
|
||||||
tree: "拓扑树",
|
tree: "拓扑树",
|
||||||
treeLoading: "加载拓扑树…",
|
treeLoading: "加载拓扑树…",
|
||||||
|
treeLoadFailed: "拓扑树加载失败",
|
||||||
|
treeRetry: "重试",
|
||||||
graphLoading: "正在加载拓扑图…",
|
graphLoading: "正在加载拓扑图…",
|
||||||
graphRefreshing: "刷新中…",
|
graphRefreshing: "刷新中…",
|
||||||
canvasEmpty: "这张图上还没有网元",
|
canvasEmpty: "这张图上还没有网元",
|
||||||
|
|
|
||||||
|
|
@ -4894,6 +4894,13 @@ pre {
|
||||||
.topo-browser__empty p {
|
.topo-browser__empty p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.topo-browser__empty-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.topo-browser__empty-icon {
|
.topo-browser__empty-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
@ -9895,6 +9902,16 @@ pre {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 13px;
|
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,
|
.app-main option,
|
||||||
option {
|
option {
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ export function AppLayout({ children }: Props) {
|
||||||
queryKey: ["opsTasks"],
|
queryKey: ["opsTasks"],
|
||||||
queryFn: fetchOpsTasks,
|
queryFn: fetchOpsTasks,
|
||||||
enabled: Boolean(user),
|
enabled: Boolean(user),
|
||||||
refetchInterval: 4000,
|
// Idle installs: poll slowly; speed up only while tasks are active.
|
||||||
staleTime: 1500,
|
refetchInterval: (q) => ((q.state.data?.active ?? 0) > 0 ? 4000 : 20_000),
|
||||||
|
staleTime: 10_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
const activeTasks = opsTasksQuery.data?.active ?? 0;
|
const activeTasks = opsTasksQuery.data?.active ?? 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,16 @@ import { I18nProvider } from "./i18n";
|
||||||
import { ToastProvider } from "./hooks/useToast";
|
import { ToastProvider } from "./hooks/useToast";
|
||||||
import { AuthProvider } from "./auth/AuthContext";
|
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(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|
|
||||||
|
|
@ -1189,6 +1189,7 @@ export function TopologyPage() {
|
||||||
ip_address: string;
|
ip_address: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [createNeBusy, setCreateNeBusy] = useState(false);
|
const [createNeBusy, setCreateNeBusy] = useState(false);
|
||||||
|
const [newRootDialog, setNewRootDialog] = useState<{ name: string } | null>(null);
|
||||||
const [discoverOpen, setDiscoverOpen] = useState(false);
|
const [discoverOpen, setDiscoverOpen] = useState(false);
|
||||||
const [discovering, setDiscovering] = useState(false);
|
const [discovering, setDiscovering] = useState(false);
|
||||||
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
|
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
|
||||||
|
|
@ -1511,6 +1512,8 @@ export function TopologyPage() {
|
||||||
|
|
||||||
const treeRoot = treeQuery.data?.root || null;
|
const treeRoot = treeQuery.data?.root || null;
|
||||||
const regions = useMemo(() => treeRoot?.children || [], [treeRoot]);
|
const regions = useMemo(() => treeRoot?.children || [], [treeRoot]);
|
||||||
|
const treeLoading = treeQuery.isPending && !treeQuery.data;
|
||||||
|
const treeFailed = treeQuery.isError && !treeQuery.data;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!treeRoot || !regions.length) return;
|
if (!treeRoot || !regions.length) return;
|
||||||
|
|
@ -1999,13 +2002,17 @@ export function TopologyPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const createRegionMut = useMutation({
|
const createRegionMut = useMutation({
|
||||||
mutationFn: (input: { name: string; parent_id?: string }) =>
|
mutationFn: (input: { name: string; parent_id?: string }) => {
|
||||||
createTopologyFolder({
|
const parentId = String(input.parent_id || "").trim();
|
||||||
name: input.name,
|
// Top-level root: omit parent_id so API bootstraps system root even if tree query failed.
|
||||||
kind: "region",
|
return createTopologyFolder(
|
||||||
parent_id: input.parent_id || treeQuery.data?.root?.id,
|
parentId
|
||||||
}),
|
? { name: input.name, kind: "region", parent_id: parentId }
|
||||||
|
: { name: input.name, kind: "region" },
|
||||||
|
);
|
||||||
|
},
|
||||||
onSuccess: async (folder, input) => {
|
onSuccess: async (folder, input) => {
|
||||||
|
setNewRootDialog(null);
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||||
const tree = await fetchTopologyTree();
|
const tree = await fetchTopologyTree();
|
||||||
const regionsList = tree.root?.children || [];
|
const regionsList = tree.root?.children || [];
|
||||||
|
|
@ -2095,10 +2102,17 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const promptNewRegion = useCallback(() => {
|
const promptNewRegion = useCallback(() => {
|
||||||
const name = window.prompt(t("topology.newRegionPrompt"), t("topology.newRegionName"));
|
setNewRootDialog({ name: t("topology.newRegionName") });
|
||||||
if (!name?.trim()) return;
|
}, [t]);
|
||||||
createRegionMut.mutate({ name: name.trim() });
|
|
||||||
}, [createRegionMut, 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(() => {
|
const promptNewSubRegion = useCallback(() => {
|
||||||
if (isWorldFlatViewName(activeView?.name)) {
|
if (isWorldFlatViewName(activeView?.name)) {
|
||||||
|
|
@ -3999,7 +4013,7 @@ export function TopologyPage() {
|
||||||
type="button"
|
type="button"
|
||||||
className="topo-sidebar__icon-btn"
|
className="topo-sidebar__icon-btn"
|
||||||
onClick={promptNewRegion}
|
onClick={promptNewRegion}
|
||||||
disabled={createRegionMut.isPending || !treeRoot}
|
disabled={createRegionMut.isPending}
|
||||||
title={t("topology.newRegion")}
|
title={t("topology.newRegion")}
|
||||||
aria-label={t("topology.newRegion")}
|
aria-label={t("topology.newRegion")}
|
||||||
>
|
>
|
||||||
|
|
@ -4095,11 +4109,22 @@ export function TopologyPage() {
|
||||||
</div>
|
</div>
|
||||||
{!treeRoot ? (
|
{!treeRoot ? (
|
||||||
<p className="panel__hint topo-tree-status" role="status">
|
<p className="panel__hint topo-tree-status" role="status">
|
||||||
{treeQuery.isLoading ? (
|
{treeLoading ? (
|
||||||
<>
|
<>
|
||||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||||
{t("topology.treeLoading")}
|
{t("topology.treeLoading")}
|
||||||
</>
|
</>
|
||||||
|
) : treeFailed ? (
|
||||||
|
<>
|
||||||
|
{t("topology.treeLoadFailed")}{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => void treeQuery.refetch()}
|
||||||
|
>
|
||||||
|
{t("topology.treeRetry")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
t("topology.emptyMaps")
|
t("topology.emptyMaps")
|
||||||
)}
|
)}
|
||||||
|
|
@ -4456,11 +4481,32 @@ export function TopologyPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!hexBrowseRegion ? (
|
{!hexBrowseRegion ? (
|
||||||
treeQuery.isLoading ? (
|
treeLoading ? (
|
||||||
<div className="topo-browser__empty topo-browser__empty--loading" role="status">
|
<div className="topo-browser__empty topo-browser__empty--loading" role="status">
|
||||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||||
<p>{t("topology.treeLoading")}</p>
|
<p>{t("topology.treeLoading")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : treeFailed ? (
|
||||||
|
<div className="topo-browser__empty">
|
||||||
|
<p>{t("topology.treeLoadFailed")}</p>
|
||||||
|
<div className="topo-browser__empty-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => void treeQuery.refetch()}
|
||||||
|
>
|
||||||
|
{t("topology.treeRetry")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
onClick={promptNewRegion}
|
||||||
|
disabled={createRegionMut.isPending}
|
||||||
|
>
|
||||||
|
{t("topology.newRegion")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : regions.length === 0 ? (
|
) : regions.length === 0 ? (
|
||||||
<div className="topo-browser__empty">
|
<div className="topo-browser__empty">
|
||||||
<span className="topo-browser__empty-icon" aria-hidden="true">
|
<span className="topo-browser__empty-icon" aria-hidden="true">
|
||||||
|
|
@ -4471,7 +4517,7 @@ export function TopologyPage() {
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm"
|
className="btn btn--sm"
|
||||||
onClick={promptNewRegion}
|
onClick={promptNewRegion}
|
||||||
disabled={createRegionMut.isPending || !treeRoot}
|
disabled={createRegionMut.isPending}
|
||||||
>
|
>
|
||||||
{t("topology.newRegion")}
|
{t("topology.newRegion")}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -4516,7 +4562,7 @@ export function TopologyPage() {
|
||||||
type="button"
|
type="button"
|
||||||
className="topo-region-hex topo-region-hex--add"
|
className="topo-region-hex topo-region-hex--add"
|
||||||
onClick={promptNewRegion}
|
onClick={promptNewRegion}
|
||||||
disabled={createRegionMut.isPending || !treeRoot}
|
disabled={createRegionMut.isPending}
|
||||||
title={t("topology.newRegion")}
|
title={t("topology.newRegion")}
|
||||||
>
|
>
|
||||||
<span className="topo-region-hex__plus" aria-hidden="true">
|
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||||
|
|
@ -5350,11 +5396,44 @@ export function TopologyPage() {
|
||||||
{!isWorldFlatCanvas ? <MiniMap pannable zoomable /> : null}
|
{!isWorldFlatCanvas ? <MiniMap pannable zoomable /> : null}
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</TopoDisplayContext.Provider>
|
</TopoDisplayContext.Provider>
|
||||||
) : (
|
) : treeLoading ? (
|
||||||
<div className="topo-canvas__empty topo-canvas__status" role="status">
|
<div className="topo-canvas__empty topo-canvas__status" role="status">
|
||||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||||
<p>{t("topology.treeLoading")}</p>
|
<p>{t("topology.treeLoading")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : treeFailed ? (
|
||||||
|
<div className="topo-canvas__empty">
|
||||||
|
<p className="muted">{t("topology.treeLoadFailed")}</p>
|
||||||
|
<div className="topo-browser__empty-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => void treeQuery.refetch()}
|
||||||
|
>
|
||||||
|
{t("topology.treeRetry")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
onClick={promptNewRegion}
|
||||||
|
disabled={createRegionMut.isPending}
|
||||||
|
>
|
||||||
|
{t("topology.newRegion")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="topo-canvas__empty">
|
||||||
|
<p className="muted">{t("topology.emptyMaps")}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
onClick={promptNewRegion}
|
||||||
|
disabled={createRegionMut.isPending}
|
||||||
|
>
|
||||||
|
{t("topology.newRegion")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{treeRoot && canvasGraphLoading ? (
|
{treeRoot && canvasGraphLoading ? (
|
||||||
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
|
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
|
||||||
|
|
@ -5378,6 +5457,62 @@ export function TopologyPage() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{newRootDialog ? (
|
||||||
|
<div
|
||||||
|
className="topo-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="topo-new-root-title"
|
||||||
|
>
|
||||||
|
<div className="topo-modal__backdrop" onClick={() => setNewRootDialog(null)} />
|
||||||
|
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
|
||||||
|
<div className="topo-modal__head">
|
||||||
|
<strong id="topo-new-root-title">{t("topology.newRegion")}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => setNewRootDialog(null)}
|
||||||
|
>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="panel__hint topo-modal__hint">{t("topology.folderHint")}</p>
|
||||||
|
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
|
||||||
|
<label>
|
||||||
|
<span className="form-label">{t("topology.newRegionPrompt")}</span>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newRootDialog.name}
|
||||||
|
disabled={createRegionMut.isPending}
|
||||||
|
onChange={(e) => setNewRootDialog({ name: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") submitNewRoot();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="topo-modal__foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={createRegionMut.isPending}
|
||||||
|
onClick={() => setNewRootDialog(null)}
|
||||||
|
>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={createRegionMut.isPending || !String(newRootDialog.name || "").trim()}
|
||||||
|
onClick={submitNewRoot}
|
||||||
|
>
|
||||||
|
{createRegionMut.isPending ? "…" : t("topology.newRegion")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{createNeDialog && canvasMode ? (
|
{createNeDialog && canvasMode ? (
|
||||||
<div
|
<div
|
||||||
className="topo-modal"
|
className="topo-modal"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue