mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 21:53:22 +08:00
MCP: add bizMonitor server, align ops/topology tools, harden CLI exec
- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools mirroring the netxops bizMonitor group. - Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP contracts; surface partial batch failures as tool errors. - CLI hard gate: single-line show/display only; exec job IDs, path containment, owner and ne:exec checks. - Cisco IOSv collection: stable prompt/echo handling over CR. - Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json; sync skills and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8da7acb7d
commit
9085cac742
63 changed files with 3251 additions and 1092 deletions
|
|
@ -7,6 +7,7 @@ from fastapi.responses import Response
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_deps import AuthContext, require_user
|
||||
from .auth_scopes import has_scope
|
||||
from .auth_service import write_audit
|
||||
from .db import get_db
|
||||
from .device_types import SUPPORTED_VENDORS
|
||||
|
|
@ -275,6 +276,10 @@ def api_create_exec_job(
|
|||
|
||||
uid, uname = _actor(ctx)
|
||||
payload = body.model_dump(exclude_none=True)
|
||||
if not has_scope(ctx.scopes, "ne:exec"):
|
||||
raise HTTPException(status_code=403, detail={"error": "insufficient_scope", "required": ["ne:exec"]})
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail={"error": "job_owner_required"})
|
||||
is_batch = bool(
|
||||
payload.get("targets")
|
||||
or payload.get("ne_ids")
|
||||
|
|
@ -330,7 +335,7 @@ def api_create_exec_job(
|
|||
)
|
||||
return out
|
||||
|
||||
ack = start_ne_exec_job(kind=kind, arguments=payload, runner=_runner)
|
||||
ack = start_ne_exec_job(kind=kind, arguments=payload, owner_user_id=uid, runner=_runner)
|
||||
write_audit(
|
||||
db,
|
||||
action="ne.exec_job_start",
|
||||
|
|
@ -349,9 +354,16 @@ def api_get_exec_job(
|
|||
job_id: str,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
):
|
||||
"""Poll a background exec job started via POST /exec-jobs."""
|
||||
_ = ctx
|
||||
return get_ne_exec_job(job_id)
|
||||
"""Poll own jobs; admins (role or admin:users scope) may read any job."""
|
||||
if not has_scope(ctx.scopes, "ne:exec"):
|
||||
raise HTTPException(status_code=403, detail={"error": "insufficient_scope", "required": ["ne:exec"]})
|
||||
uid, _ = _actor(ctx)
|
||||
is_admin = ctx.user.role == "admin" or has_scope(ctx.scopes, "admin:users")
|
||||
out = get_ne_exec_job(job_id, user_id=uid, is_admin=is_admin)
|
||||
if out.get("error_code") in {"job_forbidden", "invalid_job_id", "job_id_required"}:
|
||||
status = 403 if out["error_code"] == "job_forbidden" else 400
|
||||
raise HTTPException(status_code=status, detail=out)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/connect-test")
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
"""Backward-compatible re-exports; prefer ``pip install netx-mcp`` and ``import netx_mcp``."""
|
||||
|
||||
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool
|
||||
|
||||
__all__ = ["HTTP_MCP_TOOLS", "call_http_tool"]
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
from netx_mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,573 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .db import Base, SessionLocal, engine
|
||||
from .importer import aggregate_alarms, query_alarms
|
||||
from .models import AlarmBatch, UmeAlarmCurrent, UmeAlarmHistory, UmeInventoryNE
|
||||
from .ume_client import UMEClient
|
||||
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full, sync_topology_full
|
||||
from .ume_token_store import (
|
||||
clear_shared_token,
|
||||
load_shared_token,
|
||||
release_refresh_lock,
|
||||
save_shared_token,
|
||||
try_acquire_refresh_lock,
|
||||
wait_for_token_update,
|
||||
)
|
||||
|
||||
_UME_CLIENT_SINGLETON = UMEClient(
|
||||
token_loader=lambda: load_shared_token(),
|
||||
token_saver=lambda token, exp: save_shared_token(token, exp),
|
||||
token_clearer=lambda: clear_shared_token(),
|
||||
lock_acquirer=lambda: try_acquire_refresh_lock(),
|
||||
lock_releaser=lambda: release_refresh_lock(),
|
||||
token_waiter=lambda min_exp: wait_for_token_update(min_expires_at_epoch_s=float(min_exp)),
|
||||
)
|
||||
|
||||
|
||||
def _ok(rid: Any, result: dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _err(rid: Any, code: int, message: str) -> None:
|
||||
sys.stdout.write(
|
||||
json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False)
|
||||
+ "\n"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _tool_list() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "queryAlarms",
|
||||
"description": "Query normalized alarms with filters and pagination.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"batch_id": {"type": "string"},
|
||||
"alarm_code": {"type": "string"},
|
||||
"severity": {"type": "string"},
|
||||
"ne_name": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 200},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "aggregateAlarms",
|
||||
"description": "Aggregate alarms by severity_norm, alarm_code or ne_name.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_by": {
|
||||
"type": "string",
|
||||
"enum": ["severity_norm", "alarm_code", "ne_name"],
|
||||
},
|
||||
"batch_id": {"type": "string"},
|
||||
},
|
||||
"required": ["group_by"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "getImportBatch",
|
||||
"description": "Get imported batch summary by batch_id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"batch_id": {"type": "string"}},
|
||||
"required": ["batch_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "runDiagnostics",
|
||||
"description": "Generate quick diagnostics summary by batch.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"batch_id": {"type": "string"}},
|
||||
"required": ["batch_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "umeSync",
|
||||
"description": "Trigger UME sync for inventory/current/history domains.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domains": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["inventory", "alarms_current", "alarms_history", "topology"]},
|
||||
},
|
||||
"trigger_mode": {"type": "string", "enum": ["manual", "schedule"]},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "umeListNE",
|
||||
"description": "List UME network elements from inventory table.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "umeGetNE",
|
||||
"description": "Get UME network element by ne_id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"ne_id": {"type": "string"}},
|
||||
"required": ["ne_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "umeListCurrentAlarms",
|
||||
"description": "List current UME alarms from current table.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"severity": {"type": "string"},
|
||||
"is_cleared": {"type": "string"},
|
||||
"ne_id": {"type": "string"},
|
||||
"keyword": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "umeListHistoryAlarms",
|
||||
"description": "List historical UME alarms from history table.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"severity": {"type": "string"},
|
||||
"ne_id": {"type": "string"},
|
||||
"keyword": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
with SessionLocal() as db:
|
||||
if name == "queryAlarms":
|
||||
total, rows = query_alarms(
|
||||
db,
|
||||
batch_id=str(args.get("batch_id") or "").strip() or None,
|
||||
alarm_code=str(args.get("alarm_code") or "").strip() or None,
|
||||
severity=str(args.get("severity") or "").strip() or None,
|
||||
ne_name=str(args.get("ne_name") or "").strip() or None,
|
||||
page=max(1, int(args.get("page") or 1)),
|
||||
page_size=min(200, max(1, int(args.get("page_size") or 50))),
|
||||
)
|
||||
payload = {
|
||||
"total": total,
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"batch_id": r.batch_id,
|
||||
"alarm_time": r.alarm_time.isoformat(),
|
||||
"severity_norm": r.severity_norm,
|
||||
"ne_name": r.ne_name,
|
||||
"alarm_code": r.alarm_code,
|
||||
"alarm_name": r.alarm_name,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "aggregateAlarms":
|
||||
group_by = str(args.get("group_by") or "").strip()
|
||||
rows = aggregate_alarms(
|
||||
db,
|
||||
group_by=group_by,
|
||||
batch_id=str(args.get("batch_id") or "").strip() or None,
|
||||
)
|
||||
payload = {"group_by": group_by, "buckets": [{"key": k, "count": v} for k, v in rows]}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "getImportBatch":
|
||||
batch_id = str(args.get("batch_id") or "").strip()
|
||||
batch = db.get(AlarmBatch, batch_id)
|
||||
if not batch:
|
||||
return {"content": [{"type": "text", "text": json.dumps({"error": "batch_not_found"})}], "isError": True}
|
||||
payload = {
|
||||
"batch_id": batch.batch_id,
|
||||
"total_rows": batch.total_rows,
|
||||
"success_rows": batch.success_rows,
|
||||
"failed_rows": batch.failed_rows,
|
||||
"status": batch.status,
|
||||
"created_at": batch.created_at.isoformat(),
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "runDiagnostics":
|
||||
batch_id = str(args.get("batch_id") or "").strip()
|
||||
sev_rows = aggregate_alarms(db, group_by="severity_norm", batch_id=batch_id)
|
||||
code_rows = aggregate_alarms(db, group_by="alarm_code", batch_id=batch_id)[:5]
|
||||
ne_rows = aggregate_alarms(db, group_by="ne_name", batch_id=batch_id)[:5]
|
||||
sev_map = {k: v for k, v in sev_rows}
|
||||
findings: list[str] = []
|
||||
actions: list[str] = []
|
||||
risk_level = "low"
|
||||
if int(sev_map.get("critical", 0)) > 0:
|
||||
findings.append("critical 告警存在,建议优先确认核心网元影响。")
|
||||
actions.append("优先处理 critical 告警,确认影响面并升级。")
|
||||
risk_level = "high"
|
||||
if int(sev_map.get("warning", 0)) > int(sev_map.get("major", 0)):
|
||||
findings.append("warning 占比较高,疑似阈值型告警风暴。")
|
||||
actions.append("检查高频 warning 告警码是否集中在单一阈值策略。")
|
||||
if risk_level != "high":
|
||||
risk_level = "medium"
|
||||
if not findings:
|
||||
findings.append("分布相对均衡,建议按 top 告警码进一步排查。")
|
||||
actions.append("按 top 告警码和网元继续下钻分析。")
|
||||
payload = {
|
||||
"batch_id": batch_id,
|
||||
"risk_level": risk_level,
|
||||
"severity_summary": [{"key": k, "count": v} for k, v in sev_rows],
|
||||
"top_alarm_codes": [{"key": k, "count": v} for k, v in code_rows],
|
||||
"top_ne": [{"key": k, "count": v} for k, v in ne_rows],
|
||||
"findings": findings,
|
||||
"actions": actions,
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "umeSync":
|
||||
domains_raw = args.get("domains")
|
||||
domains = []
|
||||
if isinstance(domains_raw, list):
|
||||
domains = [str(x).strip() for x in domains_raw if str(x).strip()]
|
||||
if not domains:
|
||||
domains = ["inventory", "alarms_current", "alarms_history"]
|
||||
trigger_mode = str(args.get("trigger_mode") or "manual").strip().lower()
|
||||
if trigger_mode not in {"manual", "schedule"}:
|
||||
trigger_mode = "manual"
|
||||
client = _UME_CLIENT_SINGLETON
|
||||
results: list[dict[str, Any]] = []
|
||||
if "inventory" in domains:
|
||||
j = sync_inventory_full(db, client, trigger_mode=trigger_mode)
|
||||
results.append(
|
||||
{
|
||||
"domain": "inventory",
|
||||
"status": j.status,
|
||||
"pulled_count": int(j.pulled_count or 0),
|
||||
"inserted_count": int(j.inserted_count or 0),
|
||||
"updated_count": int(j.updated_count or 0),
|
||||
"error_message": str(j.error_message or ""),
|
||||
}
|
||||
)
|
||||
if "alarms_current" in domains:
|
||||
j, b = sync_alarms_current(db, client, trigger_mode=trigger_mode)
|
||||
results.append(
|
||||
{
|
||||
"domain": "alarms_current",
|
||||
"status": j.status,
|
||||
"batch_id": str(b.batch_id),
|
||||
"pulled_count": int(j.pulled_count or 0),
|
||||
"inserted_count": int(j.inserted_count or 0),
|
||||
"updated_count": int(j.updated_count or 0),
|
||||
"error_message": str(j.error_message or ""),
|
||||
}
|
||||
)
|
||||
if "alarms_history" in domains:
|
||||
j, b = sync_alarms_history_full(db, client, trigger_mode=trigger_mode)
|
||||
results.append(
|
||||
{
|
||||
"domain": "alarms_history",
|
||||
"status": j.status,
|
||||
"batch_id": str(b.batch_id),
|
||||
"pulled_count": int(j.pulled_count or 0),
|
||||
"inserted_count": int(j.inserted_count or 0),
|
||||
"updated_count": int(j.updated_count or 0),
|
||||
"error_message": str(j.error_message or ""),
|
||||
}
|
||||
)
|
||||
if "topology" in domains:
|
||||
j = sync_topology_full(db, client, trigger_mode=trigger_mode)
|
||||
results.append(
|
||||
{
|
||||
"domain": "topology",
|
||||
"status": j.status,
|
||||
"pulled_count": int(j.pulled_count or 0),
|
||||
"inserted_count": int(j.inserted_count or 0),
|
||||
"updated_count": int(j.updated_count or 0),
|
||||
"error_message": str(j.error_message or ""),
|
||||
}
|
||||
)
|
||||
return {"content": [{"type": "text", "text": json.dumps({"ok": True, "jobs": results}, ensure_ascii=False)}]}
|
||||
if name == "umeListNE":
|
||||
stmt = db.query(UmeInventoryNE)
|
||||
keyword = str(args.get("keyword") or "").strip()
|
||||
if keyword:
|
||||
stmt = stmt.filter(
|
||||
UmeInventoryNE.ne_id.contains(keyword)
|
||||
| UmeInventoryNE.ne_name.contains(keyword)
|
||||
| UmeInventoryNE.user_label.contains(keyword)
|
||||
| UmeInventoryNE.ip_address.contains(keyword)
|
||||
)
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
total = int(stmt.count())
|
||||
rows = stmt.order_by(UmeInventoryNE.ne_id.asc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
payload = {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": [
|
||||
{
|
||||
"ne_id": str(x.ne_id or ""),
|
||||
"ne_name": str(x.ne_name or ""),
|
||||
"user_label": str(x.user_label or ""),
|
||||
"ip_address": str(x.ip_address or ""),
|
||||
"ne_type": str(x.ne_type or ""),
|
||||
}
|
||||
for x in rows
|
||||
],
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "umeGetNE":
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
row = db.get(UmeInventoryNE, ne_id)
|
||||
if not row:
|
||||
return {"content": [{"type": "text", "text": json.dumps({"error": "ume_ne_not_found"})}], "isError": True}
|
||||
payload = {
|
||||
"ne_id": str(row.ne_id or ""),
|
||||
"ne_name": str(row.ne_name or ""),
|
||||
"user_label": str(row.user_label or ""),
|
||||
"ip_address": str(row.ip_address or ""),
|
||||
"ne_type": str(row.ne_type or ""),
|
||||
"vendor": str(row.vendor or ""),
|
||||
"raw_json": str(row.raw_json or "{}"),
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "umeListCurrentAlarms":
|
||||
stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin(
|
||||
UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id
|
||||
)
|
||||
severity = str(args.get("severity") or "").strip()
|
||||
is_cleared = str(args.get("is_cleared") or "").strip()
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
keyword = str(args.get("keyword") or "").strip()
|
||||
if severity:
|
||||
stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == severity)
|
||||
if is_cleared:
|
||||
stmt = stmt.filter(UmeAlarmCurrent.is_cleared == is_cleared)
|
||||
if ne_id:
|
||||
stmt = stmt.filter(UmeAlarmCurrent.ne_id == ne_id)
|
||||
if keyword:
|
||||
stmt = stmt.filter(
|
||||
UmeAlarmCurrent.alarm_key.contains(keyword)
|
||||
| UmeAlarmCurrent.object_name.contains(keyword)
|
||||
| UmeAlarmCurrent.host_name.contains(keyword)
|
||||
| UmeInventoryNE.ne_name.contains(keyword)
|
||||
| UmeInventoryNE.user_label.contains(keyword)
|
||||
| UmeInventoryNE.ip_address.contains(keyword)
|
||||
| UmeInventoryNE.host_name.contains(keyword)
|
||||
)
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
total = int(stmt.count())
|
||||
rows = (
|
||||
stmt.order_by(
|
||||
UmeAlarmCurrent.time_created.desc(),
|
||||
UmeAlarmCurrent.last_seen_at.desc(),
|
||||
UmeAlarmCurrent.alarm_key.desc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
payload = {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": [
|
||||
{
|
||||
"alarm_key": str(alarm.alarm_key or ""),
|
||||
"ne_id": str(alarm.ne_id or ""),
|
||||
"host_name": str(alarm.host_name or (ne.host_name if ne else "") or ""),
|
||||
"ne_name": str((ne.ne_name if ne else "") or ""),
|
||||
"user_label": str((ne.user_label if ne else "") or ""),
|
||||
"object_name": str(alarm.object_name or ""),
|
||||
"event_type": str(alarm.event_type or ""),
|
||||
"native_probable_cause": str(alarm.native_probable_cause or ""),
|
||||
"perceived_severity": str(alarm.perceived_severity or ""),
|
||||
"is_cleared": str(alarm.is_cleared or ""),
|
||||
"time_created": str(alarm.time_created or ""),
|
||||
}
|
||||
for alarm, ne in rows
|
||||
],
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "umeListHistoryAlarms":
|
||||
stmt = db.query(UmeAlarmHistory, UmeInventoryNE).outerjoin(
|
||||
UmeInventoryNE, UmeAlarmHistory.ne_id == UmeInventoryNE.ne_id
|
||||
)
|
||||
severity = str(args.get("severity") or "").strip()
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
keyword = str(args.get("keyword") or "").strip()
|
||||
if severity:
|
||||
stmt = stmt.filter(UmeAlarmHistory.perceived_severity == severity)
|
||||
if ne_id:
|
||||
stmt = stmt.filter(UmeAlarmHistory.ne_id == ne_id)
|
||||
if keyword:
|
||||
stmt = stmt.filter(
|
||||
UmeAlarmHistory.alarm_key.contains(keyword)
|
||||
| UmeAlarmHistory.object_name.contains(keyword)
|
||||
| UmeAlarmHistory.host_name.contains(keyword)
|
||||
| UmeInventoryNE.ne_name.contains(keyword)
|
||||
| UmeInventoryNE.user_label.contains(keyword)
|
||||
| UmeInventoryNE.ip_address.contains(keyword)
|
||||
| UmeInventoryNE.host_name.contains(keyword)
|
||||
)
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
total = int(stmt.count())
|
||||
rows = stmt.order_by(UmeAlarmHistory.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
payload = {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": [
|
||||
{
|
||||
"alarm_key": str(alarm.alarm_key or ""),
|
||||
"ne_id": str(alarm.ne_id or ""),
|
||||
"host_name": str(alarm.host_name or (ne.host_name if ne else "") or ""),
|
||||
"ne_name": str((ne.ne_name if ne else "") or ""),
|
||||
"user_label": str((ne.user_label if ne else "") or ""),
|
||||
"object_name": str(alarm.object_name or ""),
|
||||
"event_type": str(alarm.event_type or ""),
|
||||
"native_probable_cause": str(alarm.native_probable_cause or ""),
|
||||
"perceived_severity": str(alarm.perceived_severity or ""),
|
||||
"is_cleared": str(alarm.is_cleared or ""),
|
||||
"time_created": str(alarm.time_created or ""),
|
||||
}
|
||||
for alarm, ne in rows
|
||||
],
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
raise ValueError(f"unknown tool: {name}")
|
||||
|
||||
|
||||
def _ensure_db_schema() -> None:
|
||||
try:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS device_level VARCHAR(64) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS host_name VARCHAR(256) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS location VARCHAR(512) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS ipv6_address VARCHAR(128) DEFAULT ''")
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS hardware_version VARCHAR(128) DEFAULT ''"
|
||||
)
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS loopback VARCHAR(128) DEFAULT ''")
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS consistent_state VARCHAR(64) DEFAULT ''"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS interface_version VARCHAR(128) DEFAULT ''"
|
||||
)
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS mac VARCHAR(128) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS admin_status VARCHAR(64) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS address_type VARCHAR(64) DEFAULT ''")
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS connection_status VARCHAR(64) DEFAULT ''"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS maintain_status VARCHAR(64) DEFAULT ''"
|
||||
)
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS net_mask VARCHAR(128) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS create_time VARCHAR(64) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS creator VARCHAR(128) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS ne_name")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS user_label")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS ne_name")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS user_label")
|
||||
conn.exec_driver_sql("COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_type IS '网元类型'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.user_label IS '用户标签'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.address_type IS '管理地址类型(1:IPv4,2:IPv6)'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ip_address IS '网元IPv4地址'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.net_mask IS '管理IPv4掩码(点分十进制)'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ipv6_address IS 'IPv6地址'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.admin_status IS '管理状态(0-离线,1-在线)'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.connection_status IS '连接状态(0-断链,1-正常)'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.consistent_state IS '数据一致性状态(1一致,2不一致,3冲突)'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.maintain_status IS '工程状态(0普通,1调测,2新建)'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.vendor IS '网元提供商'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.interface_version IS '网元接口版本号'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.hardware_version IS '硬件版本'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.mac IS '设备机架MAC地址'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.loopback IS '业务环回IP(IPv4)'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.device_level IS '网元层次'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.host_name IS '主机名称'")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_stdio_loop() -> None:
|
||||
"""Legacy MCP: direct PostgreSQL access (deprecated; use HTTP mode)."""
|
||||
_ensure_db_schema()
|
||||
for line in sys.stdin:
|
||||
raw = line.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
rid = req.get("id")
|
||||
method = str(req.get("method") or "")
|
||||
params = req.get("params") if isinstance(req.get("params"), dict) else {}
|
||||
|
||||
try:
|
||||
if method == "initialize":
|
||||
_ok(
|
||||
rid,
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "netx-mcp", "version": "0.1.0"},
|
||||
},
|
||||
)
|
||||
continue
|
||||
if method == "notifications/initialized":
|
||||
continue
|
||||
if method == "tools/list":
|
||||
_ok(rid, {"tools": _tool_list()})
|
||||
continue
|
||||
if method == "tools/call":
|
||||
name = str(params.get("name") or "")
|
||||
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
||||
_ok(rid, _call_tool(name, args))
|
||||
continue
|
||||
_err(rid, -32601, f"method not found: {method}")
|
||||
except Exception as exc:
|
||||
_err(rid, -32000, str(exc))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
run_stdio_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""Re-export from ``netx-mcp`` package (install: ``pip install -e packages/netx-mcp``)."""
|
||||
|
||||
from netx_mcp.http_client import * # noqa: F403
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""Re-export from ``netx-mcp`` package (install: ``pip install -e packages/netx-mcp``)."""
|
||||
|
||||
from netx_mcp.http_tools import * # noqa: F403
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"""Backward-compatible entry for ``python netx_api/mcp_server.py`` / ``netx-mcp`` script on netx-ops install.
|
||||
|
||||
HTTP MCP: delegates to ``netx_mcp`` (``pip install -e packages/netx-mcp``).
|
||||
Legacy DB mode (``NETX_MCP_MODE=db``): requires full ``netx-ops`` install only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Script-path launch without pip install netx-mcp: add packages/netx-mcp/src
|
||||
_repo_root = Path(__file__).resolve().parent.parent
|
||||
_mcp_src = _repo_root / "packages" / "netx-mcp" / "src"
|
||||
if _mcp_src.is_dir() and str(_mcp_src) not in sys.path:
|
||||
sys.path.insert(0, str(_mcp_src))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if str(os.getenv("NETX_MCP_MODE") or "http").strip().lower() == "db":
|
||||
from netx_api.mcp.db_server import run_stdio_loop
|
||||
|
||||
run_stdio_loop()
|
||||
return
|
||||
from netx_mcp.server import main as mcp_main
|
||||
|
||||
mcp_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -87,21 +87,28 @@ def _collect_on_device(
|
|||
if conn_holder is not None:
|
||||
conn_holder["conn"] = conn
|
||||
conn_holder["session_log"] = log_buf
|
||||
# Belt-and-suspenders: hop/bastion nested CLIs and missed session_prep.
|
||||
try:
|
||||
disable_target_paging(
|
||||
conn,
|
||||
vendor=str(creds.get("vendor") or ""),
|
||||
device_type=str(creds.get("device_type") or ""),
|
||||
)
|
||||
except Exception:
|
||||
_log.debug("collection paging disable failed", exc_info=True)
|
||||
prompt = str(conn.find_prompt() or "")
|
||||
# find_prompt / paging-off often leave an extra prompt in the channel;
|
||||
# drain before the first show or Netmiko returns empty (IOSv).
|
||||
try:
|
||||
from .ne_netmiko import drain_read_channel
|
||||
from .ne_netmiko import drain_read_channel, is_cisco_ios_device_type
|
||||
|
||||
device_type = str(creds.get("device_type") or "")
|
||||
# Direct Cisco session_prep already set width/length. A second timing-based
|
||||
# ``terminal length 0`` plus find_prompt() re-introduces leftover prompts
|
||||
# and line-buffer races on IOSv. Keep the extra paging pass for hop/other.
|
||||
need_paging_pass = bool(creds.get("hop_enabled")) or not is_cisco_ios_device_type(
|
||||
device_type
|
||||
)
|
||||
if need_paging_pass:
|
||||
try:
|
||||
disable_target_paging(
|
||||
conn,
|
||||
vendor=str(creds.get("vendor") or ""),
|
||||
device_type=device_type,
|
||||
)
|
||||
except Exception:
|
||||
_log.debug("collection paging disable failed", exc_info=True)
|
||||
# Transcript header only — never find_prompt() here (sends Enter).
|
||||
base = str(getattr(conn, "base_prompt", "") or "").strip()
|
||||
prompt = f"{base}#" if base else ""
|
||||
try:
|
||||
drain_read_channel(conn)
|
||||
except Exception:
|
||||
_log.debug("collection channel drain failed", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -24,6 +25,8 @@ _INFLIGHT = 0
|
|||
_DEFAULT_TIMEOUT_S = 900
|
||||
_DEFAULT_MAX_CONCURRENT = 3
|
||||
_DEFAULT_ASYNC_MIN_NES = 4
|
||||
_JOB_ID_RE = re.compile(r"[0-9a-f]{32}")
|
||||
_MAX_JOB_TARGETS = 20 # Same combined limit as ne_exec._normalize_batch_targets.
|
||||
|
||||
|
||||
def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int:
|
||||
|
|
@ -72,7 +75,14 @@ def _jobs_dir() -> Path:
|
|||
|
||||
|
||||
def _job_path(job_id: str) -> Path:
|
||||
return _jobs_dir() / f"{job_id}.json"
|
||||
if not isinstance(job_id, str) or not _JOB_ID_RE.fullmatch(job_id):
|
||||
raise ValueError("invalid_job_id")
|
||||
directory = _jobs_dir().resolve()
|
||||
path = (directory / f"{job_id}.json").resolve()
|
||||
# Also reject a valid-looking filename symlinked outside the job directory.
|
||||
if path.parent != directory:
|
||||
raise ValueError("invalid_job_id")
|
||||
return path
|
||||
|
||||
|
||||
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
|
||||
|
|
@ -90,13 +100,11 @@ def _read_job(job_id: str) -> dict[str, Any] | None:
|
|||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
return data if isinstance(data, dict) and data.get("job_id") == job_id else None
|
||||
|
||||
|
||||
def _write_job(job: dict[str, Any]) -> None:
|
||||
jid = str(job.get("job_id") or "").strip()
|
||||
if not jid:
|
||||
return
|
||||
jid = job.get("job_id")
|
||||
_atomic_write(_job_path(jid), job)
|
||||
|
||||
|
||||
|
|
@ -116,17 +124,16 @@ def default_job_timeout_s() -> int:
|
|||
def count_exec_ne_targets(args: dict[str, Any] | None) -> int:
|
||||
a = args if isinstance(args, dict) else {}
|
||||
n = 0
|
||||
for key in ("ne_ids", "ume_ne_ids", "nms_ne_ids"):
|
||||
for key in ("ne_ids", "ume_ne_ids"):
|
||||
val = a.get(key)
|
||||
if isinstance(val, list):
|
||||
n = max(n, len([x for x in val if str(x or "").strip()]))
|
||||
n += len([x for x in val if str(x or "").strip()])
|
||||
targets = a.get("targets")
|
||||
if isinstance(targets, list):
|
||||
n = max(n, len([t for t in targets if isinstance(t, dict)]))
|
||||
n += len([t for t in targets if isinstance(t, dict)])
|
||||
if n == 0 and (
|
||||
str(a.get("ne_id") or "").strip()
|
||||
or str(a.get("ume_ne_id") or "").strip()
|
||||
or str(a.get("nms_ne_id") or "").strip()
|
||||
):
|
||||
return 1
|
||||
return int(n)
|
||||
|
|
@ -164,11 +171,18 @@ def start_ne_exec_job(
|
|||
*,
|
||||
kind: str,
|
||||
arguments: dict[str, Any],
|
||||
owner_user_id: str,
|
||||
runner: Callable[[], dict[str, Any]],
|
||||
timeout_s: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Start background runner; returns immediate ack with job_id."""
|
||||
global _INFLIGHT
|
||||
owner = str(owner_user_id or "").strip()
|
||||
if not owner:
|
||||
return {"ok": False, "error_code": "job_owner_required", "error": "job_owner_required"}
|
||||
ne_count = count_exec_ne_targets(arguments)
|
||||
if ne_count > _MAX_JOB_TARGETS:
|
||||
return {"ok": False, "error_code": "too_many_targets", "error": "too_many_targets", "max_targets": _MAX_JOB_TARGETS}
|
||||
max_c = max_concurrent_jobs()
|
||||
with _LOCK:
|
||||
if _INFLIGHT >= max_c:
|
||||
|
|
@ -189,10 +203,11 @@ def start_ne_exec_job(
|
|||
now = int(time.time() * 1000)
|
||||
job = {
|
||||
"job_id": job_id,
|
||||
"owner_user_id": owner,
|
||||
"status": _STATUS_RUNNING,
|
||||
"kind": str(kind or "exec"),
|
||||
"arguments": dict(arguments or {}),
|
||||
"ne_count": count_exec_ne_targets(arguments),
|
||||
"ne_count": ne_count,
|
||||
"created_at_ms": now,
|
||||
"updated_at_ms": now,
|
||||
"timeout_s": timeout,
|
||||
|
|
@ -260,13 +275,26 @@ def start_ne_exec_job(
|
|||
}
|
||||
|
||||
|
||||
def get_ne_exec_job(job_id: str) -> dict[str, Any]:
|
||||
jid = str(job_id or "").strip()
|
||||
def get_ne_exec_job(job_id: str, *, user_id: str, is_admin: bool = False) -> dict[str, Any]:
|
||||
"""Read only the caller's jobs; explicit admins may inspect any persisted job.
|
||||
|
||||
Ownerless legacy records are never granted to ordinary callers.
|
||||
"""
|
||||
jid = job_id
|
||||
if not jid:
|
||||
return {"ok": False, "error_code": "job_id_required", "error": "job_id_required"}
|
||||
job = _read_job(jid)
|
||||
try:
|
||||
job = _read_job(jid)
|
||||
except ValueError:
|
||||
return {"ok": False, "error_code": "invalid_job_id", "error": "invalid_job_id"}
|
||||
requester = str(user_id or "").strip()
|
||||
if not requester:
|
||||
return {"ok": False, "error_code": "job_forbidden", "error": "job_forbidden"}
|
||||
if not job:
|
||||
return {"ok": False, "error_code": "job_not_found", "error": "job_not_found", "job_id": jid}
|
||||
owner = str(job.get("owner_user_id") or "").strip()
|
||||
if not is_admin and (not owner or owner != requester):
|
||||
return {"ok": False, "error_code": "job_forbidden", "error": "job_forbidden"}
|
||||
status = str(job.get("status") or "")
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -138,6 +139,35 @@ def drain_read_channel(
|
|||
|
||||
def _send_command_expect_prompt(conn: Any, cmd: str, *, read_timeout: int) -> str:
|
||||
device_type = getattr(conn, "device_type", "")
|
||||
if isinstance(device_type, str) and is_cisco_ios_device_type(device_type):
|
||||
# IOSv has the same late-prompt race as RouterOS: auto_find_prompt sends
|
||||
# a newline after our drain and its reply can terminate the next show.
|
||||
# Use the session's cached hostname, but require a full prompt line so
|
||||
# "R2 uptime ..." inside show version cannot end the command early.
|
||||
#
|
||||
# cmd_verify must stay False: Netmiko hardcodes a 10s echo wait, and
|
||||
# IOSv often echoes ``show version | include ...`` slower than that
|
||||
# (live: ~13s) even when the command itself succeeds. Correctness comes
|
||||
# from drain_read_channel + full-line expect_string, not echo verify.
|
||||
base_prompt = getattr(conn, "base_prompt", "")
|
||||
if not isinstance(base_prompt, str) or not base_prompt.strip():
|
||||
raise ValueError("cisco_base_prompt_missing")
|
||||
prompt_pattern = rf"(?m:^{re.escape(base_prompt.strip())}[>#][ \t]*$)"
|
||||
out = str(
|
||||
conn.send_command(
|
||||
command_string=cmd,
|
||||
read_timeout=read_timeout,
|
||||
auto_find_prompt=False,
|
||||
expect_string=prompt_pattern,
|
||||
cmd_verify=False,
|
||||
)
|
||||
or ""
|
||||
)
|
||||
# Leftover-prompt races still yield a silent empty body with ok=true at
|
||||
# higher layers. Fail loud here — never re-send the command.
|
||||
if not out.strip():
|
||||
raise ValueError("cisco_empty_show_output")
|
||||
return out
|
||||
if isinstance(device_type, str) and device_type.lower().startswith("mikrotik"):
|
||||
# find_prompt sends an extra Enter *after* our channel drain. RouterOS
|
||||
# can repaint that old prompt after send_command starts, ending the read
|
||||
|
|
@ -179,8 +209,10 @@ def send_show_command(conn: Any, command: str, *, read_timeout: int = 120) -> st
|
|||
Prefer ``send_command`` over ``send_command_timing`` for long config dumps:
|
||||
idle during ``Building configuration...`` truncates timing reads.
|
||||
|
||||
``cmd_verify=False``: Netmiko's default echo check often raises
|
||||
``Pattern not detected: 'show\\ lldp\\ ...'`` on IOSv / hop / slow echo paths.
|
||||
Cisco uses ``cmd_verify=False`` with a full-line prompt expect after drain;
|
||||
Netmiko's echo verify is hardcoded to 10s and false-fails slow IOSv echoes.
|
||||
Other drivers also use ``cmd_verify=False``: the default echo check often
|
||||
raises ``Pattern not detected`` on hop / slow echo paths.
|
||||
"""
|
||||
cmd = str(command or "").strip()
|
||||
if not cmd:
|
||||
|
|
@ -188,5 +220,9 @@ def send_show_command(conn: Any, command: str, *, read_timeout: int = 120) -> st
|
|||
|
||||
# IOSv / hop: find_prompt / paging-off often leave an extra prompt in the
|
||||
# channel; drain so send_command does not match that leftover immediately.
|
||||
drain_read_channel(conn)
|
||||
device_type = getattr(conn, "device_type", "")
|
||||
if isinstance(device_type, str) and is_cisco_ios_device_type(device_type):
|
||||
drain_read_channel(conn, idle_reads=6, pause_sec=0.08)
|
||||
else:
|
||||
drain_read_channel(conn)
|
||||
return _send_command_expect_prompt(conn, cmd, read_timeout=read_timeout)
|
||||
|
|
|
|||
|
|
@ -304,24 +304,23 @@ class _ProgressSessionLog:
|
|||
|
||||
|
||||
def _cisco_ios_collection_driver_class(base_cls: type) -> type:
|
||||
"""Cisco IOSv-friendly session prep: avoid cmd_verify on terminal width/length."""
|
||||
"""Cisco IOSv collection prep: establish hostname prompt only (like interactive)."""
|
||||
|
||||
class _CiscoIosCollectionSession(base_cls): # type: ignore[misc,valid-type]
|
||||
def session_preparation(self) -> None:
|
||||
# Default Netmiko waits for exact echo of "terminal width 511" (ReadTimeout on IOSv).
|
||||
self._test_channel_read(pattern=r"[>#]")
|
||||
try:
|
||||
self.set_terminal_width(command="terminal width 511", pattern=r"[>#]")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.disable_paging(command="terminal length 0", cmd_verify=False, pattern=r"[>#]")
|
||||
except Exception:
|
||||
try:
|
||||
self.send_command_timing("terminal length 0", read_timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
self.set_base_prompt()
|
||||
# Stock Netmiko width/paging on IOSv+CR races leftover prompts
|
||||
# (``terexit`` / ``terminal lengexit``) or blocks forever waiting for
|
||||
# setup echo once VTY pressure builds. The interactive WebCRT path
|
||||
# skips these commands and still returns complete show output; MCP
|
||||
# show/display bodies are short enough that --More-- is not required.
|
||||
# Keep hostname discovery so send_show_command can use a full-line
|
||||
# prompt expect without auto_find_prompt.
|
||||
from .ne_netmiko import drain_read_channel
|
||||
|
||||
initial_pattern = r"(?m:^[^\r\n#>]+[>#][ \t]*$)"
|
||||
self._test_channel_read(pattern=initial_pattern)
|
||||
self.set_base_prompt(pattern=initial_pattern)
|
||||
drain_read_channel(self, idle_reads=8, pause_sec=0.1)
|
||||
|
||||
_CiscoIosCollectionSession.__name__ = (
|
||||
f"CiscoIosCollection{getattr(base_cls, '__name__', 'Netmiko')}"
|
||||
|
|
@ -508,6 +507,13 @@ def _base_connect_kwargs(
|
|||
"auth_timeout": timeout,
|
||||
"banner_timeout": timeout,
|
||||
}
|
||||
# IOSv SSH accepts CR as Enter. LF alone can leave command input pending
|
||||
# until a later write, producing missing echo, empty output or prompt timeouts.
|
||||
# Keep Telnet's own line ending and other vendors' defaults unchanged.
|
||||
from .ne_netmiko import is_cisco_ios_device_type
|
||||
|
||||
if is_cisco_ios_device_type(device_type) and "telnet" not in device_type.lower():
|
||||
dev["default_enter"] = "\r"
|
||||
if session_timeout is not None:
|
||||
dev["session_timeout"] = session_timeout
|
||||
if keepalive is not None and int(keepalive) > 0:
|
||||
|
|
|
|||
|
|
@ -749,6 +749,8 @@ def patch_view_positions(
|
|||
updated += 1
|
||||
else:
|
||||
from .topology_region_canvas import add_missing_region_placement, is_region_canvas_node_id
|
||||
from .ume_topology_world import is_ume_canvas_view, is_world_flat_view
|
||||
from .ume_topology_world_graph import get_level_view_graph
|
||||
|
||||
positions = list(body.positions or [])
|
||||
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
|
||||
|
|
@ -769,6 +771,24 @@ def patch_view_positions(
|
|||
.filter(TopoFabricNode.id.in_(check_ids))
|
||||
.all()
|
||||
}
|
||||
# UME canvases synthesize their members instead of adding them via /nodes.
|
||||
# Persist placements only for actual members of this canvas; a position
|
||||
# patch must never add unrelated fabric nodes to an ordinary view.
|
||||
ume_member_ids: set[str] = set()
|
||||
if is_ume_canvas_view(view) and any(
|
||||
str(p.fabric_node_id or "").strip() not in existing for p in positions
|
||||
):
|
||||
if is_world_flat_view(view):
|
||||
ume_member_ids = {
|
||||
str(row[0])
|
||||
for row in db.query(TopoFabricNode.id).filter(
|
||||
TopoFabricNode.id.in_(check_ids),
|
||||
TopoFabricNode.world_x.isnot(None),
|
||||
TopoFabricNode.world_y.isnot(None),
|
||||
).all()
|
||||
}
|
||||
else:
|
||||
ume_member_ids = {node.fabric_node_id for node in get_level_view_graph(db, view).nodes}
|
||||
for p in positions:
|
||||
fid = str(p.fabric_node_id or "").strip()
|
||||
if not fid:
|
||||
|
|
@ -777,9 +797,17 @@ def patch_view_positions(
|
|||
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
|
||||
row = existing.get(fid)
|
||||
if row is None:
|
||||
if fid in ume_member_ids:
|
||||
row = TopoViewNode(
|
||||
id=uuid4().hex, view_id=view.id, fabric_node_id=fid,
|
||||
x=float(p.x), y=float(p.y), label="", locked=False,
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
# Child-region icons may be synthesized without a placement row; persist those.
|
||||
# Fabric NEs must not bypass membership caps / freeze (MCP adds via /nodes).
|
||||
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
|
||||
else:
|
||||
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
|
||||
if row is None:
|
||||
skipped_missing += 1
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -46,9 +46,6 @@ WORLD_FLAT_DETAIL_CAP = VIEW_GRAPH_NODE_HARD_CAP
|
|||
# Viewport span (display/world units) at/under which we prefer a full region layout
|
||||
# instead of stride-thinning — stops “zoomed in still a peppered blob”.
|
||||
WORLD_FLAT_CLOSE_SPAN = 28000.0
|
||||
# Legacy crush span (pre-LOD). Flat graph is now 1:1 with fabric world_*;
|
||||
# this constant only detects stale TopoViewNode overrides from that era.
|
||||
WORLD_FLAT_DISPLAY_SPAN = 6000.0
|
||||
|
||||
|
||||
def apply_persisted_view_positions(
|
||||
|
|
@ -553,8 +550,6 @@ def get_flat_view_graph(
|
|||
full_max_x = float(extent_row[1])
|
||||
full_min_y = float(extent_row[2])
|
||||
full_max_y = float(extent_row[3])
|
||||
span_x = max(1.0, full_max_x - full_min_x)
|
||||
span_y = max(1.0, full_max_y - full_min_y)
|
||||
# 1:1 with fabric world_* (only origin-shift to ~0). Infinite zoom keeps
|
||||
# each packed region block identical to its UME local relative layout.
|
||||
scale = 1.0
|
||||
|
|
@ -768,25 +763,9 @@ def get_flat_view_graph(
|
|||
)
|
||||
for n in nodes
|
||||
]
|
||||
# Drag overrides in display space (world - origin). Skip legacy rows saved
|
||||
# under the old ~6000 crush — they would yank nodes to the wrong place.
|
||||
override_rows = (
|
||||
db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).limit(8).all()
|
||||
)
|
||||
display_span = max(span_x, span_y)
|
||||
overrides_ok = False
|
||||
if override_rows and display_span > 0:
|
||||
oxs = [abs(float(r.x or 0)) for r in override_rows]
|
||||
oys = [abs(float(r.y or 0)) for r in override_rows]
|
||||
peak = max(oxs + oys) if (oxs or oys) else 0.0
|
||||
legacy_crush = display_span > WORLD_FLAT_DISPLAY_SPAN * 2 and peak <= WORLD_FLAT_DISPLAY_SPAN * 1.5
|
||||
overrides_ok = (not legacy_crush) and all(
|
||||
0.0 <= float(r.x or 0) <= display_span * 1.2
|
||||
and 0.0 <= float(r.y or 0) <= display_span * 1.2
|
||||
for r in override_rows
|
||||
)
|
||||
if overrides_ok:
|
||||
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
|
||||
# Saved placements are authoritative display coordinates. Do not infer an
|
||||
# obsolete coordinate system from their magnitude or reject valid negatives.
|
||||
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
|
||||
|
||||
edges_out: list[ViewEdgeOut] = []
|
||||
if include_edges and fids:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue