mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +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
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1385,6 +1385,8 @@ const zh = {
|
|||
maps: "拓扑图",
|
||||
tree: "拓扑树",
|
||||
treeLoading: "加载拓扑树…",
|
||||
treeLoadFailed: "拓扑树加载失败",
|
||||
treeRetry: "重试",
|
||||
graphLoading: "正在加载拓扑图…",
|
||||
graphRefreshing: "刷新中…",
|
||||
canvasEmpty: "这张图上还没有网元",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue