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:
oliver 2026-08-07 09:18:13 +08:00
parent c4911b6ddc
commit 990798987b
8 changed files with 246 additions and 61 deletions

View file

@ -75,11 +75,8 @@ function LegacyPortTrafficWallRedirect() {
}
function PageFallback() {
return (
<div className="page-loading" role="status" aria-live="polite">
Loading…
</div>
);
// Delayed fade-in so fast chunk loads do not flash a full-page "Loading…".
return <div className="page-loading page-loading--soft" role="status" aria-busy="true" />;
}
function ProtectedApp() {

View file

@ -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",

View file

@ -1385,6 +1385,8 @@ const zh = {
maps: "拓扑图",
tree: "拓扑树",
treeLoading: "加载拓扑树…",
treeLoadFailed: "拓扑树加载失败",
treeRetry: "重试",
graphLoading: "正在加载拓扑图…",
graphRefreshing: "刷新中…",
canvasEmpty: "这张图上还没有网元",

View file

@ -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 {

View file

@ -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;

View file

@ -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(
<StrictMode>

View file

@ -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<TopologyDiscoverOut | null>(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() {
</div>
{!treeRoot ? (
<p className="panel__hint topo-tree-status" role="status">
{treeQuery.isLoading ? (
{treeLoading ? (
<>
<span className="topo-loading-spinner" aria-hidden="true" />
{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")
)}
@ -4456,11 +4481,32 @@ export function TopologyPage() {
</button>
</div>
{!hexBrowseRegion ? (
treeQuery.isLoading ? (
treeLoading ? (
<div className="topo-browser__empty topo-browser__empty--loading" role="status">
<span className="topo-loading-spinner" aria-hidden="true" />
<p>{t("topology.treeLoading")}</p>
</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 ? (
<div className="topo-browser__empty">
<span className="topo-browser__empty-icon" aria-hidden="true">
@ -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")}
</button>
@ -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")}
>
<span className="topo-region-hex__plus" aria-hidden="true">
@ -5350,11 +5396,44 @@ export function TopologyPage() {
{!isWorldFlatCanvas ? <MiniMap pannable zoomable /> : null}
</ReactFlow>
</TopoDisplayContext.Provider>
) : (
) : treeLoading ? (
<div className="topo-canvas__empty topo-canvas__status" role="status">
<span className="topo-loading-spinner" aria-hidden="true" />
<p>{t("topology.treeLoading")}</p>
</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 ? (
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
@ -5378,6 +5457,62 @@ export function TopologyPage() {
)}
</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 ? (
<div
className="topo-modal"