Ship gated eye polish, fabric levels, and collection UI refresh.

Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 16:13:05 +08:00
parent b1aee86701
commit b81e5869a6
91 changed files with 10395 additions and 1513 deletions

View file

@ -291,6 +291,7 @@ export type TopologyReactFlowViewProps = {
setCanvasZoom: (z: number) => void;
scheduleFlatViewportRefresh: () => void;
toggleFullscreen: () => void | Promise<void>;
readOnly?: boolean;
};
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {

View file

@ -8,6 +8,7 @@ import type { NeNodeData } from "./TopologyReactFlowView";
import type { ToolMode } from "./toolMode";
export type TopologyToolbarProps = {
readOnly?: boolean;
breadcrumbFolders: TopologyTreeFolderItem[];
activeView: TopologyTreeViewItem | null | undefined;
activeRegion: TopologyTreeFolderItem | null | undefined;
@ -51,6 +52,7 @@ export type TopologyToolbarProps = {
};
export function TopologyToolbar({
readOnly = false,
breadcrumbFolders,
activeView,
activeRegion,
@ -186,8 +188,14 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm"
disabled={isWorldFlatCanvas}
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={onAddNe}
>
{t("topology.addNe")}
@ -195,8 +203,14 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm"
disabled={isWorldFlatCanvas}
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={handleCreateNe}
>
{t("topology.createNe")}
@ -207,7 +221,7 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!canUndo}
disabled={readOnly || !canUndo}
onClick={onUndo}
title="Ctrl+Z"
>
@ -216,7 +230,7 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!canRedo}
disabled={readOnly || !canRedo}
onClick={onRedo}
title="Ctrl+Y"
>
@ -225,9 +239,9 @@ export function TopologyToolbar({
<button
type="button"
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
disabled={savePending || !dirty}
disabled={readOnly || savePending || !dirty}
onClick={onSave}
title="Ctrl+S"
title={readOnly ? t("topology.readOnlyHint") : "Ctrl+S"}
>
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
</button>
@ -265,8 +279,8 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!staleEdgeCount}
title={t("topology.removeStaleHint")}
disabled={readOnly || !staleEdgeCount}
title={readOnly ? t("topology.readOnlyHint") : t("topology.removeStaleHint")}
onClick={() => void onRemoveStale()}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
@ -286,8 +300,9 @@ export function TopologyToolbar({
key={mode}
type="button"
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
title={`${label} (${key})`}
title={`${label} (${key})${readOnly && mode === "connect" ? ` — ${t("topology.readOnlyHint")}` : ""}`}
aria-pressed={toolMode === mode}
disabled={readOnly && mode === "connect"}
onClick={() => {
onToolModeChange(mode);
onConnectClickReset();

View file

@ -1,9 +1,10 @@
import type { RefObject } from "react";
import { createPortal } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
import type { RefObject } from "react";
import type { Node } from "@xyflow/react";
import { createPortal } from "react-dom";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { updateLldpCollectPolicy } from "../../services/api";
import {
DEFAULT_CANVAS_BG,
@ -87,6 +88,7 @@ export type TopologyViewToolsProps = {
export function TopologyViewTools(props: TopologyViewToolsProps) {
const { t } = useI18n();
const { showError } = useToast();
const queryClient = useQueryClient();
const {
fullscreen,
@ -225,7 +227,7 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
void queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard });
})
.catch(() => {
/* local toggle still applies to this canvas discover */
showError(t("topology.policySyncFailed"));
});
}}
/>

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
import { fetchTopologyGraph } from "../../../services/api";
import { fetchTopologyGraph, formatErr } from "../../../services/api";
import { queryKeys } from "../../../constants/queryKeys";
import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types";
import {
@ -493,6 +493,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
const showWorldScatter =
isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0;
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
const canvasGraphError = Boolean(mapId) && graphQuery.isError;
const canvasGraphErrorMsg = graphQuery.error ? formatErr(graphQuery.error) : "";
const canvasGraphEmpty =
Boolean(mapId) &&
graphQuery.isSuccess &&
@ -551,6 +553,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
worldDockMe,
showWorldScatter,
canvasGraphLoading,
canvasGraphError,
canvasGraphErrorMsg,
canvasGraphEmpty,
worldNeedsApply,
canvasGraphRefreshing,

View file

@ -13,6 +13,7 @@ import {
removeTopologyViewNodes,
startLldpDiscover,
stopLldpCollectJob,
formatErr,
} from "../../../services/api";
import type {
TopologyDiscoverJob,
@ -181,6 +182,11 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
setDiscoverJobId(jobStart.id);
let job: TopologyDiscoverJob = jobStart;
let cancelled = false;
const isTerminalStatus = (status: string) =>
status === "done" ||
status === "failed" ||
status === "cancelled" ||
status === "stopped";
for (let i = 0; i < 600; i++) {
if (discoverAbortRef.current) {
cancelled = true;
@ -202,12 +208,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
}));
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
if (
job.status === "done" ||
job.status === "failed" ||
job.status === "cancelled" ||
job.status === "stopped"
) {
if (isTerminalStatus(job.status)) {
if (job.status === "cancelled" || job.status === "stopped") cancelled = true;
break;
}
@ -227,6 +228,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
});
return;
}
if (!isTerminalStatus(job.status)) {
throw new Error(t("topology.discoverTimeout"));
}
if (job.status === "failed") {
throw new Error(job.error || "discover_failed");
}
@ -293,8 +297,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
if (discoverAbortRef.current) {
setDiscoverError(t("topology.discoverCancelled"));
} else {
setDiscoverError(String(err));
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
const detail = formatErr(err);
setDiscoverError(detail);
showError(t("topology.discoverFail").replace("{{detail}}", detail));
}
} finally {
setDiscovering(false);