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:
oliver 2026-08-02 03:39:15 +08:00
parent 9674de221d
commit d05ba0f1f7
30 changed files with 4713 additions and 1870 deletions

View file

@ -36,11 +36,13 @@ python -m netx_mcp
[`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。
## 工具(12)
## 工具(21)
UME:`queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics`, `queryUmeNeInventory`, `getUmeNe`, `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme`
托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe`
托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets`
拓扑 Fabric:`getTopologySummary`, `queryTopologyNodes`, `queryTopologyEdges`, `getTopologyNeighborhood`, `runLldpDiscover`, `getLldpDiscoverJob`, `listTopologyViews`, `getTopologyView`
## 兼容

View file

@ -1,4 +1,4 @@
"""MCP tool schemas and HTTP-backed handlers (12 tools: UME + managed NE; no Excel import batch)."""
"""MCP tool schemas and HTTP-backed handlers (UME + managed NE + topology fabric)."""
from __future__ import annotations
@ -285,6 +285,81 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
return http_json("GET", "/v1/cli/targets", params=params)
def _get_topology_summary(args: dict[str, Any]) -> dict[str, Any]:
_ = args
return http_json("GET", "/v1/topology/fabric/summary", params=None)
def _query_topology_nodes(args: dict[str, Any]) -> dict[str, Any]:
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
params: dict[str, Any] = {"page": page, "page_size": page_size}
if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip()
return http_json("GET", "/v1/topology/fabric/nodes", params=params)
def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
params: dict[str, Any] = {
"page": page,
"page_size": page_size,
"layer": str(args.get("layer") or "physical").strip() or "physical",
}
if str(args.get("node_id") or "").strip():
params["node_id"] = str(args.get("node_id")).strip()
if str(args.get("status") or "").strip():
params["status"] = str(args.get("status")).strip()
if str(args.get("source") or "").strip():
params["source"] = str(args.get("source")).strip()
return http_json("GET", "/v1/topology/fabric/edges", params=params)
def _get_topology_neighborhood(args: dict[str, Any]) -> dict[str, Any]:
node_id = str(args.get("node_id") or "").strip()
if not node_id:
return {"ok": False, "error": "node_id_required", "error_code": "node_id_required"}
params: dict[str, Any] = {
"node_id": node_id,
"depth": max(1, min(3, int(args.get("depth") or 1))),
"layer": str(args.get("layer") or "physical").strip() or "physical",
}
return http_json("GET", "/v1/topology/fabric/neighborhood", params=params)
def _run_lldp_discover(args: dict[str, Any]) -> dict[str, Any]:
scope = str(args.get("scope") or "ne_ids").strip() or "ne_ids"
body: dict[str, Any] = {
"scope": scope,
"auto_add_unmatched": bool(args.get("auto_add_unmatched") or False),
"concurrency": max(1, min(32, int(args.get("concurrency") or 4))),
}
ne_ids = args.get("ne_ids")
if isinstance(ne_ids, list):
body["ne_ids"] = [str(x).strip() for x in ne_ids if str(x).strip()]
return http_post_json("/v1/topology/fabric/discover", body, timeout=60.0)
def _get_lldp_discover_job(args: dict[str, Any]) -> dict[str, Any]:
job_id = str(args.get("job_id") or "").strip()
if not job_id:
return {"ok": False, "error": "job_id_required", "error_code": "job_id_required"}
return http_json("GET", f"/v1/topology/fabric/discover/{job_id}", params=None)
def _list_topology_views(args: dict[str, Any]) -> dict[str, Any]:
_ = args
return http_json("GET", "/v1/topology/views", params=None)
def _get_topology_view(args: dict[str, Any]) -> dict[str, Any]:
view_id = str(args.get("view_id") or "").strip()
if not view_id:
return {"ok": False, "error": "view_id_required", "error_code": "view_id_required"}
return http_json("GET", f"/v1/topology/views/{view_id}", params=None)
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
{
"name": "queryUmeAlarms",
@ -469,6 +544,96 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"additionalProperties": False,
},
},
{
"name": "getTopologySummary",
"description": "Fabric topology summary: node/edge counts (active/stale) and last LLDP discover time.",
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "queryTopologyNodes",
"description": "Page fabric topology nodes (keyword optional). Never dumps full fabric.",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "queryTopologyEdges",
"description": "Page fabric edges (physical layer by default); filter by node_id/status/source.",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string"},
"layer": {"type": "string", "default": "physical"},
"status": {"type": "string", "enum": ["active", "stale"]},
"source": {"type": "string", "enum": ["lldp", "manual", "stale"]},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "getTopologyNeighborhood",
"description": "Get fabric neighborhood around a node (depth 1-3).",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string"},
"depth": {"type": "integer", "minimum": 1, "maximum": 3, "default": 1},
"layer": {"type": "string", "default": "physical"},
},
"required": ["node_id"],
"additionalProperties": False,
},
},
{
"name": "runLldpDiscover",
"description": "Start async LLDP discovery into fabric (scope=ne_ids|all_inventory). Poll with getLldpDiscoverJob.",
"inputSchema": {
"type": "object",
"properties": {
"scope": {"type": "string", "enum": ["ne_ids", "all_inventory"], "default": "ne_ids"},
"ne_ids": {"type": "array", "items": {"type": "string"}},
"auto_add_unmatched": {"type": "boolean", "default": False},
"concurrency": {"type": "integer", "minimum": 1, "maximum": 32, "default": 4},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "getLldpDiscoverJob",
"description": "Get LLDP discover job status, counters, and per-NE items/unmatched.",
"inputSchema": {
"type": "object",
"properties": {"job_id": {"type": "string"}},
"required": ["job_id"],
"additionalProperties": False,
},
},
{
"name": "listTopologyViews",
"description": "List topology views (presentation canvases over fabric).",
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "getTopologyView",
"description": "Get a topology view graph (nodes with positions + fabric edges between them).",
"inputSchema": {
"type": "object",
"properties": {"view_id": {"type": "string"}},
"required": ["view_id"],
"additionalProperties": False,
},
},
]
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
@ -485,6 +650,14 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"getManagedNe": _get_managed_ne,
"execManagedNe": _exec_managed_ne,
"listCliTargets": _list_cli_targets,
"getTopologySummary": _get_topology_summary,
"queryTopologyNodes": _query_topology_nodes,
"queryTopologyEdges": _query_topology_edges,
"getTopologyNeighborhood": _get_topology_neighborhood,
"runLldpDiscover": _run_lldp_discover,
"getLldpDiscoverJob": _get_lldp_discover_job,
"listTopologyViews": _list_topology_views,
"getTopologyView": _get_topology_view,
}