mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 06:40:45 +08:00
Harden LLDP collect ops: edge list, job reclaim, and schedule hygiene.
Add fabric link browsing, conservative job retention, hour-based intervals, multi-worker start locking, and trim canvas discover UI in favor of the LLDP page. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d05ba0f1f7
commit
e7f97b7cf6
18 changed files with 1126 additions and 479 deletions
|
|
@ -57,6 +57,9 @@ export const queryKeys = {
|
|||
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
|
||||
lldpCollectJobAll: ["lldpCollectJob"] as const,
|
||||
lldpCollectJob: (jobId: string) => ["lldpCollectJob", jobId] as const,
|
||||
fabricEdgesAll: ["fabricEdges"] as const,
|
||||
fabricEdges: (status: string, keyword: string, page: number) =>
|
||||
["fabricEdges", status, keyword, page] as const,
|
||||
configSyncDashboard: ["configSyncDashboard"] as const,
|
||||
configSyncPolicy: ["configSyncPolicy"] as const,
|
||||
configSyncCyclesAll: ["configSyncCycles"] as const,
|
||||
|
|
|
|||
|
|
@ -236,7 +236,11 @@ const en = {
|
|||
enabled: "Enable scheduled collect",
|
||||
autoAddUnmatched: "Create SSH placeholder NEs for unmatched neighbors",
|
||||
intervalDays: "Interval (days)",
|
||||
interval: "Collect interval",
|
||||
intervalUnitDays: "days",
|
||||
intervalUnitHours: "hours",
|
||||
concurrency: "Concurrency",
|
||||
historyKeep: "Jobs to keep",
|
||||
scope: "Scope",
|
||||
scopeAll: "All managed NEs",
|
||||
scopeSelected: "Selected NEs",
|
||||
|
|
@ -245,6 +249,13 @@ const en = {
|
|||
targetKeywordPh: "Name / IP / vendor",
|
||||
jobsTitle: "Collect jobs",
|
||||
jobDetailTitle: "Job details",
|
||||
edgesTitle: "Fabric links",
|
||||
edgeKeywordPh: "Endpoint name or IP",
|
||||
edgeStatusAll: "All statuses",
|
||||
edgeStatusActive: "Active",
|
||||
edgeStatusMissing: "Missing",
|
||||
missCount: "Miss cycles",
|
||||
replacedBy: "Replaced",
|
||||
kpi: {
|
||||
nodes: "Fabric nodes",
|
||||
edges: "Active links",
|
||||
|
|
@ -268,6 +279,11 @@ const en = {
|
|||
ended: "Ended",
|
||||
neighbors: "Neighbors",
|
||||
error: "Error",
|
||||
aSide: "Side A",
|
||||
bSide: "Side B",
|
||||
aPort: "Port A",
|
||||
bPort: "Port B",
|
||||
lastSeen: "Last seen",
|
||||
},
|
||||
},
|
||||
configSync: {
|
||||
|
|
@ -1309,6 +1325,7 @@ const en = {
|
|||
discoverLocatePeer: "Locate peer",
|
||||
discoverLocateNe: "Locate NE",
|
||||
discoverRawPreview: "Raw output preview (truncated when long; parsing uses full output)",
|
||||
discoverGoLldp: "Details → LLDP links",
|
||||
findNode: "Find",
|
||||
findNodePh: "Type to search: name / IP / vendor",
|
||||
findNoMatch: "No matching node on the canvas",
|
||||
|
|
@ -1316,7 +1333,7 @@ const en = {
|
|||
locateOnCanvas: "Click to locate on canvas",
|
||||
openPortTraffic: "Open port traffic",
|
||||
removeStale: "Clear missing ({{count}})",
|
||||
removeStaleHint: "Delete red-marked missing edges",
|
||||
removeStaleHint: "Manually delete red missing edges; scheduled collect also purges after 4 consecutive misses",
|
||||
staleRemoved: "Removed {{count}} missing edge(s)",
|
||||
connectHint: "Connect mode: click source node, then target (Esc or Select to exit)",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -236,7 +236,11 @@ const zh = {
|
|||
enabled: "启用周期调度",
|
||||
autoAddUnmatched: "未纳管邻居建 SSH 占位网元",
|
||||
intervalDays: "周期(天)",
|
||||
interval: "采集周期",
|
||||
intervalUnitDays: "天",
|
||||
intervalUnitHours: "小时",
|
||||
concurrency: "并发",
|
||||
historyKeep: "任务保留数",
|
||||
scope: "范围",
|
||||
scopeAll: "全部纳管网元",
|
||||
scopeSelected: "指定网元",
|
||||
|
|
@ -245,6 +249,13 @@ const zh = {
|
|||
targetKeywordPh: "名称 / IP / 厂商",
|
||||
jobsTitle: "采集任务",
|
||||
jobDetailTitle: "任务明细",
|
||||
edgesTitle: "Fabric 链路",
|
||||
edgeKeywordPh: "本端 / 对端名称或 IP",
|
||||
edgeStatusAll: "全部状态",
|
||||
edgeStatusActive: "活跃",
|
||||
edgeStatusMissing: "未发现",
|
||||
missCount: "未发现周期",
|
||||
replacedBy: "已被替换",
|
||||
kpi: {
|
||||
nodes: "Fabric 网元",
|
||||
edges: "活跃链路",
|
||||
|
|
@ -268,6 +279,11 @@ const zh = {
|
|||
ended: "结束",
|
||||
neighbors: "邻居",
|
||||
error: "错误",
|
||||
aSide: "本端",
|
||||
bSide: "对端",
|
||||
aPort: "本端口",
|
||||
bPort: "对端口",
|
||||
lastSeen: "最近看见",
|
||||
},
|
||||
},
|
||||
configSync: {
|
||||
|
|
@ -1305,6 +1321,7 @@ const zh = {
|
|||
discoverLocatePeer: "定位对端",
|
||||
discoverLocateNe: "定位本端",
|
||||
discoverRawPreview: "采集原文预览(过长会截断,不影响解析)",
|
||||
discoverGoLldp: "查看详情 → LLDP 链路",
|
||||
findNode: "定位",
|
||||
findNodePh: "输入即搜:名称 / IP / 厂商",
|
||||
findNoMatch: "画布上没有匹配的节点",
|
||||
|
|
@ -1312,7 +1329,7 @@ const zh = {
|
|||
locateOnCanvas: "点击定位到画布",
|
||||
openPortTraffic: "打开端口流量",
|
||||
removeStale: "清除未发现 ({{count}})",
|
||||
removeStaleHint: "删除红色标记的未发现链路",
|
||||
removeStaleHint: "手动删除红色未发现链路;周期采集连续 4 次未发现也会自动清理",
|
||||
staleRemoved: "已删除 {{count}} 条未发现链路",
|
||||
connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ReactFlow,
|
||||
|
|
@ -324,7 +325,7 @@ const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
|
|||
|
||||
function sourceKind(source: string): EdgeSourceKind {
|
||||
const src = (source || "manual").toLowerCase();
|
||||
if (src === "stale") return "stale";
|
||||
if (src === "stale" || src === "missing") return "stale";
|
||||
if (src === "lldp" || src === "cdp") return "discovered";
|
||||
return "manual";
|
||||
}
|
||||
|
|
@ -514,12 +515,6 @@ export function TopologyPage() {
|
|||
const [searchHitIds, setSearchHitIds] = useState<string[]>([]);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [findActiveIdx, setFindActiveIdx] = useState(0);
|
||||
const [discoverFilter, setDiscoverFilter] = useState<"issues" | "fail" | "warn" | "ok" | "all">(
|
||||
"issues",
|
||||
);
|
||||
const [discoverDetail, setDiscoverDetail] = useState<TopologyDiscoverNeResult | null>(null);
|
||||
const [discoverListOpen, setDiscoverListOpen] = useState(false);
|
||||
const discoverAbortRef = useRef<AbortController | null>(null);
|
||||
const searchHitTimerRef = useRef<number | null>(null);
|
||||
const findBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
const displayMenuRef = useRef<HTMLDetailsElement | null>(null);
|
||||
|
|
@ -833,9 +828,6 @@ export function TopologyPage() {
|
|||
setDiscoverReport(null);
|
||||
setDiscoverLiveResults([]);
|
||||
setDiscoverError("");
|
||||
setDiscoverFilter("issues");
|
||||
setDiscoverDetail(null);
|
||||
setDiscoverListOpen(false);
|
||||
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
|
||||
const scoped = scannable.filter(
|
||||
(n) =>
|
||||
|
|
@ -915,7 +907,7 @@ export function TopologyPage() {
|
|||
scanned: job.done,
|
||||
edges_added: job.edges_added,
|
||||
edges_updated: job.edges_updated,
|
||||
edges_stale: job.edges_stale,
|
||||
edges_stale: job.edges_missing ?? job.edges_stale,
|
||||
results: (job.items || []) as TopologyDiscoverNeResult[],
|
||||
graph: projected,
|
||||
};
|
||||
|
|
@ -958,26 +950,16 @@ export function TopologyPage() {
|
|||
scanned: discoverReport.scanned,
|
||||
added: discoverReport.edges_added,
|
||||
updated: discoverReport.edges_updated,
|
||||
stale: discoverReport.edges_stale || 0,
|
||||
missing: discoverReport.edges_stale || 0,
|
||||
failed: discoverCounts.fail,
|
||||
}
|
||||
: {
|
||||
scanned: discoverLiveResults.length,
|
||||
added: discoverProgress.edgesAdded,
|
||||
updated: discoverProgress.edgesUpdated,
|
||||
stale: 0,
|
||||
missing: 0,
|
||||
failed: discoverCounts.fail,
|
||||
};
|
||||
const discoverFiltered = useMemo(() => {
|
||||
return discoverResults.filter((r) => {
|
||||
const k = discoverResultKind(r);
|
||||
if (discoverFilter === "all") return true;
|
||||
if (discoverFilter === "issues") return k === "fail" || k === "warn";
|
||||
return k === discoverFilter;
|
||||
});
|
||||
}, [discoverResults, discoverFilter]);
|
||||
const DISCOVER_LIST_CAP = 200;
|
||||
const discoverVisible = discoverFiltered.slice(0, DISCOVER_LIST_CAP);
|
||||
const discoverPct =
|
||||
discovering && discoverProgress.total > 0
|
||||
? Math.min(100, Math.round((discoverProgress.index / discoverProgress.total) * 100))
|
||||
|
|
@ -2215,43 +2197,14 @@ export function TopologyPage() {
|
|||
.replace("{{scanned}}", String(discoverSummary.scanned))
|
||||
.replace("{{added}}", String(discoverSummary.added))
|
||||
.replace("{{updated}}", String(discoverSummary.updated))
|
||||
.replace("{{stale}}", String(discoverSummary.stale))
|
||||
.replace("{{stale}}", String(discoverSummary.missing))
|
||||
.replace("{{failed}}", String(discoverSummary.failed))}
|
||||
</p>
|
||||
<div className="topo-discover__filters" role="group" aria-label={t("topology.discoverFilter")}>
|
||||
{(
|
||||
[
|
||||
["issues", t("topology.discoverFilterIssues"), discoverCounts.issues],
|
||||
["fail", t("topology.discoverFilterFail"), discoverCounts.fail],
|
||||
["warn", t("topology.discoverFilterWarn"), discoverCounts.warn],
|
||||
["ok", t("topology.discoverFilterOk"), discoverCounts.ok],
|
||||
] as const
|
||||
).map(([key, label, count]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="topo-discover__filter"
|
||||
onClick={() => {
|
||||
setDiscoverFilter(key);
|
||||
setDiscoverListOpen(true);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span className="topo-discover__filter-count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="topo-discover__filter topo-discover__filter--primary"
|
||||
onClick={() => {
|
||||
setDiscoverFilter("all");
|
||||
setDiscoverListOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("topology.discoverFilterDetail")}
|
||||
<span className="topo-discover__filter-count">{discoverResults.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="panel__hint">
|
||||
<Link className="btn btn--sm btn--ghost" to="/network/topology/lldp">
|
||||
{t("topology.discoverGoLldp")}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -2562,316 +2515,6 @@ export function TopologyPage() {
|
|||
) : null}
|
||||
|
||||
|
||||
{discoverListOpen ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setDiscoverListOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="modal modal--wide topo-discover-modal topo-discover-list-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="topo-discover-list-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="topo-discover-modal__head">
|
||||
<div>
|
||||
<h3 id="topo-discover-list-title">{t("topology.discoverListTitle")}</h3>
|
||||
<p className="topo-discover-modal__sub">
|
||||
{t("topology.discoverSummary")
|
||||
.replace("{{scanned}}", String(discoverSummary.scanned))
|
||||
.replace("{{added}}", String(discoverSummary.added))
|
||||
.replace("{{updated}}", String(discoverSummary.updated))
|
||||
.replace("{{stale}}", String(discoverSummary.stale))
|
||||
.replace("{{failed}}", String(discoverSummary.failed))}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setDiscoverListOpen(false)}
|
||||
>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="topo-discover__filters" role="tablist" aria-label={t("topology.discoverFilter")}>
|
||||
{(
|
||||
[
|
||||
["issues", t("topology.discoverFilterIssues"), discoverCounts.issues],
|
||||
["fail", t("topology.discoverFilterFail"), discoverCounts.fail],
|
||||
["warn", t("topology.discoverFilterWarn"), discoverCounts.warn],
|
||||
["ok", t("topology.discoverFilterOk"), discoverCounts.ok],
|
||||
["all", t("topology.discoverFilterAll"), discoverResults.length],
|
||||
] as const
|
||||
).map(([key, label, count]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={discoverFilter === key}
|
||||
className={`topo-discover__filter${discoverFilter === key ? " is-active" : ""}`}
|
||||
onClick={() => setDiscoverFilter(key)}
|
||||
>
|
||||
{label}
|
||||
<span className="topo-discover__filter-count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{discoverVisible.length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverListEmpty")}</p>
|
||||
) : (
|
||||
<ul className="topo-discover__list topo-discover__list--modal">
|
||||
{discoverVisible.map((r) => {
|
||||
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
|
||||
const kind = discoverResultKind(r);
|
||||
const title = r.ne_name || r.ne_ip || r.ne_id;
|
||||
const linkCount = r.links?.length || 0;
|
||||
return (
|
||||
<li key={r.ne_id} className={`is-${kind}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-discover__row"
|
||||
onClick={() => setDiscoverDetail(r)}
|
||||
>
|
||||
<span className="topo-discover__row-main">
|
||||
<span className="topo-discover__row-name">{title}</span>
|
||||
{r.ne_ip ? <span className="topo-discover__row-ip">{r.ne_ip}</span> : null}
|
||||
<span className="topo-discover__row-meta">
|
||||
{r.ok
|
||||
? t("topology.discoverNeOk")
|
||||
.replace("{{neighbors}}", String(r.neighbors))
|
||||
.replace("{{added}}", String(r.edges_added))
|
||||
.replace("{{updated}}", String(r.edges_updated))
|
||||
: r.error || t("topology.discoverNeFail")}
|
||||
{linkCount > 0
|
||||
? ` ? ${t("topology.discoverLinkCount").replace("{{count}}", String(linkCount))}`
|
||||
: ""}
|
||||
{r.ok && unmatchedCount > 0
|
||||
? ` ? ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="topo-discover__row-actions">
|
||||
<span className="topo-discover__badge">
|
||||
{kind === "fail"
|
||||
? "FAIL"
|
||||
: kind === "warn"
|
||||
? r.parser_stub
|
||||
? "STUB"
|
||||
: "WARN"
|
||||
: "OK"}
|
||||
</span>
|
||||
<span className="topo-discover__detail-btn">{t("topology.discoverViewDetail")}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{discoverFiltered.length > DISCOVER_LIST_CAP ? (
|
||||
<p className="topo-discover__cap">
|
||||
{t("topology.discoverShowLimited")
|
||||
.replace("{{shown}}", String(DISCOVER_LIST_CAP))
|
||||
.replace("{{total}}", String(discoverFiltered.length))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
{discoverDetail ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setDiscoverDetail(null)}
|
||||
>
|
||||
<div
|
||||
className="modal modal--wide topo-discover-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="topo-discover-detail-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="topo-discover-modal__head">
|
||||
<div>
|
||||
<h3 id="topo-discover-detail-title">
|
||||
{discoverDetail.ne_name || discoverDetail.ne_ip || discoverDetail.ne_id}
|
||||
</h3>
|
||||
<p className="topo-discover-modal__sub">
|
||||
{[discoverDetail.ne_ip, discoverDetail.command].filter(Boolean).join(SEP)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`topo-discover__badge topo-discover__badge--lg is-${discoverResultKind(discoverDetail)}`}
|
||||
>
|
||||
{discoverResultKind(discoverDetail) === "fail"
|
||||
? "FAIL"
|
||||
: discoverResultKind(discoverDetail) === "warn"
|
||||
? discoverDetail.parser_stub
|
||||
? "STUB"
|
||||
: "WARN"
|
||||
: "OK"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!discoverDetail.ok ? (
|
||||
<p className="topo-discover__error">
|
||||
{discoverDetail.error || t("topology.discoverNeFail")}
|
||||
</p>
|
||||
) : null}
|
||||
{discoverDetail.parser_stub ? (
|
||||
<p className="topo-discover__item-warn">
|
||||
{t("topology.discoverParserStub").replace(
|
||||
"{{parser}}",
|
||||
discoverDetail.parser_key || "unknown",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="topo-discover-modal__stats">
|
||||
<span>
|
||||
{t("topology.discoverNeOk")
|
||||
.replace("{{neighbors}}", String(discoverDetail.neighbors || 0))
|
||||
.replace("{{added}}", String(discoverDetail.edges_added || 0))
|
||||
.replace("{{updated}}", String(discoverDetail.edges_updated || 0))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="topo-discover-modal__section">
|
||||
{t("topology.discoverLinksTitle").replace(
|
||||
"{{count}}",
|
||||
String(discoverDetail.links?.length || 0),
|
||||
)}
|
||||
</h4>
|
||||
{(discoverDetail.links || []).length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverLinksEmpty")}</p>
|
||||
) : (
|
||||
<div className="topo-discover-modal__table-wrap">
|
||||
<table className="topo-discover-modal__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("topology.discoverColPeer")}</th>
|
||||
<th>{t("topology.discoverColLocalPort")}</th>
|
||||
<th>{t("topology.discoverColRemotePort")}</th>
|
||||
<th>{t("topology.discoverColProtocol")}</th>
|
||||
<th>{t("topology.discoverColAction")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(discoverDetail.links || []).map((link, idx) => (
|
||||
<tr key={`${discoverDetail.ne_id}-l-${idx}`}>
|
||||
<td>
|
||||
<div className="topo-discover-modal__peer">
|
||||
<strong>{link.peer_name || link.peer_ip || link.peer_ne_id || "?"}</strong>
|
||||
{link.peer_ip ? <span>{link.peer_ip}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{link.local_port || "?"}</td>
|
||||
<td>{link.remote_port || "?"}</td>
|
||||
<td>{(link.protocol || "?").toUpperCase()}</td>
|
||||
<td>
|
||||
<span className={`topo-discover-action is-${link.action || ""}`}>
|
||||
{link.action === "added"
|
||||
? t("topology.discoverLinkAdded")
|
||||
: link.action === "updated"
|
||||
? t("topology.discoverLinkUpdated")
|
||||
: link.action === "kept_manual"
|
||||
? t("topology.discoverLinkKeptManual")
|
||||
: link.action || "?"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{link.peer_node_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
const nid = String(link.peer_node_id || "");
|
||||
setDiscoverDetail(null);
|
||||
locateNode(nid);
|
||||
}}
|
||||
>
|
||||
{t("topology.discoverLocatePeer")}
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 className="topo-discover-modal__section">
|
||||
{t("topology.discoverUnmatchedTitle").replace(
|
||||
"{{count}}",
|
||||
String(discoverDetail.unmatched_count ?? discoverDetail.unmatched?.length ?? 0),
|
||||
)}
|
||||
</h4>
|
||||
{(discoverDetail.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>
|
||||
{(discoverDetail.unmatched || []).map((u, idx) => (
|
||||
<tr key={`${discoverDetail.ne_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>
|
||||
)}
|
||||
|
||||
{discoverDetail.raw_preview ? (
|
||||
<>
|
||||
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
|
||||
<pre className="topo-discover-modal__raw">{discoverDetail.raw_preview}</pre>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
const id = String(discoverDetail.ne_id || "");
|
||||
const node = nodes.find(
|
||||
(n) => n.data.managed_ne_id === id || n.data.ume_ne_id === id,
|
||||
);
|
||||
setDiscoverDetail(null);
|
||||
if (node) locateNode(node.id);
|
||||
else showError(t("topology.findNoMatch"));
|
||||
}}
|
||||
>
|
||||
{t("topology.discoverLocateNe")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--sm" onClick={() => setDiscoverDetail(null)}>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchCliTargets,
|
||||
fetchFabricEdges,
|
||||
fetchLldpCollectDashboard,
|
||||
fetchLldpCollectJob,
|
||||
fetchLldpCollectJobs,
|
||||
|
|
@ -28,9 +29,16 @@ export function LldpLinksPage() {
|
|||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
|
||||
|
||||
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
|
||||
const [edgeKeyword, setEdgeKeyword] = useState("");
|
||||
const [edgePage, setEdgePage] = useState(1);
|
||||
const EDGE_PAGE_SIZE = 20;
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalDays, setIntervalDays] = useState(1);
|
||||
const [intervalValue, setIntervalValue] = useState(1);
|
||||
const [intervalUnit, setIntervalUnit] = useState<"days" | "hours">("days");
|
||||
const [concurrency, setConcurrency] = useState(4);
|
||||
const [historyKeep, setHistoryKeep] = useState(30);
|
||||
const [scopeMode, setScopeMode] = useState<"all" | "selected">("all");
|
||||
const [autoAdd, setAutoAdd] = useState(true);
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({});
|
||||
|
|
@ -53,8 +61,16 @@ export function LldpLinksPage() {
|
|||
if (!dashQuery.data || policyHydrated) return;
|
||||
const p = dashQuery.data.policy;
|
||||
setEnabled(Boolean(p.enabled));
|
||||
setIntervalDays(Number(p.interval_days || 1));
|
||||
const hours = Math.max(1, Number(p.interval_hours || (Number(p.interval_days || 1) * 24)));
|
||||
if (hours % 24 === 0) {
|
||||
setIntervalUnit("days");
|
||||
setIntervalValue(Math.max(1, Math.min(365, hours / 24)));
|
||||
} else {
|
||||
setIntervalUnit("hours");
|
||||
setIntervalValue(Math.max(1, Math.min(8760, hours)));
|
||||
}
|
||||
setConcurrency(Number(p.concurrency || 4));
|
||||
setHistoryKeep(Math.max(0, Math.min(200, Number(p.history_keep ?? 30))));
|
||||
setScopeMode(p.scope_mode === "selected" ? "selected" : "all");
|
||||
setAutoAdd(Boolean(p.auto_add_unmatched));
|
||||
const map: Record<string, ConfigSyncTargetRef> = {};
|
||||
|
|
@ -89,30 +105,58 @@ export function LldpLinksPage() {
|
|||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const edgesQuery = useQuery({
|
||||
queryKey: queryKeys.fabricEdges(edgeStatus, edgeKeyword, edgePage),
|
||||
queryFn: () =>
|
||||
fetchFabricEdges({
|
||||
status: edgeStatus === "all" ? "" : edgeStatus,
|
||||
keyword: edgeKeyword,
|
||||
page: edgePage,
|
||||
pageSize: EDGE_PAGE_SIZE,
|
||||
}),
|
||||
staleTime: 2000,
|
||||
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobsAll }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobAll }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.fabricEdgesAll }),
|
||||
]);
|
||||
};
|
||||
|
||||
const savePolicyMut = useMutation({
|
||||
mutationFn: () =>
|
||||
updateLldpCollectPolicy({
|
||||
mutationFn: () => {
|
||||
const hours =
|
||||
intervalUnit === "days"
|
||||
? Math.max(1, Math.min(365, intervalValue)) * 24
|
||||
: Math.max(1, Math.min(8760, intervalValue));
|
||||
return updateLldpCollectPolicy({
|
||||
enabled,
|
||||
interval_days: intervalDays,
|
||||
interval_hours: hours,
|
||||
concurrency,
|
||||
history_keep: historyKeep,
|
||||
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));
|
||||
const hours = Math.max(1, Number(saved.interval_hours || (Number(saved.interval_days || 1) * 24)));
|
||||
if (hours % 24 === 0) {
|
||||
setIntervalUnit("days");
|
||||
setIntervalValue(Math.max(1, Math.min(365, hours / 24)));
|
||||
} else {
|
||||
setIntervalUnit("hours");
|
||||
setIntervalValue(Math.max(1, Math.min(8760, hours)));
|
||||
}
|
||||
setConcurrency(Number(saved.concurrency || 4));
|
||||
setHistoryKeep(Math.max(0, Math.min(200, Number(saved.history_keep ?? 30))));
|
||||
setScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
|
||||
setAutoAdd(Boolean(saved.auto_add_unmatched));
|
||||
const map: Record<string, ConfigSyncTargetRef> = {};
|
||||
|
|
@ -149,6 +193,9 @@ export function LldpLinksPage() {
|
|||
const jobPages = pageCount(jobTotal, 10);
|
||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||
const detailItems = jobDetailQuery.data?.items ?? [];
|
||||
const edgeItems = edgesQuery.data?.items ?? [];
|
||||
const edgeTotal = Number(edgesQuery.data?.total || 0);
|
||||
const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE);
|
||||
|
||||
const toggleTarget = (row: CliTargetItem) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
|
|
@ -192,7 +239,9 @@ export function LldpLinksPage() {
|
|||
</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 className="pt-list-kpi__value">
|
||||
{dash?.fabric_edge_missing ?? 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>
|
||||
|
|
@ -231,14 +280,33 @@ export function LldpLinksPage() {
|
|||
<span>{t("lldpLinks.autoAddUnmatched")}</span>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.intervalDays")}</span>
|
||||
<span>{t("lldpLinks.interval")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
|
||||
max={intervalUnit === "days" ? 365 : 8760}
|
||||
value={intervalValue}
|
||||
onChange={(e) => {
|
||||
const max = intervalUnit === "days" ? 365 : 8760;
|
||||
setIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={intervalUnit}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value === "hours" ? "hours" : "days";
|
||||
if (next === intervalUnit) return;
|
||||
if (next === "hours") {
|
||||
setIntervalValue(Math.max(1, Math.min(8760, intervalValue * 24)));
|
||||
} else {
|
||||
setIntervalValue(Math.max(1, Math.min(365, Math.round(intervalValue / 24) || 1)));
|
||||
}
|
||||
setIntervalUnit(next);
|
||||
}}
|
||||
>
|
||||
<option value="days">{t("lldpLinks.intervalUnitDays")}</option>
|
||||
<option value="hours">{t("lldpLinks.intervalUnitHours")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.concurrency")}</span>
|
||||
|
|
@ -250,6 +318,16 @@ export function LldpLinksPage() {
|
|||
onChange={(e) => setConcurrency(Math.max(1, Math.min(32, Number(e.target.value) || 1)))}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.historyKeep")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={200}
|
||||
value={historyKeep}
|
||||
onChange={(e) => setHistoryKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.scope")}</span>
|
||||
<select
|
||||
|
|
@ -338,7 +416,108 @@ export function LldpLinksPage() {
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<h3>{t("lldpLinks.jobsTitle")}</h3>
|
||||
<h3>{t("lldpLinks.edgesTitle")}</h3>
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<select
|
||||
value={edgeStatus}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setEdgeStatus(v === "active" || v === "missing" ? v : "all");
|
||||
setEdgePage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">{t("lldpLinks.edgeStatusAll")}</option>
|
||||
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
|
||||
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
|
||||
</select>
|
||||
<input
|
||||
value={edgeKeyword}
|
||||
placeholder={t("lldpLinks.edgeKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setEdgeKeyword(e.target.value);
|
||||
setEdgePage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.aSide")}</th>
|
||||
<th>{t("lldpLinks.col.aPort")}</th>
|
||||
<th>{t("lldpLinks.col.bSide")}</th>
|
||||
<th>{t("lldpLinks.col.bPort")}</th>
|
||||
<th>{t("lldpLinks.col.source")}</th>
|
||||
<th>{t("lldpLinks.missCount")}</th>
|
||||
<th>{t("lldpLinks.col.lastSeen")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{edgeItems.map((e) => {
|
||||
const attrs = (e.attrs || {}) as Record<string, unknown>;
|
||||
const missCount = Number(attrs.miss_count || 0) || 0;
|
||||
const replaced = String(attrs.replaced_by_edge_id || "").trim();
|
||||
const aLabel = e.a_name || e.a_ip || e.a_node_id.slice(0, 8);
|
||||
const bLabel = e.b_name || e.b_ip || e.b_node_id.slice(0, 8);
|
||||
const st = e.status === "stale" ? "missing" : e.status;
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td>{st}</td>
|
||||
<td>
|
||||
<div>{aLabel}</div>
|
||||
{e.a_ip && e.a_name ? <div className="muted">{e.a_ip}</div> : null}
|
||||
</td>
|
||||
<td>{e.a_port || "—"}</td>
|
||||
<td>
|
||||
<div>{bLabel}</div>
|
||||
{e.b_ip && e.b_name ? <div className="muted">{e.b_ip}</div> : null}
|
||||
</td>
|
||||
<td>{e.b_port || "—"}</td>
|
||||
<td>{e.source}</td>
|
||||
<td>
|
||||
{missCount || (st === "missing" ? 1 : 0)}
|
||||
{replaced ? (
|
||||
<div className="muted" title={replaced}>
|
||||
{t("lldpLinks.replacedBy")}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{e.last_seen_at ? formatSystemTime(e.last_seen_at) : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!edgeItems.length ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="muted">
|
||||
{edgesQuery.isLoading ? "…" : t("common.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={edgePage <= 1} onClick={() => setEdgePage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(edgeTotal),
|
||||
page: String(edgePage),
|
||||
pages: String(edgePages),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={edgePage >= edgePages}
|
||||
onClick={() => setEdgePage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
|
|
@ -382,7 +561,7 @@ export function LldpLinksPage() {
|
|||
<td>
|
||||
+{job.edges_added} / ~{job.edges_updated}
|
||||
</td>
|
||||
<td>{job.edges_stale || 0}</td>
|
||||
<td>{job.edges_missing ?? job.edges_stale ?? 0}</td>
|
||||
<td>{job.started_at ? formatSystemTime(job.started_at) : "—"}</td>
|
||||
<td>{job.ended_at ? formatSystemTime(job.ended_at) : "—"}</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type {
|
|||
CliMeta,
|
||||
CliTargetListResponse,
|
||||
UmeCliOverrideItem,
|
||||
FabricEdge,
|
||||
FabricSummary,
|
||||
TopologyDiscoverJob,
|
||||
TopologyViewGraph,
|
||||
|
|
@ -907,6 +908,26 @@ export const patchTopologyEdgeStyle = (
|
|||
|
||||
export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabric/summary");
|
||||
|
||||
export const fetchFabricEdges = (params?: {
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
nodeId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (params?.keyword) p.set("keyword", params.keyword);
|
||||
if (params?.status) p.set("status", params.status);
|
||||
if (params?.source) p.set("source", params.source);
|
||||
if (params?.nodeId) p.set("node_id", params.nodeId);
|
||||
p.set("page", String(Math.max(1, Number(params?.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(200, Number(params?.pageSize || 20)))));
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: FabricEdge[] }>(
|
||||
`/v1/topology/fabric/edges?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const startLldpDiscover = (body?: {
|
||||
scope?: "all_inventory" | "ne_ids";
|
||||
ne_ids?: string[];
|
||||
|
|
|
|||
|
|
@ -402,10 +402,30 @@ export type FabricSummary = {
|
|||
edge_count: number;
|
||||
edge_active: number;
|
||||
edge_stale: number;
|
||||
edge_missing?: number;
|
||||
last_discover_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type FabricEdge = {
|
||||
id: string;
|
||||
layer: string;
|
||||
a_node_id: string;
|
||||
b_node_id: string;
|
||||
a_port: string;
|
||||
b_port: string;
|
||||
a_name?: string;
|
||||
b_name?: string;
|
||||
a_ip?: string;
|
||||
b_ip?: string;
|
||||
source: string;
|
||||
status: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
discovered_at?: string | null;
|
||||
last_seen_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TopologyDiscoverUnmatched = {
|
||||
remote_name: string;
|
||||
remote_ip: string;
|
||||
|
|
@ -444,6 +464,7 @@ export type TopologyDiscoverJob = {
|
|||
edges_added: number;
|
||||
edges_updated: number;
|
||||
edges_stale: number;
|
||||
edges_missing?: number;
|
||||
error: string;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
|
|
@ -483,10 +504,12 @@ export type ConfigSyncTargetRef = {
|
|||
export type LldpCollectPolicy = {
|
||||
enabled: boolean;
|
||||
interval_days: number;
|
||||
interval_hours: number;
|
||||
concurrency: number;
|
||||
scope_mode: string;
|
||||
selected_targets: ConfigSyncTargetRef[];
|
||||
auto_add_unmatched: boolean;
|
||||
history_keep: number;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -500,6 +523,7 @@ export type LldpCollectJobSummary = {
|
|||
edges_added: number;
|
||||
edges_updated: number;
|
||||
edges_stale: number;
|
||||
edges_missing?: number;
|
||||
error: string;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
|
|
@ -512,6 +536,7 @@ export type LldpCollectDashboard = {
|
|||
fabric_edge_count: number;
|
||||
fabric_edge_active: number;
|
||||
fabric_edge_stale: number;
|
||||
fabric_edge_missing?: number;
|
||||
last_discover_at?: string | null;
|
||||
running_job: LldpCollectJobSummary | null;
|
||||
last_job: LldpCollectJobSummary | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue