mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Improve topology UX, traffic discover logs, and CLI wrap parsing.
Paginate LLDP job items, surface port-traffic raw logs with a 300s default, harden topology discover/edit/zoom/fullscreen, and join mid-token CLI wraps before TextFSM. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
66f26b738d
commit
c13ab48ba7
22 changed files with 693 additions and 75 deletions
|
|
@ -66,7 +66,7 @@ export const queryKeys = {
|
|||
lldpCollectJobsAll: ["lldpCollectJobs"] as const,
|
||||
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
|
||||
lldpCollectJobAll: ["lldpCollectJob"] as const,
|
||||
lldpCollectJob: (jobId: string) => ["lldpCollectJob", jobId] as const,
|
||||
lldpCollectJob: (jobId: string, page = 1) => ["lldpCollectJob", jobId, page] as const,
|
||||
fabricEdgesAll: ["fabricEdges"] as const,
|
||||
fabricEdges: (status: string, keyword: string, page: number) =>
|
||||
["fabricEdges", status, keyword, page] as const,
|
||||
|
|
|
|||
|
|
@ -159,6 +159,11 @@ const en = {
|
|||
selectedPorts: "{{count}} interface(s) selected",
|
||||
pickNeFirst: "Select one NE first",
|
||||
discover: "Discover interfaces",
|
||||
discoverDetail: "Discover detail",
|
||||
discoverCommand: "Command",
|
||||
discoverRaw: "Raw log",
|
||||
discoverOk: "Discover succeeded",
|
||||
discoverFail: "Discover failed",
|
||||
confirmCreate: "Start monitoring",
|
||||
wallTask: "Task",
|
||||
wallPort: "Interface",
|
||||
|
|
@ -1344,6 +1349,8 @@ const en = {
|
|||
alignNeedSelection: "Select at least two nodes",
|
||||
snapGrid: "Snap to grid",
|
||||
autoLayoutDiscover: "Auto-layout after discover",
|
||||
discoverAutoAddUnmatched: "Auto-add unmatched NEs to fabric on discover",
|
||||
discoverProjectNeighbors: "Project direct neighbors onto canvas after discover",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
discoverSelected: "Discover selected ({{count}})",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,11 @@ const zh = {
|
|||
selectedPorts: "已选 {{count}} 个接口",
|
||||
pickNeFirst: "请先选择一台网元",
|
||||
discover: "拉取接口",
|
||||
discoverDetail: "拉取详情",
|
||||
discoverCommand: "命令",
|
||||
discoverRaw: "原始日志",
|
||||
discoverOk: "拉取成功",
|
||||
discoverFail: "拉取失败",
|
||||
confirmCreate: "开始监控",
|
||||
wallTask: "任务",
|
||||
wallDevice: "设备",
|
||||
|
|
@ -1338,6 +1343,8 @@ const zh = {
|
|||
alignNeedSelection: "请至少选中两个节点",
|
||||
snapGrid: "网格吸附",
|
||||
autoLayoutDiscover: "发现后自动布局",
|
||||
discoverAutoAddUnmatched: "发现时自动入库未匹配网元",
|
||||
discoverProjectNeighbors: "发现后将直连邻居加入画布",
|
||||
undo: "撤销",
|
||||
redo: "重做",
|
||||
discoverSelected: "发现选中 ({{count}})",
|
||||
|
|
|
|||
|
|
@ -6181,6 +6181,50 @@ pre {
|
|||
max-height: 14px;
|
||||
}
|
||||
|
||||
.topo-fs-toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.topo-fs-toolbar__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topo-fs-toolbar__btn:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.topo-fs-toolbar__btn.is-active {
|
||||
border-color: #0ea5e9;
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
.topo-fs-toolbar__btn kbd {
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.topo-canvas__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
|
|
|||
|
|
@ -422,25 +422,35 @@ type EdgeDefaults = Record<EdgeSourceKind, EdgeDefaultStyle>;
|
|||
|
||||
const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
|
||||
const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
|
||||
const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched";
|
||||
const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors";
|
||||
|
||||
function loadAutoLayoutAfterDiscover(): boolean {
|
||||
function loadBoolFlag(key: string, defaultValue: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTO_LAYOUT_DISCOVER_KEY);
|
||||
if (raw === null) return false;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return defaultValue;
|
||||
return raw === "1" || raw === "true";
|
||||
} catch {
|
||||
return false;
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function persistAutoLayoutAfterDiscover(value: boolean) {
|
||||
function persistBoolFlag(key: string, value: boolean) {
|
||||
try {
|
||||
localStorage.setItem(AUTO_LAYOUT_DISCOVER_KEY, value ? "1" : "0");
|
||||
localStorage.setItem(key, value ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function loadAutoLayoutAfterDiscover(): boolean {
|
||||
return loadBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, false);
|
||||
}
|
||||
|
||||
function persistAutoLayoutAfterDiscover(value: boolean) {
|
||||
persistBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, value);
|
||||
}
|
||||
|
||||
const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
|
||||
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
|
||||
discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" },
|
||||
|
|
@ -592,11 +602,18 @@ function applyViewGraph(
|
|||
defaults: EdgeDefaults,
|
||||
setNodes: (ns: Node<NeNodeData>[]) => void,
|
||||
setEdges: (es: Edge[]) => void,
|
||||
localPositions?: Map<string, { x: number; y: number }>,
|
||||
) {
|
||||
const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults);
|
||||
setNodes(rfNodes);
|
||||
const merged = localPositions?.size
|
||||
? rfNodes.map((n) => {
|
||||
const p = localPositions.get(n.id);
|
||||
return p ? { ...n, position: { ...p } } : n;
|
||||
})
|
||||
: rfNodes;
|
||||
setNodes(merged);
|
||||
setEdges(rfEdges);
|
||||
return { rfNodes, rfEdges };
|
||||
return { rfNodes: merged, rfEdges };
|
||||
}
|
||||
|
||||
export function TopologyPage() {
|
||||
|
|
@ -636,6 +653,12 @@ export function TopologyPage() {
|
|||
const [toolMode, setToolMode] = useState<ToolMode>("select");
|
||||
const [snapToGrid, setSnapToGrid] = useState(true);
|
||||
const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover);
|
||||
const [discoverAutoAddUnmatched, setDiscoverAutoAddUnmatched] = useState(() =>
|
||||
loadBoolFlag(DISCOVER_AUTO_ADD_KEY, false),
|
||||
);
|
||||
const [discoverProjectNeighbors, setDiscoverProjectNeighbors] = useState(() =>
|
||||
loadBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, false),
|
||||
);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [addNeOpen, setAddNeOpen] = useState(false);
|
||||
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||
|
|
@ -668,6 +691,7 @@ export function TopologyPage() {
|
|||
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
const appliedMapIdRef = useRef("");
|
||||
const historyRef = useRef<HistorySnap[]>([]);
|
||||
const redoRef = useRef<HistorySnap[]>([]);
|
||||
const historyLockRef = useRef(false);
|
||||
|
|
@ -889,9 +913,16 @@ export function TopologyPage() {
|
|||
setSearchParams({}, { replace: true });
|
||||
}, [regions, searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
appliedMapIdRef.current = "";
|
||||
}, [mapId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasMode) return;
|
||||
if (!graphQuery.data) return;
|
||||
if (!mapId || !graphQuery.data) return;
|
||||
// Only hydrate React Flow from server when entering a map — never clobber unsaved local positions.
|
||||
if (appliedMapIdRef.current === mapId) return;
|
||||
appliedMapIdRef.current = mapId;
|
||||
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults);
|
||||
historyLockRef.current = true;
|
||||
setNodes(rfNodes);
|
||||
|
|
@ -1199,7 +1230,7 @@ export function TopologyPage() {
|
|||
scope: "ne_ids",
|
||||
ne_ids,
|
||||
concurrency: 4,
|
||||
auto_add_unmatched: true,
|
||||
auto_add_unmatched: discoverAutoAddUnmatched,
|
||||
trigger_mode: "topology",
|
||||
});
|
||||
let job: TopologyDiscoverJob = jobStart;
|
||||
|
|
@ -1221,9 +1252,18 @@ export function TopologyPage() {
|
|||
if (job.status === "failed") {
|
||||
throw new Error(job.error || "discover_failed");
|
||||
}
|
||||
const projected = await projectTopologyNeighbors(mapId);
|
||||
const projected = discoverProjectNeighbors
|
||||
? await projectTopologyNeighbors(mapId)
|
||||
: await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
|
||||
appliedMapIdRef.current = mapId;
|
||||
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
|
||||
// Keep existing node positions when we did not auto-layout.
|
||||
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
||||
rfNodes = rfNodes.map((n) => {
|
||||
const p = localPos.get(n.id);
|
||||
return p ? { ...n, position: { ...p } } : n;
|
||||
});
|
||||
let didAutoLayout = false;
|
||||
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
||||
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||
|
|
@ -1271,7 +1311,7 @@ export function TopologyPage() {
|
|||
setDiscovering(false);
|
||||
}
|
||||
},
|
||||
[mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults, clearDirty, markDirty],
|
||||
[mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty],
|
||||
);
|
||||
|
||||
const discoverResults = discoverReport?.results?.length
|
||||
|
|
@ -1580,10 +1620,16 @@ export function TopologyPage() {
|
|||
pushHistory();
|
||||
try {
|
||||
if (nodeIds.length) {
|
||||
if (dirtyRef.current) {
|
||||
await patchTopologyPositions(mapId, flowToPositions(nodes));
|
||||
clearDirty();
|
||||
}
|
||||
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
||||
const graph = await removeTopologyViewNodes(mapId, nodeIds);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
appliedMapIdRef.current = mapId;
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
} else if (edgeIds.size) {
|
||||
|
|
@ -1670,10 +1716,16 @@ export function TopologyPage() {
|
|||
pushHistory();
|
||||
closeCtxMenu();
|
||||
try {
|
||||
if (dirtyRef.current) {
|
||||
await patchTopologyPositions(mapId, flowToPositions(nodes));
|
||||
clearDirty();
|
||||
}
|
||||
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
||||
const graph = await removeTopologyViewNodes(mapId, [nodeId]);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
appliedMapIdRef.current = mapId;
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
} catch (err) {
|
||||
|
|
@ -2473,7 +2525,6 @@ export function TopologyPage() {
|
|||
[
|
||||
["select", t("topology.toolSelect"), "V"],
|
||||
["pan", t("topology.toolPan"), "H"],
|
||||
["drag", t("topology.toolDrag"), "A"],
|
||||
["connect", t("topology.toolConnect"), "C"],
|
||||
] as const
|
||||
).map(([mode, label, key]) => (
|
||||
|
|
@ -2544,6 +2595,30 @@ export function TopologyPage() {
|
|||
/>
|
||||
{t("topology.autoLayoutDiscover")}
|
||||
</label>
|
||||
<label className="topo-display-toggles__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={discoverAutoAddUnmatched}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked;
|
||||
setDiscoverAutoAddUnmatched(next);
|
||||
persistBoolFlag(DISCOVER_AUTO_ADD_KEY, next);
|
||||
}}
|
||||
/>
|
||||
{t("topology.discoverAutoAddUnmatched")}
|
||||
</label>
|
||||
<label className="topo-display-toggles__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={discoverProjectNeighbors}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked;
|
||||
setDiscoverProjectNeighbors(next);
|
||||
persistBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, next);
|
||||
}}
|
||||
/>
|
||||
{t("topology.discoverProjectNeighbors")}
|
||||
</label>
|
||||
<div className="topo-display-defaults">
|
||||
<div className="topo-display-defaults__head">
|
||||
<strong>{t("topology.edgeDefaults")}</strong>
|
||||
|
|
@ -2955,6 +3030,48 @@ export function TopologyPage() {
|
|||
onDragOver={onCanvasDragOver}
|
||||
onDrop={onCanvasDrop}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<div className="topo-fs-toolbar" role="toolbar" aria-label={t("topology.toolModes")}>
|
||||
{(
|
||||
[
|
||||
["select", t("topology.toolSelect"), "V"],
|
||||
["pan", t("topology.toolPan"), "H"],
|
||||
["connect", t("topology.toolConnect"), "C"],
|
||||
] as const
|
||||
).map(([mode, label, key]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`topo-fs-toolbar__btn${toolMode === mode ? " is-active" : ""}`}
|
||||
title={`${label} (${key})`}
|
||||
aria-pressed={toolMode === mode}
|
||||
onClick={() => {
|
||||
setToolMode(mode);
|
||||
connectClickRef.current = null;
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<kbd>{key}</kbd>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="topo-fs-toolbar__btn"
|
||||
title={t("topology.fit")}
|
||||
onClick={() => rfRef.current?.fitView({ padding: 0.2 })}
|
||||
>
|
||||
{t("topology.fit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-fs-toolbar__btn"
|
||||
title={t("topology.exitFullscreen")}
|
||||
onClick={() => void toggleFullscreen()}
|
||||
>
|
||||
{t("topology.exitFullscreen")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot ? (
|
||||
<TopoDisplayContext.Provider value={displayOpts}>
|
||||
<ReactFlow
|
||||
|
|
@ -2968,6 +3085,8 @@ export function TopologyPage() {
|
|||
connectionMode={ConnectionMode.Loose}
|
||||
defaultEdgeOptions={{ type: "straight" }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
minZoom={0.05}
|
||||
maxZoom={4}
|
||||
nodesDraggable={toolBehavior.nodesDraggable}
|
||||
nodesConnectable={toolBehavior.nodesConnectable}
|
||||
elementsSelectable={toolBehavior.elementsSelectable}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ export function LldpLinksPage() {
|
|||
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
const [itemPage, setItemPage] = useState(1);
|
||||
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
|
||||
const ITEM_PAGE_SIZE = 20;
|
||||
|
||||
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
|
||||
const [edgeKeyword, setEdgeKeyword] = useState("");
|
||||
|
|
@ -90,8 +92,8 @@ export function LldpLinksPage() {
|
|||
});
|
||||
|
||||
const jobDetailQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJob(expandedJobId),
|
||||
queryFn: () => fetchLldpCollectJob(expandedJobId),
|
||||
queryKey: queryKeys.lldpCollectJob(expandedJobId, itemPage),
|
||||
queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: ITEM_PAGE_SIZE }),
|
||||
enabled: Boolean(expandedJobId),
|
||||
staleTime: 800,
|
||||
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
|
||||
|
|
@ -193,6 +195,8 @@ export function LldpLinksPage() {
|
|||
const jobPages = pageCount(jobTotal, 10);
|
||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||
const detailItems = jobDetailQuery.data?.items ?? [];
|
||||
const itemTotal = Number(jobDetailQuery.data?.items_total ?? detailItems.length);
|
||||
const itemPages = pageCount(itemTotal, ITEM_PAGE_SIZE);
|
||||
const edgeItems = edgesQuery.data?.items ?? [];
|
||||
const edgeTotal = Number(edgesQuery.data?.total || 0);
|
||||
const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE);
|
||||
|
|
@ -546,6 +550,7 @@ export function LldpLinksPage() {
|
|||
onClick={() => {
|
||||
setItemDetail(null);
|
||||
setExpandedJobId(open ? "" : job.id);
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
{open ? "−" : "+"}
|
||||
|
|
@ -653,6 +658,25 @@ export function LldpLinksPage() {
|
|||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(itemTotal),
|
||||
page: String(itemPage),
|
||||
pages: String(itemPages),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={itemPage >= itemPages}
|
||||
onClick={() => setItemPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export function PortTrafficPage() {
|
|||
// Wizard / edit
|
||||
const [editDeviceId, setEditDeviceId] = useState("");
|
||||
const [editDeviceSnap, setEditDeviceSnap] = useState<PortTrafficDevice | null>(null);
|
||||
const [intervalSec, setIntervalSec] = useState(60);
|
||||
const [intervalSec, setIntervalSec] = useState(300);
|
||||
const [retentionDays, setRetentionDays] = useState(7);
|
||||
const [concurrency, setConcurrency] = useState(1);
|
||||
const [note, setNote] = useState("");
|
||||
|
|
@ -137,6 +137,12 @@ export function PortTrafficPage() {
|
|||
const [ports, setPorts] = useState<PortTrafficDiscoverPort[]>([]);
|
||||
const [portsLoading, setPortsLoading] = useState(false);
|
||||
const [portsError, setPortsError] = useState("");
|
||||
const [discoverMeta, setDiscoverMeta] = useState<{
|
||||
ok: boolean;
|
||||
command: string;
|
||||
raw_preview: string;
|
||||
error: string;
|
||||
} | null>(null);
|
||||
const [pickedIfnames, setPickedIfnames] = useState<Record<string, PortTrafficIfaceIn>>({});
|
||||
const [deepLinkHint, setDeepLinkHint] = useState("");
|
||||
const [deepLinkSource, setDeepLinkSource] = useState<"managed" | "ume" | "">("");
|
||||
|
|
@ -366,7 +372,7 @@ export function PortTrafficPage() {
|
|||
|
||||
const resetWizard = () => {
|
||||
setWizardStep(1);
|
||||
setIntervalSec(60);
|
||||
setIntervalSec(300);
|
||||
setRetentionDays(7);
|
||||
setConcurrency(1);
|
||||
setNote("");
|
||||
|
|
@ -374,6 +380,7 @@ export function PortTrafficPage() {
|
|||
setSelectedNe(null);
|
||||
setPorts([]);
|
||||
setPortsError("");
|
||||
setDiscoverMeta(null);
|
||||
setPickedIfnames({});
|
||||
setNeKeyword("");
|
||||
setNePage(1);
|
||||
|
|
@ -404,6 +411,7 @@ export function PortTrafficPage() {
|
|||
setPickedIfnames({});
|
||||
setPorts([]);
|
||||
setPortsError("");
|
||||
setDiscoverMeta(null);
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
|
|
@ -434,11 +442,23 @@ export function PortTrafficPage() {
|
|||
if (!id) return;
|
||||
setPortsLoading(true);
|
||||
setPortsError("");
|
||||
setDiscoverMeta(null);
|
||||
try {
|
||||
const res = await discoverPortTrafficPorts({ source, id });
|
||||
setPorts(res.ports);
|
||||
setPorts(res.ports || []);
|
||||
setDiscoverMeta({
|
||||
ok: res.ok !== false,
|
||||
command: res.command || "",
|
||||
raw_preview: res.raw_preview || "",
|
||||
error: res.error || "",
|
||||
});
|
||||
if (res.ok === false) {
|
||||
const msg = res.error || t("portTraffic.discoverFail");
|
||||
setPortsError(msg);
|
||||
showError(msg);
|
||||
}
|
||||
if (deepLinkIfname) {
|
||||
const hit = res.ports.find((p) => p.ifname === deepLinkIfname);
|
||||
const hit = (res.ports || []).find((p) => p.ifname === deepLinkIfname);
|
||||
if (hit) {
|
||||
setPickedIfnames((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -455,6 +475,12 @@ export function PortTrafficPage() {
|
|||
const msg = e instanceof Error ? e.message : "discover_failed";
|
||||
setPorts([]);
|
||||
setPortsError(msg);
|
||||
setDiscoverMeta({
|
||||
ok: false,
|
||||
command: "",
|
||||
raw_preview: "",
|
||||
error: msg,
|
||||
});
|
||||
showError(msg);
|
||||
} finally {
|
||||
setPortsLoading(false);
|
||||
|
|
@ -841,6 +867,29 @@ export function PortTrafficPage() {
|
|||
</button>
|
||||
{portsError ? <span className="muted">{portsError}</span> : null}
|
||||
</div>
|
||||
{discoverMeta ? (
|
||||
<details className="panel" open style={{ marginBottom: 12 }}>
|
||||
<summary>
|
||||
{t("portTraffic.discoverDetail")}
|
||||
{" · "}
|
||||
{discoverMeta.ok ? t("portTraffic.discoverOk") : t("portTraffic.discoverFail")}
|
||||
</summary>
|
||||
{discoverMeta.command ? (
|
||||
<p className="muted">
|
||||
{t("portTraffic.discoverCommand")}: <code>{discoverMeta.command}</code>
|
||||
</p>
|
||||
) : null}
|
||||
{discoverMeta.error ? (
|
||||
<p className="muted" style={{ color: "var(--danger, #b00)" }}>
|
||||
{discoverMeta.error}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="muted">{t("portTraffic.discoverRaw")}</p>
|
||||
<pre className="topo-discover-modal__raw" style={{ maxHeight: 240, overflow: "auto" }}>
|
||||
{discoverMeta.raw_preview || "—"}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{ports.length ? (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
|
|
@ -894,7 +943,7 @@ export function PortTrafficPage() {
|
|||
min={15}
|
||||
max={3600}
|
||||
value={intervalSec}
|
||||
onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
|
||||
onChange={(e) => setIntervalSec(Number(e.target.value) || 300)}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
|
|
@ -1005,7 +1054,7 @@ export function PortTrafficPage() {
|
|||
min={15}
|
||||
max={3600}
|
||||
value={intervalSec}
|
||||
onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
|
||||
onChange={(e) => setIntervalSec(Number(e.target.value) || 300)}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
|
|
@ -1040,6 +1089,29 @@ export function PortTrafficPage() {
|
|||
<span className="muted">{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</span>
|
||||
</div>
|
||||
{portsError ? <p className="muted">{portsError}</p> : null}
|
||||
{discoverMeta ? (
|
||||
<details className="panel" open style={{ marginBottom: 12 }}>
|
||||
<summary>
|
||||
{t("portTraffic.discoverDetail")}
|
||||
{" · "}
|
||||
{discoverMeta.ok ? t("portTraffic.discoverOk") : t("portTraffic.discoverFail")}
|
||||
</summary>
|
||||
{discoverMeta.command ? (
|
||||
<p className="muted">
|
||||
{t("portTraffic.discoverCommand")}: <code>{discoverMeta.command}</code>
|
||||
</p>
|
||||
) : null}
|
||||
{discoverMeta.error ? (
|
||||
<p className="muted" style={{ color: "var(--danger, #b00)" }}>
|
||||
{discoverMeta.error}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="muted">{t("portTraffic.discoverRaw")}</p>
|
||||
<pre className="topo-discover-modal__raw" style={{ maxHeight: 240, overflow: "auto" }}>
|
||||
{discoverMeta.raw_preview || "—"}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{ports.length ? (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/** Topology editor interaction modes (NMS-style). */
|
||||
|
||||
export type ToolMode = "select" | "pan" | "drag" | "connect";
|
||||
export type ToolMode = "select" | "pan" | "connect";
|
||||
|
||||
export type ToolModeBehavior = {
|
||||
nodesDraggable: boolean;
|
||||
|
|
@ -23,15 +23,6 @@ export function behaviorForMode(mode: ToolMode): ToolModeBehavior {
|
|||
selectionOnDrag: false,
|
||||
panOnScroll: true,
|
||||
};
|
||||
case "drag":
|
||||
return {
|
||||
nodesDraggable: true,
|
||||
nodesConnectable: false,
|
||||
elementsSelectable: true,
|
||||
panOnDrag: [1, 2],
|
||||
selectionOnDrag: false,
|
||||
panOnScroll: true,
|
||||
};
|
||||
case "connect":
|
||||
return {
|
||||
nodesDraggable: false,
|
||||
|
|
@ -58,7 +49,6 @@ export function toolModeFromKey(key: string): ToolMode | null {
|
|||
const k = key.toLowerCase();
|
||||
if (k === "v") return "select";
|
||||
if (k === "h") return "pan";
|
||||
if (k === "a") return "drag";
|
||||
if (k === "c") return "connect";
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,8 +1153,17 @@ export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number
|
|||
);
|
||||
};
|
||||
|
||||
export const fetchLldpCollectJob = (jobId: string) =>
|
||||
apiGet<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}`);
|
||||
export const fetchLldpCollectJob = (
|
||||
jobId: string,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params?.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20)))));
|
||||
return apiGet<TopologyDiscoverJob>(
|
||||
`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const createFabricManualEdge = (body: {
|
||||
a_node_id: string;
|
||||
|
|
|
|||
|
|
@ -590,6 +590,9 @@ export type TopologyDiscoverJob = {
|
|||
ended_at?: string | null;
|
||||
created_at?: string | null;
|
||||
items: TopologyDiscoverJobItem[];
|
||||
items_total?: number;
|
||||
items_page?: number;
|
||||
items_page_size?: number;
|
||||
};
|
||||
|
||||
/** @deprecated alias — prefer TopologyViewItem */
|
||||
|
|
@ -887,6 +890,10 @@ export type PortTrafficDiscoverResponse = {
|
|||
vendor: string;
|
||||
vendor_key: string;
|
||||
ports: PortTrafficDiscoverPort[];
|
||||
ok?: boolean;
|
||||
command?: string;
|
||||
raw_preview?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type PortTrafficSamplePoint = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue