mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
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:
parent
99fce837f0
commit
2382cb8269
11 changed files with 416 additions and 128 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue