Harden topology editor save/discover flow and edge editing.

Guard unsaved changes, cancel discovery, block invalid links, preserve discovered_at on PUT, and normalize interface names for discovery keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 12:50:33 +08:00
parent 99fce837f0
commit 2382cb8269
11 changed files with 416 additions and 128 deletions

View file

@ -550,3 +550,51 @@ def _kv(text: str, pattern: str) -> str:
if not m:
return ""
return str(m.group(1) or "").strip()
# Long media names → short canonical form (case-insensitive prefix).
_IFNAME_PREFIXES: tuple[tuple[str, str], ...] = (
("tengigabitethernet", "te"),
("ten-gigabitethernet", "te"),
("gigabitethernet", "gi"),
("fastethernet", "fa"),
("ethernet", "eth"),
("xgigabitethernet", "xge"),
("100ge", "100ge"),
("40ge", "40ge"),
("25ge", "25ge"),
("10ge", "10ge"),
("ge-trunk", "ge-trunk"),
("eth-trunk", "eth-trunk"),
("port-channel", "po"),
("portchannel", "po"),
("loopback", "lo"),
("vlanif", "vlanif"),
("vlan", "vlan"),
("mgmteth", "mgmt"),
("management", "mgmt"),
("hundredgige", "hu"),
("fiftygige", "fi"),
("fortygige", "fo"),
("twentyfivegige", "twe"),
("twogigabitethernet", "tw"),
)
def normalize_ifname(name: str) -> str:
"""Canonicalize interface names so Gi0/0 and GigabitEthernet0/0 share a key."""
raw = str(name or "").strip()
if not raw:
return ""
s = re.sub(r"\s+", "", raw).lower()
s = s.replace("_", "/")
for long, short in _IFNAME_PREFIXES:
if s.startswith(long):
rest = s[len(long) :]
if rest.startswith((":", "/", "-")) or rest == "" or rest[0].isdigit():
if rest.startswith(":"):
rest = rest[1:]
return f"{short}{rest}"
break
# Already-short forms: gi0/0, te1/0/1, xge0/0/1, 10ge1/0/1
return s

View file

@ -34,6 +34,7 @@ class TopologyNodeIn(BaseModel):
label: str = ""
x: float = 0.0
y: float = 0.0
created_at: datetime | None = None
class TopologyEdgeIn(BaseModel):
@ -46,6 +47,8 @@ class TopologyEdgeIn(BaseModel):
stroke_color: str = Field(default="", max_length=32)
stroke_width: int = Field(default=0, ge=0, le=12)
line_style: str = Field(default="", max_length=16)
discovered_at: datetime | None = None
created_at: datetime | None = None
class TopologyNodeOut(BaseModel):

View file

@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE
from .ne_exec import execute_managed_ne_commands
from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
from .topology_lldp import NeighborHit, normalize_ifname, parse_neighbor_output, pick_neighbor_command
from .topology_schemas import (
TopologyDiscoverNeResult,
TopologyDiscoverOut,
@ -251,6 +251,8 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
tid = str(e.target_node_id or "").strip()
if sid not in node_ids or tid not in node_ids:
raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes")
if sid == tid:
raise HTTPException(status_code=400, detail="edge_self_loop")
src = str(e.source or "manual").strip().lower() or "manual"
if src not in {"manual", "lldp", "cdp", "stale"}:
raise HTTPException(status_code=400, detail="invalid_edge_source")
@ -262,27 +264,47 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
normalized_edges.append((e, src, color, width, line))
now = _utcnow()
prev_nodes = {
str(n.id): {"created_at": n.created_at}
for n in db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all()
}
prev_edges = {
str(e.id): {"created_at": e.created_at, "discovered_at": e.discovered_at}
for e in db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all()
}
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
db.expire_all()
for n in nodes_in:
nid = str(n.id).strip()
prev = prev_nodes.get(nid) or {}
created = getattr(n, "created_at", None) or prev.get("created_at") or now
db.add(
TopologyNode(
id=str(n.id).strip(),
id=nid,
map_id=row.id,
managed_ne_id=str(n.managed_ne_id or "").strip(),
ume_ne_id=str(n.ume_ne_id or "").strip(),
label=str(n.label or "").strip()[:256],
x=float(n.x or 0),
y=float(n.y or 0),
created_at=now,
created_at=created,
updated_at=now,
)
)
for e, src, color, width, line in normalized_edges:
eid = str(e.id).strip() or uuid4().hex
prev = prev_edges.get(eid) or {}
created = getattr(e, "created_at", None) or prev.get("created_at") or now
client_discovered = getattr(e, "discovered_at", None)
if src in {"lldp", "cdp", "stale"}:
discovered = client_discovered or prev.get("discovered_at") or now
else:
discovered = None
db.add(
TopologyEdge(
id=str(e.id).strip() or uuid4().hex,
id=eid,
map_id=row.id,
source_node_id=str(e.source_node_id).strip(),
target_node_id=str(e.target_node_id).strip(),
@ -292,8 +314,8 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
stroke_color=color,
stroke_width=width,
line_style=line,
discovered_at=now if src in {"lldp", "cdp", "stale"} else None,
created_at=now,
discovered_at=discovered,
created_at=created,
updated_at=now,
)
)
@ -345,9 +367,11 @@ def _match_neighbor_to_node(
def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[str, str, str, str]:
lp = normalize_ifname(local_port)
rp = normalize_ifname(remote_port)
if a <= b:
return (a, b, local_port, remote_port)
return (b, a, remote_port, local_port)
return (a, b, lp, rp)
return (b, a, rp, lp)
def _discover_target_for_node(