mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 05:43:20 +08:00
Add LLDP collect page with conservative fabric edge lifecycle.
Move scheduled discovery under Network → LLDP links, mark absent edges missing after one successful scan, purge only after four miss cycles, and expose unmatched/raw job detail. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9674de221d
commit
d05ba0f1f7
30 changed files with 4713 additions and 1870 deletions
|
|
@ -119,9 +119,25 @@ export function ConfigSyncPage() {
|
|||
history_keep: historyKeep,
|
||||
selected_targets: Object.values(selectedMap),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (saved) => {
|
||||
// Apply server response immediately — do NOT flip policyHydrated false then
|
||||
// rehydrate from a possibly-stale dashboard cache (checkbox "pops back" bug).
|
||||
setEnabled(Boolean(saved.enabled));
|
||||
setIntervalDays(Number(saved.interval_days || 3));
|
||||
setConcurrency(Number(saved.concurrency || 5));
|
||||
setScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
|
||||
setHistoryKeep(Number(saved.history_keep ?? 3));
|
||||
const map: Record<string, ConfigSyncTargetRef> = {};
|
||||
for (const ref of saved.selected_targets || []) {
|
||||
map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id };
|
||||
}
|
||||
setSelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
queryClient.setQueryData(queryKeys.configSyncDashboard, (prev: unknown) => {
|
||||
if (!prev || typeof prev !== "object") return prev;
|
||||
return { ...(prev as object), policy: saved };
|
||||
});
|
||||
showOk(t("configSync.policySaved"));
|
||||
setPolicyHydrated(false);
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ReactFlow,
|
||||
|
|
@ -23,16 +23,23 @@ import {
|
|||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
addTopologyViewNodes,
|
||||
createFabricManualEdge,
|
||||
createTopologyMap,
|
||||
deleteTopologyMap,
|
||||
discoverTopologyNeighborsStream,
|
||||
fetchLldpDiscoverJob,
|
||||
fetchManagedNe,
|
||||
fetchTopologyGraph,
|
||||
fetchTopologyMaps,
|
||||
fetchUmeNe,
|
||||
putTopologyGraph,
|
||||
patchTopologyEdgeStyle,
|
||||
patchTopologyPositions,
|
||||
projectTopologyNeighbors,
|
||||
removeTopologyViewNodes,
|
||||
startLldpDiscover,
|
||||
updateTopologyMap,
|
||||
} from "../services/api";
|
||||
import type { TopologyDiscoverJob, TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem } from "../types";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { HelpHint } from "../components/HelpHint";
|
||||
import { useI18n } from "../i18n";
|
||||
|
|
@ -399,27 +406,32 @@ function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge {
|
|||
return { ...edge, style, markerEnd: edgeMarker(style.stroke) };
|
||||
}
|
||||
|
||||
function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defaults: EdgeDefaults) {
|
||||
function graphToFlow(
|
||||
nodes: TopologyViewNodeItem[],
|
||||
edges: TopologyViewEdgeItem[],
|
||||
defaults: EdgeDefaults,
|
||||
) {
|
||||
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
id: n.fabric_node_id,
|
||||
type: "neNode",
|
||||
position: { x: n.x || 0, y: n.y || 0 },
|
||||
data: {
|
||||
label: n.label || n.ne_name || n.ne_ip || n.id,
|
||||
label: n.label || n.name || n.ip || n.fabric_node_id,
|
||||
managed_ne_id: n.managed_ne_id || "",
|
||||
ume_ne_id: n.ume_ne_id || "",
|
||||
ne_ip: n.ne_ip || "",
|
||||
ne_ip: n.ip || "",
|
||||
vendor: n.vendor || "",
|
||||
connect_status: n.connect_status || "",
|
||||
},
|
||||
}));
|
||||
const rfEdges: Edge[] = edges.map((e) => {
|
||||
const src = e.source || "manual";
|
||||
const label = [e.source_port, e.target_port].filter(Boolean).join(SEP);
|
||||
const src =
|
||||
e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual";
|
||||
const label = [e.a_port, e.b_port].filter(Boolean).join(SEP);
|
||||
const data: EdgeStyleData = {
|
||||
source: src,
|
||||
source_port: e.source_port || "",
|
||||
target_port: e.target_port || "",
|
||||
source_port: e.a_port || "",
|
||||
target_port: e.b_port || "",
|
||||
stroke_color: e.stroke_color || "",
|
||||
stroke_width: Number(e.stroke_width || 0),
|
||||
line_style: e.line_style || "",
|
||||
|
|
@ -428,8 +440,8 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau
|
|||
return withEdgeVisual(
|
||||
{
|
||||
id: e.id,
|
||||
source: e.source_node_id,
|
||||
target: e.target_node_id,
|
||||
source: e.a_node_id,
|
||||
target: e.b_node_id,
|
||||
type: "straight",
|
||||
label: label || undefined,
|
||||
animated: false,
|
||||
|
|
@ -441,32 +453,25 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau
|
|||
return { rfNodes, rfEdges };
|
||||
}
|
||||
|
||||
function flowToGraphPayload(nodes: Node<NeNodeData>[], edges: Edge[]) {
|
||||
return {
|
||||
nodes: nodes.map((n) => ({
|
||||
id: n.id,
|
||||
managed_ne_id: n.data.managed_ne_id || "",
|
||||
ume_ne_id: n.data.ume_ne_id || "",
|
||||
label: n.data.label || "",
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
})),
|
||||
edges: edges.map((e) => {
|
||||
const data = (e.data || {}) as EdgeStyleData;
|
||||
return {
|
||||
id: e.id,
|
||||
source_node_id: e.source,
|
||||
target_node_id: e.target,
|
||||
source_port: String(data.source_port || ""),
|
||||
target_port: String(data.target_port || ""),
|
||||
source: String(data.source || "manual"),
|
||||
stroke_color: String(data.stroke_color || ""),
|
||||
stroke_width: Number(data.stroke_width || 0),
|
||||
line_style: String(data.line_style || ""),
|
||||
discovered_at: data.discovered_at ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
function flowToPositions(nodes: Node<NeNodeData>[]) {
|
||||
return nodes.map((n) => ({
|
||||
fabric_node_id: n.id,
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
label: n.data.label || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function applyViewGraph(
|
||||
graph: TopologyViewGraph,
|
||||
defaults: EdgeDefaults,
|
||||
setNodes: (ns: Node<NeNodeData>[]) => void,
|
||||
setEdges: (es: Edge[]) => void,
|
||||
) {
|
||||
const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults);
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
return { rfNodes, rfEdges };
|
||||
}
|
||||
|
||||
export function TopologyPage() {
|
||||
|
|
@ -731,7 +736,7 @@ export function TopologyPage() {
|
|||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 40);
|
||||
if (opts?.persist && mapId) {
|
||||
try {
|
||||
const graph = await putTopologyGraph(mapId, flowToGraphPayload(next, edges));
|
||||
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
|
||||
clearDirty();
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
} catch (err) {
|
||||
|
|
@ -804,7 +809,7 @@ export function TopologyPage() {
|
|||
});
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)),
|
||||
mutationFn: () => patchTopologyPositions(mapId, flowToPositions(nodes)),
|
||||
onSuccess: async (graph) => {
|
||||
clearDirty();
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
|
|
@ -817,10 +822,12 @@ export function TopologyPage() {
|
|||
const runDiscover = useCallback(
|
||||
async (neIds?: string[]) => {
|
||||
if (!mapId || discovering) return;
|
||||
// Topology canvas: single-NE (or explicit id list) only — bulk collect lives under Network → LLDP.
|
||||
const filterIds = (neIds || []).map((x) => String(x || "").trim()).filter(Boolean);
|
||||
discoverAbortRef.current?.abort();
|
||||
const ac = new AbortController();
|
||||
discoverAbortRef.current = ac;
|
||||
if (!filterIds.length) {
|
||||
showError(t("topology.discoverOneNeedNe"));
|
||||
return;
|
||||
}
|
||||
setDiscoverOpen(true);
|
||||
setDiscovering(true);
|
||||
setDiscoverReport(null);
|
||||
|
|
@ -830,13 +837,11 @@ export function TopologyPage() {
|
|||
setDiscoverDetail(null);
|
||||
setDiscoverListOpen(false);
|
||||
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
|
||||
const scoped = filterIds.length
|
||||
? scannable.filter(
|
||||
(n) =>
|
||||
filterIds.includes(String(n.data.managed_ne_id || "")) ||
|
||||
filterIds.includes(String(n.data.ume_ne_id || "")),
|
||||
)
|
||||
: scannable;
|
||||
const scoped = scannable.filter(
|
||||
(n) =>
|
||||
filterIds.includes(String(n.data.managed_ne_id || "")) ||
|
||||
filterIds.includes(String(n.data.ume_ne_id || "")),
|
||||
);
|
||||
setDiscoverProgress({
|
||||
index: 0,
|
||||
total: scoped.length,
|
||||
|
|
@ -847,71 +852,76 @@ export function TopologyPage() {
|
|||
});
|
||||
try {
|
||||
if (dirtyRef.current) {
|
||||
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
|
||||
await patchTopologyPositions(mapId, flowToPositions(nodes));
|
||||
clearDirty();
|
||||
}
|
||||
const out = await discoverTopologyNeighborsStream(
|
||||
mapId,
|
||||
{
|
||||
protocol: "auto",
|
||||
...(filterIds.length ? { ne_ids: filterIds } : {}),
|
||||
},
|
||||
{
|
||||
onStart: (ev) => {
|
||||
setDiscoverProgress((p) => ({ ...p, total: ev.total, index: 0 }));
|
||||
},
|
||||
onNeStart: (ev) => {
|
||||
setDiscoverProgress((p) => ({
|
||||
...p,
|
||||
index: ev.index,
|
||||
total: ev.total,
|
||||
neName: ev.ne_name || ev.ne_ip || ev.ne_id,
|
||||
neIp: ev.ne_ip,
|
||||
}));
|
||||
},
|
||||
onNeResult: (ev) => {
|
||||
if (ev.result) {
|
||||
setDiscoverLiveResults((prev) => [...prev, ev.result]);
|
||||
}
|
||||
setDiscoverProgress((p) => ({
|
||||
...p,
|
||||
index: ev.index,
|
||||
total: ev.total,
|
||||
edgesAdded: ev.edges_added,
|
||||
edgesUpdated: ev.edges_updated,
|
||||
}));
|
||||
},
|
||||
},
|
||||
ac.signal,
|
||||
);
|
||||
if (out.graph) {
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
|
||||
let { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges, edgeDefaults);
|
||||
let didAutoLayout = false;
|
||||
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
||||
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||
didAutoLayout = true;
|
||||
try {
|
||||
const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges));
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
clearDirty();
|
||||
} catch {
|
||||
markDirty();
|
||||
}
|
||||
} else {
|
||||
clearDirty();
|
||||
}
|
||||
historyLockRef.current = true;
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
historyLockRef.current = false;
|
||||
// Only refit when layout changed; otherwise keep the user's viewport.
|
||||
if (didAutoLayout) {
|
||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||
}
|
||||
const ne_ids = filterIds;
|
||||
if (!scoped.length) {
|
||||
throw new Error(t("topology.discoverOneNeedNe"));
|
||||
}
|
||||
const jobStart = await startLldpDiscover({
|
||||
scope: "ne_ids",
|
||||
ne_ids,
|
||||
concurrency: 4,
|
||||
auto_add_unmatched: true,
|
||||
trigger_mode: "topology",
|
||||
});
|
||||
let job: TopologyDiscoverJob = jobStart;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
await new Promise((r) => window.setTimeout(r, 500));
|
||||
job = await fetchLldpDiscoverJob(jobStart.id);
|
||||
setDiscoverProgress((p) => ({
|
||||
...p,
|
||||
index: job.done,
|
||||
total: job.total || p.total,
|
||||
edgesAdded: job.edges_added,
|
||||
edgesUpdated: job.edges_updated,
|
||||
neName: job.items?.[job.items.length - 1]?.ne_name || p.neName,
|
||||
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
|
||||
}));
|
||||
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
|
||||
if (job.status === "done" || job.status === "failed") break;
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
throw new Error(job.error || "discover_failed");
|
||||
}
|
||||
const projected = await projectTopologyNeighbors(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
|
||||
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
|
||||
let didAutoLayout = false;
|
||||
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
||||
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||
didAutoLayout = true;
|
||||
try {
|
||||
const graph = await patchTopologyPositions(mapId, flowToPositions(rfNodes));
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
clearDirty();
|
||||
} catch {
|
||||
markDirty();
|
||||
}
|
||||
} else {
|
||||
clearDirty();
|
||||
}
|
||||
historyLockRef.current = true;
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
historyLockRef.current = false;
|
||||
if (didAutoLayout) {
|
||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||
}
|
||||
const out: TopologyDiscoverOut = {
|
||||
map_id: mapId,
|
||||
protocol: "lldp",
|
||||
scanned: job.done,
|
||||
edges_added: job.edges_added,
|
||||
edges_updated: job.edges_updated,
|
||||
edges_stale: job.edges_stale,
|
||||
results: (job.items || []) as TopologyDiscoverNeResult[],
|
||||
graph: projected,
|
||||
};
|
||||
setDiscoverReport(out);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.fabricSummary });
|
||||
showOk(
|
||||
t("topology.discovered")
|
||||
.replace("{{added}}", String(out.edges_added))
|
||||
|
|
@ -919,15 +929,13 @@ export function TopologyPage() {
|
|||
.replace("{{stale}}", String(out.edges_stale || 0)),
|
||||
);
|
||||
} catch (err) {
|
||||
if (ac.signal.aborted) return;
|
||||
setDiscoverError(String(err));
|
||||
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
|
||||
} finally {
|
||||
if (discoverAbortRef.current === ac) discoverAbortRef.current = null;
|
||||
setDiscovering(false);
|
||||
}
|
||||
},
|
||||
[mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults],
|
||||
[mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults, clearDirty, markDirty],
|
||||
);
|
||||
|
||||
const discoverResults = discoverReport?.results?.length
|
||||
|
|
@ -989,91 +997,84 @@ export function TopologyPage() {
|
|||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!isValidConnection(connection)) return;
|
||||
if (!mapId || !isValidConnection(connection)) return;
|
||||
pushHistory();
|
||||
markDirty();
|
||||
connectClickRef.current = null;
|
||||
const data: EdgeStyleData = {
|
||||
source: "manual",
|
||||
source_port: "",
|
||||
target_port: "",
|
||||
stroke_color: "",
|
||||
stroke_width: 0,
|
||||
line_style: "",
|
||||
discovered_at: null,
|
||||
};
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
withEdgeVisual(
|
||||
{
|
||||
...connection,
|
||||
id: newId(),
|
||||
type: "straight",
|
||||
data,
|
||||
},
|
||||
edgeDefaults,
|
||||
),
|
||||
eds,
|
||||
),
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
await createFabricManualEdge({
|
||||
a_node_id: String(connection.source || ""),
|
||||
b_node_id: String(connection.target || ""),
|
||||
a_port: "",
|
||||
b_port: "",
|
||||
});
|
||||
const graph = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
})();
|
||||
},
|
||||
[setEdges, pushHistory, edgeDefaults, markDirty, isValidConnection],
|
||||
[setEdges, setNodes, pushHistory, edgeDefaults, isValidConnection, mapId, queryClient, clearDirty, showError],
|
||||
);
|
||||
|
||||
const addNodeAt = useCallback(
|
||||
(item: PaletteItem, position: { x: number; y: number }) => {
|
||||
async (item: PaletteItem, position: { x: number; y: number }) => {
|
||||
if (!mapId) {
|
||||
showError(t("topology.selectMap"));
|
||||
return;
|
||||
}
|
||||
if (item.source === "managed") {
|
||||
if (nodes.some((n) => n.data.managed_ne_id === item.managed_ne_id)) return;
|
||||
const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id);
|
||||
if (!ne) return;
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newId(),
|
||||
type: "neNode",
|
||||
position,
|
||||
data: {
|
||||
label: ne.name || ne.ip_address,
|
||||
managed_ne_id: ne.id,
|
||||
ume_ne_id: "",
|
||||
ne_ip: ne.ip_address,
|
||||
vendor: ne.vendor,
|
||||
connect_status: ne.connect_status,
|
||||
try {
|
||||
if (item.source === "managed") {
|
||||
if (nodes.some((n) => n.data.managed_ne_id === item.managed_ne_id)) return;
|
||||
const graph = await addTopologyViewNodes(mapId, { managed_ne_ids: [item.managed_ne_id] });
|
||||
const added = graph.nodes.find((n) => n.managed_ne_id === item.managed_ne_id);
|
||||
if (added) {
|
||||
await patchTopologyPositions(mapId, [
|
||||
{
|
||||
fabric_node_id: added.fabric_node_id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
label: added.label || added.name || "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const refreshed = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
return;
|
||||
}
|
||||
if (nodes.some((n) => n.data.ume_ne_id === item.ume_ne_id)) return;
|
||||
const graph = await addTopologyViewNodes(mapId, { ume_ne_ids: [item.ume_ne_id] });
|
||||
const added = graph.nodes.find((n) => n.ume_ne_id === item.ume_ne_id);
|
||||
if (added) {
|
||||
await patchTopologyPositions(mapId, [
|
||||
{
|
||||
fabric_node_id: added.fabric_node_id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
label: added.label || added.name || "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
]);
|
||||
}
|
||||
const refreshed = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
if (nodes.some((n) => n.data.ume_ne_id === item.ume_ne_id)) return;
|
||||
const ne = (umeQuery.data?.items || []).find((x) => x.ne_id === item.ume_ne_id);
|
||||
if (!ne) return;
|
||||
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newId(),
|
||||
type: "neNode",
|
||||
position,
|
||||
data: {
|
||||
label: name,
|
||||
managed_ne_id: "",
|
||||
ume_ne_id: ne.ne_id,
|
||||
ne_ip: ne.ip_address || "",
|
||||
vendor: "ZTE",
|
||||
connect_status: ne.connection_status || "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
[mapId, nodes, neQuery.data, umeQuery.data, setNodes, pushHistory, showError, t],
|
||||
[mapId, nodes, setNodes, setEdges, showError, t, queryClient, edgeDefaults, clearDirty],
|
||||
);
|
||||
|
||||
const addManagedNeToCanvas = (ne: ManagedNeItem) => {
|
||||
|
|
@ -1161,9 +1162,8 @@ export function TopologyPage() {
|
|||
>,
|
||||
opts?: { skipHistory?: boolean },
|
||||
) => {
|
||||
if (!selectedEdgeId) return;
|
||||
if (!selectedEdgeId || !mapId) return;
|
||||
if (!opts?.skipHistory) pushHistory();
|
||||
markDirty();
|
||||
setEdges((eds) =>
|
||||
eds.map((e) => {
|
||||
if (e.id !== selectedEdgeId) return e;
|
||||
|
|
@ -1181,8 +1181,17 @@ export function TopologyPage() {
|
|||
return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults);
|
||||
}),
|
||||
);
|
||||
const edge = edges.find((e) => e.id === selectedEdgeId);
|
||||
const prev = (edge?.data || {}) as EdgeStyleData;
|
||||
void patchTopologyEdgeStyle(mapId, {
|
||||
fabric_edge_id: selectedEdgeId,
|
||||
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
|
||||
stroke_width:
|
||||
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
|
||||
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
|
||||
}).catch((err) => showError(String(err)));
|
||||
},
|
||||
[selectedEdgeId, setEdges, pushHistory, edgeDefaults, markDirty],
|
||||
[selectedEdgeId, mapId, edges, setEdges, pushHistory, edgeDefaults, showError],
|
||||
);
|
||||
|
||||
const renameSelectedNode = useCallback(() => {
|
||||
|
|
@ -1202,13 +1211,6 @@ export function TopologyPage() {
|
|||
setCtxMenu(null);
|
||||
}, [selectedNode, t, pushHistory, markDirty, setNodes]);
|
||||
|
||||
const staleEdgeCount = useMemo(
|
||||
() =>
|
||||
edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale")
|
||||
.length,
|
||||
[edges],
|
||||
);
|
||||
|
||||
const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1251,19 +1253,29 @@ export function TopologyPage() {
|
|||
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const removeSelected = useCallback(() => {
|
||||
const nodeIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id));
|
||||
const removeSelected = useCallback(async () => {
|
||||
if (!mapId) return;
|
||||
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
|
||||
const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id));
|
||||
if (nodeIds.size === 0 && edgeIds.size === 0) return;
|
||||
if (!nodeIds.length && !edgeIds.size) return;
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setNodes((ns) => ns.filter((n) => !nodeIds.has(n.id)));
|
||||
setEdges((es) =>
|
||||
es.filter((e) => !edgeIds.has(e.id) && !nodeIds.has(e.source) && !nodeIds.has(e.target)),
|
||||
);
|
||||
setSelectedEdgeId(null);
|
||||
closeCtxMenu();
|
||||
}, [nodes, edges, setNodes, setEdges, pushHistory, closeCtxMenu]);
|
||||
try {
|
||||
if (nodeIds.length) {
|
||||
const graph = await removeTopologyViewNodes(mapId, nodeIds);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
} else if (edgeIds.size) {
|
||||
setEdges((eds) => eds.filter((e) => !edgeIds.has(e.id)));
|
||||
markDirty();
|
||||
}
|
||||
setSelectedEdgeId(null);
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
}, [mapId, nodes, edges, setNodes, setEdges, pushHistory, queryClient, edgeDefaults, clearDirty, markDirty, showError]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
|
|
@ -1350,18 +1362,6 @@ export function TopologyPage() {
|
|||
closeCtxMenu();
|
||||
};
|
||||
|
||||
const removeStaleEdges = () => {
|
||||
const n = staleEdgeCount;
|
||||
if (n <= 0) return;
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setEdges((es) =>
|
||||
es.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") !== "stale"),
|
||||
);
|
||||
setSelectedEdgeId(null);
|
||||
showOk(t("topology.staleRemoved").replace("{{count}}", String(n)));
|
||||
};
|
||||
|
||||
const openWebcrtFor = (node: Node<NeNodeData> | null) => {
|
||||
closeCtxMenu();
|
||||
const managedId = node?.data.managed_ne_id;
|
||||
|
|
@ -1894,23 +1894,6 @@ export function TopologyPage() {
|
|||
? t("topology.saveDirty")
|
||||
: t("topology.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={!mapId || discovering}
|
||||
onClick={() => void runDiscover()}
|
||||
>
|
||||
{discovering ? t("topology.discovering") : t("topology.discover")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!mapId || discovering || staleEdgeCount <= 0}
|
||||
onClick={removeStaleEdges}
|
||||
title={t("topology.removeStaleHint")}
|
||||
>
|
||||
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
|
|
@ -2191,8 +2174,7 @@ export function TopologyPage() {
|
|||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
if (discovering) {
|
||||
discoverAbortRef.current?.abort();
|
||||
return;
|
||||
return;
|
||||
}
|
||||
setDiscoverOpen(false);
|
||||
}}
|
||||
|
|
@ -2391,28 +2373,6 @@ export function TopologyPage() {
|
|||
<li className="topo-ctx__head" role="presentation">
|
||||
{t("topology.selectionMenu")}
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-ctx__item"
|
||||
role="menuitem"
|
||||
disabled={discovering}
|
||||
onClick={() => {
|
||||
const ids = selectedNodes
|
||||
.map((n) => String(n.data.managed_ne_id || n.data.ume_ne_id || "").trim())
|
||||
.filter(Boolean);
|
||||
closeCtxMenu();
|
||||
if (!ids.length) {
|
||||
showError(t("topology.discoverOneNeedNe"));
|
||||
return;
|
||||
}
|
||||
void runDiscover(ids);
|
||||
}}
|
||||
>
|
||||
{t("topology.discoverSelected").replace("{{count}}", String(selectedNodes.length))}
|
||||
</button>
|
||||
</li>
|
||||
<li className="topo-ctx__sep" aria-hidden />
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
581
web/src/pages/network/LldpLinksPage.tsx
Normal file
581
web/src/pages/network/LldpLinksPage.tsx
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchCliTargets,
|
||||
fetchLldpCollectDashboard,
|
||||
fetchLldpCollectJob,
|
||||
fetchLldpCollectJobs,
|
||||
startLldpCollect,
|
||||
updateLldpCollectPolicy,
|
||||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import type { CliTargetItem, ConfigSyncTargetRef, TopologyDiscoverJobItem } from "../../types";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
const POLL_MS = 2500;
|
||||
const TARGET_PAGE_SIZE = 20;
|
||||
const SEP = " · ";
|
||||
|
||||
export function LldpLinksPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalDays, setIntervalDays] = useState(1);
|
||||
const [concurrency, setConcurrency] = useState(4);
|
||||
const [scopeMode, setScopeMode] = useState<"all" | "selected">("all");
|
||||
const [autoAdd, setAutoAdd] = useState(true);
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({});
|
||||
const [policyHydrated, setPolicyHydrated] = useState(false);
|
||||
|
||||
const [targetKeyword, setTargetKeyword] = useState("");
|
||||
const [targetPage, setTargetPage] = useState(1);
|
||||
|
||||
const dashQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectDashboard,
|
||||
queryFn: fetchLldpCollectDashboard,
|
||||
staleTime: 1000,
|
||||
refetchInterval: (q) => {
|
||||
const running = q.state.data?.running_job;
|
||||
return running && (running.status === "running" || running.status === "pending") ? POLL_MS : false;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashQuery.data || policyHydrated) return;
|
||||
const p = dashQuery.data.policy;
|
||||
setEnabled(Boolean(p.enabled));
|
||||
setIntervalDays(Number(p.interval_days || 1));
|
||||
setConcurrency(Number(p.concurrency || 4));
|
||||
setScopeMode(p.scope_mode === "selected" ? "selected" : "all");
|
||||
setAutoAdd(Boolean(p.auto_add_unmatched));
|
||||
const map: Record<string, ConfigSyncTargetRef> = {};
|
||||
for (const ref of p.selected_targets || []) {
|
||||
const source = ref.source === "ume" ? "ume" : "managed";
|
||||
map[`${source}:${ref.id}`] = { source, id: ref.id };
|
||||
}
|
||||
setSelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
}, [dashQuery.data, policyHydrated]);
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJobs(jobPage),
|
||||
queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: 10 }),
|
||||
staleTime: 1000,
|
||||
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const jobDetailQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJob(expandedJobId),
|
||||
queryFn: () => fetchLldpCollectJob(expandedJobId),
|
||||
enabled: Boolean(expandedJobId),
|
||||
staleTime: 800,
|
||||
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const targetsQuery = useQuery({
|
||||
queryKey: queryKeys.cliTargets(targetKeyword, targetPage, TARGET_PAGE_SIZE),
|
||||
queryFn: () =>
|
||||
fetchCliTargets({ source: "all", keyword: targetKeyword, page: targetPage, pageSize: TARGET_PAGE_SIZE }),
|
||||
enabled: scopeMode === "selected",
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobsAll }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobAll }),
|
||||
]);
|
||||
};
|
||||
|
||||
const savePolicyMut = useMutation({
|
||||
mutationFn: () =>
|
||||
updateLldpCollectPolicy({
|
||||
enabled,
|
||||
interval_days: intervalDays,
|
||||
concurrency,
|
||||
scope_mode: scopeMode,
|
||||
auto_add_unmatched: autoAdd,
|
||||
selected_targets: Object.values(selectedMap),
|
||||
}),
|
||||
onSuccess: async (saved) => {
|
||||
// Apply server response immediately — do NOT flip policyHydrated false then
|
||||
// rehydrate from a possibly-stale dashboard cache (checkbox "pops back" bug).
|
||||
setEnabled(Boolean(saved.enabled));
|
||||
setIntervalDays(Number(saved.interval_days || 1));
|
||||
setConcurrency(Number(saved.concurrency || 4));
|
||||
setScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
|
||||
setAutoAdd(Boolean(saved.auto_add_unmatched));
|
||||
const map: Record<string, ConfigSyncTargetRef> = {};
|
||||
for (const ref of saved.selected_targets || []) {
|
||||
const source = ref.source === "ume" ? "ume" : "managed";
|
||||
map[`${source}:${ref.id}`] = { source, id: ref.id };
|
||||
}
|
||||
setSelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
queryClient.setQueryData(queryKeys.lldpCollectDashboard, (prev: unknown) => {
|
||||
if (!prev || typeof prev !== "object") return prev;
|
||||
return { ...(prev as object), policy: saved };
|
||||
});
|
||||
showOk(t("lldpLinks.policySaved"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const startMut = useMutation({
|
||||
mutationFn: () => startLldpCollect(),
|
||||
onSuccess: async () => {
|
||||
showOk(t("lldpLinks.started"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const dash = dashQuery.data;
|
||||
const running = dash?.running_job;
|
||||
const last = dash?.last_job;
|
||||
const jobs = jobsQuery.data?.items ?? [];
|
||||
const jobTotal = Number(jobsQuery.data?.total || 0);
|
||||
const jobPages = pageCount(jobTotal, 10);
|
||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||
const detailItems = jobDetailQuery.data?.items ?? [];
|
||||
|
||||
const toggleTarget = (row: CliTargetItem) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
const key = `${source}:${row.id}`;
|
||||
setSelectedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[key]) delete next[key];
|
||||
else next[key] = { source, id: row.id };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("lldpLinks.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" onClick={() => void refresh()} disabled={dashQuery.isFetching}>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={Boolean(running) || startMut.isPending}
|
||||
onClick={() => startMut.mutate()}
|
||||
>
|
||||
{t("lldpLinks.collectNow")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list" style={{ marginBottom: 16 }}>
|
||||
<div className="pt-list-kpis">
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.nodes")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.fabric_node_count ?? "—"}</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.edges")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.fabric_edge_active ?? "—"}</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.missing")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.fabric_edge_stale ?? "—"}</div>
|
||||
</div>
|
||||
<div className={`pt-list-kpi${running ? " pt-list-kpi--live" : ""}`}>
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.running")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: running ? 15 : 22 }}>
|
||||
{running
|
||||
? `${running.status} · ${running.done}/${running.total}`
|
||||
: t("lldpLinks.kpi.idle")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.last")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
|
||||
{last
|
||||
? `${last.status} · +${last.edges_added} / ~${last.edges_updated}`
|
||||
: t("common.empty")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.nextDue")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
|
||||
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ marginBottom: 16 }}>
|
||||
<h3>{t("lldpLinks.policyTitle")}</h3>
|
||||
<div className="config-sync-policy-row">
|
||||
<label className="config-sync-policy-check">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>{t("lldpLinks.enabled")}</span>
|
||||
</label>
|
||||
<label className="config-sync-policy-check">
|
||||
<input type="checkbox" checked={autoAdd} onChange={(e) => setAutoAdd(e.target.checked)} />
|
||||
<span>{t("lldpLinks.autoAddUnmatched")}</span>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.intervalDays")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.concurrency")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={32}
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Math.max(1, Math.min(32, Number(e.target.value) || 1)))}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.scope")}</span>
|
||||
<select
|
||||
value={scopeMode}
|
||||
onChange={(e) => setScopeMode(e.target.value === "selected" ? "selected" : "all")}
|
||||
>
|
||||
<option value="all">{t("lldpLinks.scopeAll")}</option>
|
||||
<option value="selected">{t("lldpLinks.scopeSelected")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={savePolicyMut.isPending}
|
||||
onClick={() => savePolicyMut.mutate()}
|
||||
>
|
||||
{t("lldpLinks.savePolicy")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scopeMode === "selected" ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p className="muted">{t("lldpLinks.selectedCount", { count: String(selectedCount) })}</p>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={targetKeyword}
|
||||
placeholder={t("lldpLinks.targetKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setTargetKeyword(e.target.value);
|
||||
setTargetPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t("lldpLinks.col.source")}</th>
|
||||
<th>{t("lldpLinks.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("lldpLinks.col.vendor")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(targetsQuery.data?.items ?? []).map((row) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
const key = `${source}:${row.id}`;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(selectedMap[key])}
|
||||
onChange={() => toggleTarget(row)}
|
||||
/>
|
||||
</td>
|
||||
<td>{source}</td>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>{row.vendor || "-"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={targetPage <= 1} onClick={() => setTargetPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(targetsQuery.data?.total ?? 0),
|
||||
page: String(targetPage),
|
||||
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
|
||||
onClick={() => setTargetPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h3>{t("lldpLinks.jobsTitle")}</h3>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>ID</th>
|
||||
<th>{t("lldpLinks.col.trigger")}</th>
|
||||
<th>{t("lldpLinks.col.scope")}</th>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.progress")}</th>
|
||||
<th>{t("lldpLinks.col.edges")}</th>
|
||||
<th>{t("lldpLinks.col.missingDelta")}</th>
|
||||
<th>{t("lldpLinks.col.started")}</th>
|
||||
<th>{t("lldpLinks.col.ended")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => {
|
||||
const open = expandedJobId === job.id;
|
||||
return (
|
||||
<tr key={job.id} className={open ? "is-expanded" : undefined}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
setItemDetail(null);
|
||||
setExpandedJobId(open ? "" : job.id);
|
||||
}}
|
||||
>
|
||||
{open ? "−" : "+"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="mono">{job.id.slice(0, 8)}</td>
|
||||
<td>{job.trigger_mode}</td>
|
||||
<td>{job.scope}</td>
|
||||
<td>{job.status}</td>
|
||||
<td>
|
||||
{job.done}/{job.total}
|
||||
</td>
|
||||
<td>
|
||||
+{job.edges_added} / ~{job.edges_updated}
|
||||
</td>
|
||||
<td>{job.edges_stale || 0}</td>
|
||||
<td>{job.started_at ? formatSystemTime(job.started_at) : "—"}</td>
|
||||
<td>{job.ended_at ? formatSystemTime(job.ended_at) : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!jobs.length ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="muted">
|
||||
{t("common.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={jobPage <= 1} onClick={() => setJobPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(jobTotal),
|
||||
page: String(jobPage),
|
||||
pages: String(jobPages),
|
||||
})}
|
||||
</span>
|
||||
<button type="button" disabled={jobPage >= jobPages} onClick={() => setJobPage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expandedJobId ? (
|
||||
<div className="panel" style={{ marginTop: 16 }}>
|
||||
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
|
||||
{jobDetailQuery.isLoading ? <p className="muted">…</p> : null}
|
||||
{jobDetailQuery.data?.error ? (
|
||||
<p className="muted" style={{ color: "var(--danger, #b00)" }}>
|
||||
{jobDetailQuery.data.error}
|
||||
</p>
|
||||
) : null}
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("lldpLinks.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.neighbors")}</th>
|
||||
<th>{t("lldpLinks.col.edges")}</th>
|
||||
<th>{t("lldpLinks.col.error")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailItems.map((it) => {
|
||||
const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
|
||||
return (
|
||||
<tr key={it.id}>
|
||||
<td>{it.ne_name || it.ne_id || "—"}</td>
|
||||
<td>{it.ne_ip || "—"}</td>
|
||||
<td>
|
||||
{!it.ok ? "fail" : it.parser_stub || unmatchedCount > 0 ? "warn" : "ok"}
|
||||
</td>
|
||||
<td>{it.neighbors}</td>
|
||||
<td>
|
||||
+{it.edges_added} / ~{it.edges_updated}
|
||||
{unmatchedCount > 0
|
||||
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
|
||||
: ""}
|
||||
</td>
|
||||
<td className="muted">{it.error || "—"}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setItemDetail(it)}
|
||||
>
|
||||
{t("topology.discoverViewDetail")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!detailItems.length && !jobDetailQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
{t("common.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{itemDetail ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setItemDetail(null)}>
|
||||
<div
|
||||
className="modal modal--wide topo-discover-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="lldp-item-detail-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="topo-discover-modal__head">
|
||||
<div>
|
||||
<h3 id="lldp-item-detail-title">
|
||||
{itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id}
|
||||
</h3>
|
||||
<p className="topo-discover-modal__sub">
|
||||
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setItemDetail(null)}
|
||||
>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!itemDetail.ok ? (
|
||||
<p className="topo-discover__error">
|
||||
{itemDetail.error || t("topology.discoverNeFail")}
|
||||
</p>
|
||||
) : null}
|
||||
{itemDetail.parser_stub ? (
|
||||
<p className="topo-discover__item-warn">
|
||||
{t("topology.discoverParserStub").replace(
|
||||
"{{parser}}",
|
||||
itemDetail.parser_key || "unknown",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="topo-discover-modal__stats">
|
||||
<span>
|
||||
{t("topology.discoverNeOk")
|
||||
.replace("{{neighbors}}", String(itemDetail.neighbors || 0))
|
||||
.replace("{{added}}", String(itemDetail.edges_added || 0))
|
||||
.replace("{{updated}}", String(itemDetail.edges_updated || 0))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="topo-discover-modal__section">
|
||||
{t("topology.discoverUnmatchedTitle").replace(
|
||||
"{{count}}",
|
||||
String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
|
||||
)}
|
||||
</h4>
|
||||
{(itemDetail.unmatched || []).length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
|
||||
) : (
|
||||
<div className="topo-discover-modal__table-wrap">
|
||||
<table className="topo-discover-modal__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("topology.discoverColRemote")}</th>
|
||||
<th>{t("topology.discoverColLocalPort")}</th>
|
||||
<th>{t("topology.discoverColRemotePort")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(itemDetail.unmatched || []).map((u, idx) => (
|
||||
<tr key={`${itemDetail.id}-u-${idx}`}>
|
||||
<td>
|
||||
{(u.remote_name || u.remote_ip || "?").trim()}
|
||||
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
|
||||
</td>
|
||||
<td>{u.local_port || "?"}</td>
|
||||
<td>{u.remote_port || "?"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{itemDetail.raw_preview ? (
|
||||
<>
|
||||
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
|
||||
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="modal__actions">
|
||||
<button type="button" className="btn btn--sm" onClick={() => setItemDetail(null)}>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue