mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 12:03:17 +08:00
Add topology classify inventory, fabric lifecycle, and broader collect targets.
Treat Fabric as an inventory sheet for role/region tagging and slices; detach fabric links on managed/UME delete instead of cascading map deletes. Extend collection to all managed and UME NEs, show NE source in management, and refine canvas vendor icon tones. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
90dff56e48
commit
a5a3a4f156
30 changed files with 5986 additions and 495 deletions
|
|
@ -8,13 +8,15 @@ from pydantic import BaseModel, Field
|
||||||
class CollectionJobCreate(BaseModel):
|
class CollectionJobCreate(BaseModel):
|
||||||
title: str = ""
|
title: str = ""
|
||||||
commands: str = Field(min_length=1)
|
commands: str = Field(min_length=1)
|
||||||
ne_ids: list[str] = Field(min_length=1)
|
ne_ids: list[str] = Field(default_factory=list)
|
||||||
|
ume_ne_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CollectionRunOut(BaseModel):
|
class CollectionRunOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
job_id: str
|
job_id: str
|
||||||
ne_id: str
|
ne_id: str
|
||||||
|
ne_source: str = "managed"
|
||||||
ne_name: str
|
ne_name: str
|
||||||
ne_ip: str
|
ne_ip: str
|
||||||
status: str
|
status: str
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import ManagedNE, NeCollectionJob, NeCollectionRun
|
from .models import ManagedNE, NeCollectionJob, NeCollectionRun, UmeCliOverride, UmeInventoryNE
|
||||||
from .collection_job_state import (
|
from .collection_job_state import (
|
||||||
finalize_collection_job,
|
finalize_collection_job,
|
||||||
reconcile_stale_collection_job,
|
reconcile_stale_collection_job,
|
||||||
|
|
@ -100,6 +100,7 @@ def run_to_out(row: NeCollectionRun) -> CollectionRunOut:
|
||||||
id=str(row.id),
|
id=str(row.id),
|
||||||
job_id=str(row.job_id),
|
job_id=str(row.job_id),
|
||||||
ne_id=str(row.ne_id),
|
ne_id=str(row.ne_id),
|
||||||
|
ne_source=str(getattr(row, "ne_source", None) or "managed"),
|
||||||
ne_name=str(row.ne_name or ""),
|
ne_name=str(row.ne_name or ""),
|
||||||
ne_ip=str(row.ne_ip or ""),
|
ne_ip=str(row.ne_ip or ""),
|
||||||
status=str(row.status or "pending"),
|
status=str(row.status or "pending"),
|
||||||
|
|
@ -118,11 +119,17 @@ def list_eligible_ne(
|
||||||
page_size: int = 200,
|
page_size: int = 200,
|
||||||
keyword: str = "",
|
keyword: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
stmt = db.query(ManagedNE).filter(ManagedNE.connect_status == "pass")
|
from .device_types import WEBCRT_NE_SOURCE
|
||||||
|
|
||||||
|
page = max(1, int(page or 1))
|
||||||
|
page_size = max(1, min(500, int(page_size or 200)))
|
||||||
kw = str(keyword or "").strip()
|
kw = str(keyword or "").strip()
|
||||||
if kw:
|
like = f"%{kw}%" if kw else ""
|
||||||
like = f"%{kw}%"
|
|
||||||
stmt = stmt.filter(
|
# Managed inventory (any connect_status); exclude WebCRT session hosts.
|
||||||
|
m_stmt = db.query(ManagedNE).filter(ManagedNE.source != WEBCRT_NE_SOURCE)
|
||||||
|
if like:
|
||||||
|
m_stmt = m_stmt.filter(
|
||||||
or_(
|
or_(
|
||||||
ManagedNE.name.ilike(like),
|
ManagedNE.name.ilike(like),
|
||||||
ManagedNE.ip_address.ilike(like),
|
ManagedNE.ip_address.ilike(like),
|
||||||
|
|
@ -130,16 +137,10 @@ def list_eligible_ne(
|
||||||
ManagedNE.device_type.ilike(like),
|
ManagedNE.device_type.ilike(like),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
total = int(stmt.count())
|
managed_items = [
|
||||||
rows = (
|
|
||||||
stmt.order_by(ManagedNE.name.asc())
|
|
||||||
.offset((page - 1) * page_size)
|
|
||||||
.limit(page_size)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
items = [
|
|
||||||
{
|
{
|
||||||
"id": str(x.id),
|
"id": str(x.id),
|
||||||
|
"source": "managed",
|
||||||
"name": str(x.name or ""),
|
"name": str(x.name or ""),
|
||||||
"vendor": str(x.vendor or ""),
|
"vendor": str(x.vendor or ""),
|
||||||
"device_type": str(x.device_type or ""),
|
"device_type": str(x.device_type or ""),
|
||||||
|
|
@ -147,36 +148,94 @@ def list_eligible_ne(
|
||||||
"connect_status": str(x.connect_status or ""),
|
"connect_status": str(x.connect_status or ""),
|
||||||
"connect_tested_at": x.connect_tested_at.isoformat() if x.connect_tested_at else None,
|
"connect_tested_at": x.connect_tested_at.isoformat() if x.connect_tested_at else None,
|
||||||
}
|
}
|
||||||
for x in rows
|
for x in m_stmt.order_by(ManagedNE.name.asc()).all()
|
||||||
]
|
]
|
||||||
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
|
||||||
|
u_stmt = db.query(UmeInventoryNE)
|
||||||
|
if like:
|
||||||
|
u_stmt = u_stmt.filter(
|
||||||
|
or_(
|
||||||
|
UmeInventoryNE.ne_id.ilike(like),
|
||||||
|
UmeInventoryNE.ne_name.ilike(like),
|
||||||
|
UmeInventoryNE.user_label.ilike(like),
|
||||||
|
UmeInventoryNE.host_name.ilike(like),
|
||||||
|
UmeInventoryNE.ip_address.ilike(like),
|
||||||
|
UmeInventoryNE.vendor.ilike(like),
|
||||||
|
UmeInventoryNE.ne_type.ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ume_rows = u_stmt.order_by(UmeInventoryNE.host_name.asc(), UmeInventoryNE.ne_name.asc()).all()
|
||||||
|
overrides = {
|
||||||
|
str(o.ume_ne_id): o
|
||||||
|
for o in db.query(UmeCliOverride).filter(
|
||||||
|
UmeCliOverride.ume_ne_id.in_([str(x.ne_id) for x in ume_rows] or ["__none__"])
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
ume_items = []
|
||||||
|
for x in ume_rows:
|
||||||
|
uid = str(x.ne_id)
|
||||||
|
ov = overrides.get(uid)
|
||||||
|
ume_items.append(
|
||||||
|
{
|
||||||
|
"id": uid,
|
||||||
|
"source": "ume",
|
||||||
|
"name": str(x.host_name or x.user_label or x.ne_name or x.ip_address or uid),
|
||||||
|
"vendor": str(x.vendor or "ZTE"),
|
||||||
|
"device_type": str(x.ne_type or ""),
|
||||||
|
"ip_address": str(x.ip_address or ""),
|
||||||
|
"connect_status": str(ov.connect_status or "unknown") if ov else "unknown",
|
||||||
|
"connect_tested_at": ov.connect_tested_at.isoformat()
|
||||||
|
if ov and ov.connect_tested_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
items = managed_items + ume_items
|
||||||
|
items.sort(key=lambda r: (str(r.get("name") or "").lower(), str(r.get("id") or "")))
|
||||||
|
total = len(items)
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
page_items = items[start : start + page_size]
|
||||||
|
return {"total": total, "page": page, "page_size": page_size, "items": page_items}
|
||||||
|
|
||||||
|
|
||||||
def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOut:
|
def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOut:
|
||||||
commands = _parse_commands(body.commands)
|
commands = _parse_commands(body.commands)
|
||||||
if not commands:
|
if not commands:
|
||||||
raise HTTPException(status_code=400, detail="commands_empty")
|
raise HTTPException(status_code=400, detail="commands_empty")
|
||||||
ne_ids = [str(x).strip() for x in body.ne_ids if str(x).strip()]
|
ne_ids = [str(x).strip() for x in (body.ne_ids or []) if str(x).strip()]
|
||||||
if not ne_ids:
|
ume_ids = [str(x).strip() for x in (body.ume_ne_ids or []) if str(x).strip()]
|
||||||
|
if not ne_ids and not ume_ids:
|
||||||
raise HTTPException(status_code=400, detail="ne_ids_required")
|
raise HTTPException(status_code=400, detail="ne_ids_required")
|
||||||
|
|
||||||
ne_rows: list[ManagedNE] = []
|
targets: list[tuple[str, str, str, str]] = [] # source, id, name, ip
|
||||||
missing: list[str] = []
|
missing_m: list[str] = []
|
||||||
not_pass: list[str] = []
|
|
||||||
for ne_id in ne_ids:
|
for ne_id in ne_ids:
|
||||||
row = db.get(ManagedNE, ne_id)
|
row = db.get(ManagedNE, ne_id)
|
||||||
if not row:
|
if not row:
|
||||||
missing.append(ne_id)
|
missing_m.append(ne_id)
|
||||||
continue
|
continue
|
||||||
if str(row.connect_status or "") != "pass":
|
targets.append(
|
||||||
not_pass.append(ne_id)
|
(
|
||||||
|
"managed",
|
||||||
|
str(row.id),
|
||||||
|
str(row.name or row.ip_address or ""),
|
||||||
|
str(row.ip_address or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if missing_m:
|
||||||
|
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing_m[:5])}")
|
||||||
|
|
||||||
|
missing_u: list[str] = []
|
||||||
|
for uid in ume_ids:
|
||||||
|
inv = db.get(UmeInventoryNE, uid)
|
||||||
|
if not inv:
|
||||||
|
missing_u.append(uid)
|
||||||
continue
|
continue
|
||||||
ne_rows.append(row)
|
name = str(inv.host_name or inv.user_label or inv.ne_name or inv.ip_address or uid)
|
||||||
if missing:
|
targets.append(("ume", uid, name, str(inv.ip_address or "")))
|
||||||
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
|
if missing_u:
|
||||||
if not_pass:
|
raise HTTPException(status_code=404, detail=f"ume_ne_not_found: {','.join(missing_u[:5])}")
|
||||||
raise HTTPException(status_code=400, detail=f"ne_connect_not_pass: {','.join(not_pass[:5])}")
|
if not targets:
|
||||||
if not ne_rows:
|
|
||||||
raise HTTPException(status_code=400, detail="no_eligible_ne")
|
raise HTTPException(status_code=400, detail="no_eligible_ne")
|
||||||
|
|
||||||
now = _now()
|
now = _now()
|
||||||
|
|
@ -184,7 +243,7 @@ def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOu
|
||||||
title=str(body.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}",
|
title=str(body.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}",
|
||||||
commands="\n".join(commands),
|
commands="\n".join(commands),
|
||||||
status="pending",
|
status="pending",
|
||||||
ne_count=len(ne_rows),
|
ne_count=len(targets),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
started_at=None,
|
started_at=None,
|
||||||
last_run_at=None,
|
last_run_at=None,
|
||||||
|
|
@ -192,12 +251,13 @@ def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOu
|
||||||
db.add(job)
|
db.add(job)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
for ne in ne_rows:
|
for source, tid, name, ip in targets:
|
||||||
run = NeCollectionRun(
|
run = NeCollectionRun(
|
||||||
job_id=str(job.id),
|
job_id=str(job.id),
|
||||||
ne_id=str(ne.id),
|
ne_id=tid,
|
||||||
ne_name=str(ne.name or ne.ip_address or ""),
|
ne_source=source,
|
||||||
ne_ip=str(ne.ip_address or ""),
|
ne_name=name,
|
||||||
|
ne_ip=ip,
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
db.add(run)
|
db.add(run)
|
||||||
|
|
|
||||||
|
|
@ -820,15 +820,25 @@ def on_startup() -> None:
|
||||||
|
|
||||||
ensure_port_traffic_series_schema(conn)
|
ensure_port_traffic_series_schema(conn)
|
||||||
ensure_topology_schema(conn)
|
ensure_topology_schema(conn)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE ne_collection_run ADD COLUMN IF NOT EXISTS ne_source VARCHAR(16) DEFAULT 'managed'"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE ne_collection_run ALTER COLUMN ne_id TYPE VARCHAR(128)"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
_schedule_log.exception("startup: auth/port_traffic/topology schema migration failed")
|
_schedule_log.exception("startup: auth/port_traffic/topology schema migration failed")
|
||||||
_reset_runtime_pause_flags()
|
_reset_runtime_pause_flags()
|
||||||
_fail_stale_running_sync_jobs_on_startup()
|
_fail_stale_running_sync_jobs_on_startup()
|
||||||
try:
|
try:
|
||||||
from .topology_service import reclaim_stale_discover_jobs
|
from .topology_service import bootstrap_topology_tree, reclaim_stale_discover_jobs
|
||||||
|
|
||||||
db_topo = SessionLocal()
|
db_topo = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
bootstrap_topology_tree(db_topo)
|
||||||
closed = reclaim_stale_discover_jobs(db_topo, force_all_open=True)
|
closed = reclaim_stale_discover_jobs(db_topo, force_all_open=True)
|
||||||
if closed:
|
if closed:
|
||||||
_schedule_log.warning(
|
_schedule_log.warning(
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,10 @@ class NeCollectionRun(Base):
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||||
job_id: Mapped[str] = mapped_column(String(64), index=True)
|
job_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
ne_id: Mapped[str] = mapped_column(String(64), index=True)
|
# managed_ne.id or ume_inventory_ne.ne_id
|
||||||
|
ne_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
# managed | ume
|
||||||
|
ne_source: Mapped[str] = mapped_column(String(16), default="managed", index=True)
|
||||||
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
||||||
ne_ip: Mapped[str] = mapped_column(String(128), default="")
|
ne_ip: Mapped[str] = mapped_column(String(128), default="")
|
||||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||||
|
|
@ -409,12 +412,39 @@ class TopoFabricNode(Base):
|
||||||
ip: Mapped[str] = mapped_column(String(128), default="", index=True)
|
ip: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||||
device_type: Mapped[str] = mapped_column(String(64), default="")
|
device_type: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
# Classify tags (regex rules / manual). role: core|aggregation|access|unknown|""
|
||||||
|
role: Mapped[str] = mapped_column(String(32), default="", index=True)
|
||||||
|
region_folder_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
# rule | manual | ""
|
||||||
|
role_source: Mapped[str] = mapped_column(String(16), default="")
|
||||||
|
region_source: Mapped[str] = mapped_column(String(16), default="")
|
||||||
attrs: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
attrs: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TopoClassifyRule(Base):
|
||||||
|
"""Regex rules to assign fabric role or region from NE name/IP."""
|
||||||
|
|
||||||
|
__tablename__ = "topo_classify_rule"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||||
|
# role | region
|
||||||
|
scope: Mapped[str] = mapped_column(String(16), default="role", index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
pattern: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
# name | ip | name_ip
|
||||||
|
match_field: Mapped[str] = mapped_column(String(32), default="name")
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=100, index=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
# role: {role}; region: {folder_id} or {region_name_from_group}
|
||||||
|
payload: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||||
|
remark: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
class TopoFabricEdge(Base):
|
class TopoFabricEdge(Base):
|
||||||
"""Global fabric link. Target scale ~1M; layer reserved for future BGP/tunnel/l2vpn."""
|
"""Global fabric link. Target scale ~1M; layer reserved for future BGP/tunnel/l2vpn."""
|
||||||
|
|
||||||
|
|
@ -448,15 +478,39 @@ class TopoFabricEdge(Base):
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TopoFolder(Base):
|
||||||
|
"""Grouping node for topology tree (root / region). Not a canvas."""
|
||||||
|
|
||||||
|
__tablename__ = "topo_folder"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||||
|
parent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
# root | region
|
||||||
|
kind: Mapped[str] = mapped_column(String(32), default="region", index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
class TopoView(Base):
|
class TopoView(Base):
|
||||||
"""Named topology view (presentation); replaces legacy topology_map."""
|
"""Topology canvas under a site/region (physical or custom; flat siblings)."""
|
||||||
|
|
||||||
__tablename__ = "topo_view"
|
__tablename__ = "topo_view"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||||
|
folder_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
# Legacy nesting column; unused (always null after vendor-model migration).
|
||||||
|
parent_view_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
# physical | custom
|
||||||
|
kind: Mapped[str] = mapped_column(String(32), default="custom", index=True)
|
||||||
|
# Optional filter preset label (legacy core|aggregation|access); not a tree level.
|
||||||
|
role: Mapped[str] = mapped_column(String(32), default="core", index=True)
|
||||||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
remark: Mapped[str] = mapped_column(String(1024), default="")
|
remark: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
# { node_ids?: [], layer?: "physical", status?: "active", keyword?: "" }
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
# { layer?, status?, membership?: {...} }
|
||||||
filter: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
filter: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||||
viewport: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
viewport: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ from typing import Any
|
||||||
from .collection_job_state import finalize_collection_job, sync_job_progress
|
from .collection_job_state import finalize_collection_job, sync_job_progress
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import SessionLocal
|
from .db import SessionLocal
|
||||||
from .models import ManagedNE, NeCollectionJob, NeCollectionRun
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .cli_resolve import resolve_cli_target
|
||||||
|
from .models import NeCollectionJob, NeCollectionRun
|
||||||
from .ne_collection_paths import clear_run_output_files, run_output_dir
|
from .ne_collection_paths import clear_run_output_files, run_output_dir
|
||||||
from .ne_crypto import CredentialCryptoError
|
from .ne_crypto import CredentialCryptoError
|
||||||
from .ne_netmiko import send_show_command
|
from .ne_netmiko import send_show_command
|
||||||
from .ne_service import get_device_credentials
|
|
||||||
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
|
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
|
||||||
|
|
||||||
_log = logging.getLogger("netx.ne.collect")
|
_log = logging.getLogger("netx.ne.collect")
|
||||||
|
|
@ -159,15 +161,16 @@ def _run_single(job_id: str, run_id: str, commands: list[str]) -> None:
|
||||||
run = db.get(NeCollectionRun, run_id)
|
run = db.get(NeCollectionRun, run_id)
|
||||||
if not run:
|
if not run:
|
||||||
return
|
return
|
||||||
ne = db.get(ManagedNE, str(run.ne_id))
|
|
||||||
if not ne:
|
|
||||||
_update_run(run_id, status="fail", message="managed_ne_not_found", ended_at=datetime.now())
|
|
||||||
return
|
|
||||||
if _job_is_paused(job_id):
|
if _job_is_paused(job_id):
|
||||||
_update_run(run_id, status="cancelled", message="paused", ended_at=datetime.now())
|
_update_run(run_id, status="cancelled", message="paused", ended_at=datetime.now())
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
creds = get_device_credentials(ne)
|
source = str(getattr(run, "ne_source", None) or "managed").strip().lower()
|
||||||
|
tid = str(run.ne_id or "").strip()
|
||||||
|
if source == "ume":
|
||||||
|
creds, _device = resolve_cli_target(db, ume_ne_id=tid)
|
||||||
|
else:
|
||||||
|
creds, _device = resolve_cli_target(db, managed_ne_id=tid)
|
||||||
output = _collect_with_timeout(creds, commands)
|
output = _collect_with_timeout(creds, commands)
|
||||||
finished_at = datetime.now()
|
finished_at = datetime.now()
|
||||||
name_part = _safe_filename_part(str(run.ne_name or creds.get("name") or "ne"))
|
name_part = _safe_filename_part(str(run.ne_name or creds.get("name") or "ne"))
|
||||||
|
|
@ -190,6 +193,9 @@ def _run_single(job_id: str, run_id: str, commands: list[str]) -> None:
|
||||||
)
|
)
|
||||||
except CredentialCryptoError as exc:
|
except CredentialCryptoError as exc:
|
||||||
_update_run(run_id, status="fail", message=str(exc)[:1020], ended_at=datetime.now())
|
_update_run(run_id, status="fail", message=str(exc)[:1020], ended_at=datetime.now())
|
||||||
|
except HTTPException as exc:
|
||||||
|
detail = str(exc.detail if exc.detail is not None else exc)[:1020]
|
||||||
|
_update_run(run_id, status="fail", message=detail, ended_at=datetime.now())
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("collection failed run=%s", run_id)
|
_log.exception("collection failed run=%s", run_id)
|
||||||
_update_run(run_id, status="fail", message=_format_run_error(exc), ended_at=datetime.now())
|
_update_run(run_id, status="fail", message=_format_run_error(exc), ended_at=datetime.now())
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ class ManagedNeOut(BaseModel):
|
||||||
connect_tested_at: datetime | None
|
connect_tested_at: datetime | None
|
||||||
tags: str
|
tags: str
|
||||||
remark: str
|
remark: str
|
||||||
|
# Provenance: "" | ume_sync | webcrt | lldp | …
|
||||||
|
source: str = ""
|
||||||
|
source_ref: str = ""
|
||||||
hop_enabled: bool = False
|
hop_enabled: bool = False
|
||||||
hop_vendor: str = "zte"
|
hop_vendor: str = "zte"
|
||||||
hop_host: str = ""
|
hop_host: str = ""
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,8 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut:
|
||||||
connect_tested_at=row.connect_tested_at,
|
connect_tested_at=row.connect_tested_at,
|
||||||
tags=str(row.tags or ""),
|
tags=str(row.tags or ""),
|
||||||
remark=str(row.remark or ""),
|
remark=str(row.remark or ""),
|
||||||
|
source=str(row.source or ""),
|
||||||
|
source_ref=str(row.source_ref or ""),
|
||||||
hop_enabled=bool(row.hop_enabled),
|
hop_enabled=bool(row.hop_enabled),
|
||||||
hop_vendor=str(row.hop_vendor or "zte"),
|
hop_vendor=str(row.hop_vendor or "zte"),
|
||||||
hop_host=str(row.hop_host or ""),
|
hop_host=str(row.hop_host or ""),
|
||||||
|
|
@ -641,9 +643,12 @@ def batch_apply_account(db: Session, ids: list[str], account: BatchAccountConfig
|
||||||
|
|
||||||
|
|
||||||
def delete_managed_ne(db: Session, ne_id: str) -> dict[str, bool]:
|
def delete_managed_ne(db: Session, ne_id: str) -> dict[str, bool]:
|
||||||
|
from .topology_inventory_lifecycle import detach_fabric_from_managed
|
||||||
|
|
||||||
row = db.get(ManagedNE, ne_id)
|
row = db.get(ManagedNE, ne_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="managed_ne_not_found")
|
raise HTTPException(status_code=404, detail="managed_ne_not_found")
|
||||||
|
detach_fabric_from_managed(db, [str(row.id)])
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
@ -724,6 +729,8 @@ def get_ids_by_tag(db: Session, tag: str | None) -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
def batch_delete_managed_ne(db: Session, ids: list[str]) -> dict[str, Any]:
|
def batch_delete_managed_ne(db: Session, ids: list[str]) -> dict[str, Any]:
|
||||||
|
from .topology_inventory_lifecycle import detach_fabric_from_managed
|
||||||
|
|
||||||
ne_ids = [str(x).strip() for x in ids if str(x).strip()]
|
ne_ids = [str(x).strip() for x in ids if str(x).strip()]
|
||||||
if not ne_ids:
|
if not ne_ids:
|
||||||
raise HTTPException(status_code=400, detail="ids_required")
|
raise HTTPException(status_code=400, detail="ids_required")
|
||||||
|
|
@ -732,6 +739,7 @@ def batch_delete_managed_ne(db: Session, ids: list[str]) -> dict[str, Any]:
|
||||||
missing = [x for x in ne_ids if x not in found_ids]
|
missing = [x for x in ne_ids if x not in found_ids]
|
||||||
if missing:
|
if missing:
|
||||||
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
|
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
|
||||||
|
detach_fabric_from_managed(db, [str(r.id) for r in rows])
|
||||||
for row in rows:
|
for row in rows:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -779,12 +787,19 @@ def sync_ume_inventory_to_managed_ne(db: Session) -> UmeManagedSyncResult:
|
||||||
existing.source = UME_SYNC_SOURCE
|
existing.source = UME_SYNC_SOURCE
|
||||||
existing.source_ref = source_ref
|
existing.source_ref = source_ref
|
||||||
existing.updated_at = now
|
existing.updated_at = now
|
||||||
deleted = 0
|
from .topology_inventory_lifecycle import detach_fabric_from_managed
|
||||||
for row in db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all():
|
|
||||||
ref = str(row.source_ref or "").strip()
|
stale = [
|
||||||
if not ref or ref not in inventory_ids:
|
row
|
||||||
|
for row in db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all()
|
||||||
|
if (not str(row.source_ref or "").strip())
|
||||||
|
or str(row.source_ref or "").strip() not in inventory_ids
|
||||||
|
]
|
||||||
|
if stale:
|
||||||
|
detach_fabric_from_managed(db, [str(r.id) for r in stale])
|
||||||
|
for row in stale:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
deleted += 1
|
deleted = len(stale)
|
||||||
db.commit()
|
db.commit()
|
||||||
return UmeManagedSyncResult(
|
return UmeManagedSyncResult(
|
||||||
inserted=inserted,
|
inserted=inserted,
|
||||||
|
|
@ -795,10 +810,14 @@ def sync_ume_inventory_to_managed_ne(db: Session) -> UmeManagedSyncResult:
|
||||||
|
|
||||||
|
|
||||||
def delete_ume_synced_managed_ne(db: Session) -> UmeManagedDeleteResult:
|
def delete_ume_synced_managed_ne(db: Session) -> UmeManagedDeleteResult:
|
||||||
|
from .topology_inventory_lifecycle import detach_fabric_from_managed
|
||||||
|
|
||||||
rows = db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all()
|
rows = db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all()
|
||||||
deleted = len(rows)
|
deleted = len(rows)
|
||||||
for row in rows:
|
if rows:
|
||||||
db.delete(row)
|
detach_fabric_from_managed(db, [str(r.id) for r in rows])
|
||||||
|
for row in rows:
|
||||||
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return UmeManagedDeleteResult(deleted=deleted)
|
return UmeManagedDeleteResult(deleted=deleted)
|
||||||
|
|
||||||
|
|
|
||||||
927
netx_api/topology_classify.py
Normal file
927
netx_api/topology_classify.py
Normal file
|
|
@ -0,0 +1,927 @@
|
||||||
|
"""Regex-based fabric role/region classification + slice map generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import TopoClassifyRule, TopoFabricEdge, TopoFabricNode, TopoFolder, TopoView, TopoViewNode
|
||||||
|
from .topology_membership import (
|
||||||
|
VIEW_KIND_CUSTOM,
|
||||||
|
VIEW_ROLE_ACCESS,
|
||||||
|
VIEW_ROLE_AGGREGATION,
|
||||||
|
VIEW_ROLE_CORE,
|
||||||
|
VIEW_ROLES,
|
||||||
|
merge_filter_with_membership,
|
||||||
|
normalize_view_role,
|
||||||
|
)
|
||||||
|
from .topology_schemas import (
|
||||||
|
ClassifyApplyOut,
|
||||||
|
ClassifyPreviewOut,
|
||||||
|
ClassifyRuleCreate,
|
||||||
|
ClassifyRuleOut,
|
||||||
|
ClassifyRuleUpdate,
|
||||||
|
FabricNodeOut,
|
||||||
|
FabricNodesBulkTagOut,
|
||||||
|
FabricNodesBulkTagRequest,
|
||||||
|
FabricNodesMatchOut,
|
||||||
|
FabricNodesMatchRequest,
|
||||||
|
FabricNodeTagPatch,
|
||||||
|
SliceGenerateOut,
|
||||||
|
SliceGenerateRequest,
|
||||||
|
SliceMapPlan,
|
||||||
|
TopologyFolderCreate,
|
||||||
|
TopologyViewCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MAX_PATTERN_LEN = 512
|
||||||
|
_ROLE_VALUES = VIEW_ROLES | {"unknown"}
|
||||||
|
_MATCH_FIELDS = frozenset({"name", "ip", "name_ip"})
|
||||||
|
_SCOPES = frozenset({"role", "region"})
|
||||||
|
_SLICE_TEMPLATES = frozenset({"core_only", "core_agg", "agg_access"})
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_pattern(pattern: str) -> re.Pattern[str]:
|
||||||
|
p = str(pattern or "").strip()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=400, detail="pattern_required")
|
||||||
|
if len(p) > _MAX_PATTERN_LEN:
|
||||||
|
raise HTTPException(status_code=400, detail="pattern_too_long")
|
||||||
|
try:
|
||||||
|
return re.compile(p, re.IGNORECASE)
|
||||||
|
except re.error as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"invalid_pattern:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _match_text(node: TopoFabricNode, match_field: str) -> str:
|
||||||
|
name = str(node.name or "")
|
||||||
|
ip = str(node.ip or "")
|
||||||
|
mf = str(match_field or "name").strip().lower()
|
||||||
|
if mf == "ip":
|
||||||
|
return ip
|
||||||
|
if mf == "name_ip":
|
||||||
|
return f"{name} {ip}".strip()
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut:
|
||||||
|
return ClassifyRuleOut(
|
||||||
|
id=row.id,
|
||||||
|
scope=str(row.scope or "role"),
|
||||||
|
name=str(row.name or ""),
|
||||||
|
pattern=str(row.pattern or ""),
|
||||||
|
match_field=str(row.match_field or "name"),
|
||||||
|
priority=int(row.priority or 100),
|
||||||
|
enabled=bool(row.enabled),
|
||||||
|
payload=dict(row.payload or {}),
|
||||||
|
remark=str(row.remark or ""),
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_payload(scope: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = dict(payload or {})
|
||||||
|
if scope == "role":
|
||||||
|
role = normalize_view_role(str(out.get("role") or ""))
|
||||||
|
if str(out.get("role") or "").strip().lower() not in VIEW_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail="role_payload_invalid")
|
||||||
|
return {"role": role}
|
||||||
|
if "folder_id" in out and str(out.get("folder_id") or "").strip():
|
||||||
|
return {"folder_id": str(out["folder_id"]).strip()}
|
||||||
|
if "region_name_from_group" in out:
|
||||||
|
try:
|
||||||
|
g = int(out.get("region_name_from_group"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="region_group_invalid") from exc
|
||||||
|
if g < 1:
|
||||||
|
raise HTTPException(status_code=400, detail="region_group_invalid")
|
||||||
|
return {"region_name_from_group": g}
|
||||||
|
raise HTTPException(status_code=400, detail="region_payload_invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def list_rules(db: Session, *, scope: str = "") -> list[ClassifyRuleOut]:
|
||||||
|
q = db.query(TopoClassifyRule)
|
||||||
|
if scope.strip():
|
||||||
|
q = q.filter(TopoClassifyRule.scope == scope.strip().lower())
|
||||||
|
rows = q.order_by(
|
||||||
|
TopoClassifyRule.scope.asc(),
|
||||||
|
TopoClassifyRule.priority.asc(),
|
||||||
|
TopoClassifyRule.name.asc(),
|
||||||
|
).all()
|
||||||
|
return [_rule_out(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def create_rule(db: Session, body: ClassifyRuleCreate) -> ClassifyRuleOut:
|
||||||
|
scope = str(body.scope or "").strip().lower()
|
||||||
|
if scope not in _SCOPES:
|
||||||
|
raise HTTPException(status_code=400, detail="scope_invalid")
|
||||||
|
match_field = str(body.match_field or "name").strip().lower()
|
||||||
|
if match_field not in _MATCH_FIELDS:
|
||||||
|
raise HTTPException(status_code=400, detail="match_field_invalid")
|
||||||
|
_compile_pattern(body.pattern)
|
||||||
|
payload = _validate_payload(scope, dict(body.payload or {}))
|
||||||
|
if scope == "region" and payload.get("folder_id"):
|
||||||
|
folder = db.get(TopoFolder, payload["folder_id"])
|
||||||
|
if folder is None or str(folder.kind or "") != "region":
|
||||||
|
raise HTTPException(status_code=400, detail="folder_not_found")
|
||||||
|
now = _utcnow()
|
||||||
|
row = TopoClassifyRule(
|
||||||
|
id=uuid4().hex,
|
||||||
|
scope=scope,
|
||||||
|
name=str(body.name or "").strip()[:256] or f"{scope}-rule",
|
||||||
|
pattern=str(body.pattern or "").strip()[:_MAX_PATTERN_LEN],
|
||||||
|
match_field=match_field,
|
||||||
|
priority=int(body.priority if body.priority is not None else 100),
|
||||||
|
enabled=bool(body.enabled if body.enabled is not None else True),
|
||||||
|
payload=payload,
|
||||||
|
remark=str(body.remark or "")[:512],
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return _rule_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_rule(db: Session, rule_id: str, body: ClassifyRuleUpdate) -> ClassifyRuleOut:
|
||||||
|
row = db.get(TopoClassifyRule, rule_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="rule_not_found")
|
||||||
|
if body.name is not None:
|
||||||
|
row.name = str(body.name or "").strip()[:256]
|
||||||
|
if body.pattern is not None:
|
||||||
|
_compile_pattern(body.pattern)
|
||||||
|
row.pattern = str(body.pattern or "").strip()[:_MAX_PATTERN_LEN]
|
||||||
|
if body.match_field is not None:
|
||||||
|
mf = str(body.match_field or "name").strip().lower()
|
||||||
|
if mf not in _MATCH_FIELDS:
|
||||||
|
raise HTTPException(status_code=400, detail="match_field_invalid")
|
||||||
|
row.match_field = mf
|
||||||
|
if body.priority is not None:
|
||||||
|
row.priority = int(body.priority)
|
||||||
|
if body.enabled is not None:
|
||||||
|
row.enabled = bool(body.enabled)
|
||||||
|
if body.remark is not None:
|
||||||
|
row.remark = str(body.remark or "")[:512]
|
||||||
|
if body.payload is not None:
|
||||||
|
row.payload = _validate_payload(str(row.scope or "role"), dict(body.payload or {}))
|
||||||
|
if str(row.scope) == "region" and row.payload.get("folder_id"):
|
||||||
|
folder = db.get(TopoFolder, row.payload["folder_id"])
|
||||||
|
if folder is None or str(folder.kind or "") != "region":
|
||||||
|
raise HTTPException(status_code=400, detail="folder_not_found")
|
||||||
|
row.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return _rule_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_rule(db: Session, rule_id: str) -> dict[str, Any]:
|
||||||
|
row = db.get(TopoClassifyRule, rule_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="rule_not_found")
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "id": rule_id, "deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_rules(db: Session, scope: str) -> list[tuple[TopoClassifyRule, re.Pattern[str]]]:
|
||||||
|
rows = (
|
||||||
|
db.query(TopoClassifyRule)
|
||||||
|
.filter(TopoClassifyRule.scope == scope, TopoClassifyRule.enabled.is_(True))
|
||||||
|
.order_by(TopoClassifyRule.priority.asc(), TopoClassifyRule.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
out: list[tuple[TopoClassifyRule, re.Pattern[str]]] = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
out.append((r, _compile_pattern(r.pattern)))
|
||||||
|
except HTTPException:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_region_by_name(db: Session, name: str) -> TopoFolder:
|
||||||
|
from .topology_service import bootstrap_topology_tree, create_folder
|
||||||
|
|
||||||
|
name = str(name or "").strip()[:256]
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="region_name_empty")
|
||||||
|
existing = (
|
||||||
|
db.query(TopoFolder)
|
||||||
|
.filter(TopoFolder.kind == "region", TopoFolder.name == name)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
bootstrap_topology_tree(db)
|
||||||
|
created = create_folder(db, TopologyFolderCreate(name=name, kind="region"))
|
||||||
|
folder = db.get(TopoFolder, created.id)
|
||||||
|
assert folder is not None
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_role_hit(
|
||||||
|
node: TopoFabricNode, rules: list[tuple[TopoClassifyRule, re.Pattern[str]]]
|
||||||
|
) -> tuple[str | None, str | None, bool]:
|
||||||
|
"""Return (role, rule_id, multi_hit)."""
|
||||||
|
hits: list[tuple[str, str]] = []
|
||||||
|
for rule, cre in rules:
|
||||||
|
text = _match_text(node, rule.match_field)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if cre.search(text):
|
||||||
|
role = normalize_view_role(str((rule.payload or {}).get("role") or ""))
|
||||||
|
hits.append((role, rule.id))
|
||||||
|
if not hits:
|
||||||
|
return None, None, False
|
||||||
|
return hits[0][0], hits[0][1], len(hits) > 1
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_region_hit(
|
||||||
|
db: Session,
|
||||||
|
node: TopoFabricNode,
|
||||||
|
rules: list[tuple[TopoClassifyRule, re.Pattern[str]]],
|
||||||
|
*,
|
||||||
|
create_missing: bool,
|
||||||
|
) -> tuple[str | None, str | None, bool]:
|
||||||
|
hits: list[tuple[str, str]] = []
|
||||||
|
for rule, cre in rules:
|
||||||
|
text = _match_text(node, rule.match_field)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
m = cre.search(text)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
payload = dict(rule.payload or {})
|
||||||
|
folder_id = str(payload.get("folder_id") or "").strip()
|
||||||
|
if folder_id:
|
||||||
|
hits.append((folder_id, rule.id))
|
||||||
|
continue
|
||||||
|
g = int(payload.get("region_name_from_group") or 0)
|
||||||
|
try:
|
||||||
|
region_name = m.group(g)
|
||||||
|
except IndexError:
|
||||||
|
continue
|
||||||
|
region_name = str(region_name or "").strip()
|
||||||
|
if not region_name:
|
||||||
|
continue
|
||||||
|
if create_missing:
|
||||||
|
folder = _ensure_region_by_name(db, region_name)
|
||||||
|
hits.append((folder.id, rule.id))
|
||||||
|
else:
|
||||||
|
existing = (
|
||||||
|
db.query(TopoFolder)
|
||||||
|
.filter(TopoFolder.kind == "region", TopoFolder.name == region_name)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
hits.append((existing.id if existing else f"new:{region_name}", rule.id))
|
||||||
|
if not hits:
|
||||||
|
return None, None, False
|
||||||
|
return hits[0][0], hits[0][1], len(hits) > 1
|
||||||
|
|
||||||
|
|
||||||
|
def preview_classify(db: Session, *, sample_limit: int = 20) -> ClassifyPreviewOut:
|
||||||
|
role_rules = _enabled_rules(db, "role")
|
||||||
|
region_rules = _enabled_rules(db, "region")
|
||||||
|
nodes = db.query(TopoFabricNode).order_by(TopoFabricNode.name.asc()).all()
|
||||||
|
role_matched = role_unmatched = role_conflict = 0
|
||||||
|
region_matched = region_unmatched = region_conflict = 0
|
||||||
|
role_samples: list[dict[str, Any]] = []
|
||||||
|
region_samples: list[dict[str, Any]] = []
|
||||||
|
unmatched_samples: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for n in nodes:
|
||||||
|
role, _rid, multi_r = _resolve_role_hit(n, role_rules)
|
||||||
|
if role is None:
|
||||||
|
role_unmatched += 1
|
||||||
|
else:
|
||||||
|
role_matched += 1
|
||||||
|
if multi_r:
|
||||||
|
role_conflict += 1
|
||||||
|
if len(role_samples) < sample_limit:
|
||||||
|
role_samples.append(
|
||||||
|
{
|
||||||
|
"fabric_node_id": n.id,
|
||||||
|
"name": n.name,
|
||||||
|
"ip": n.ip,
|
||||||
|
"role": role,
|
||||||
|
"multi_hit": multi_r,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
region_id, _rrid, multi_reg = _resolve_region_hit(
|
||||||
|
db, n, region_rules, create_missing=False
|
||||||
|
)
|
||||||
|
if region_id is None:
|
||||||
|
region_unmatched += 1
|
||||||
|
else:
|
||||||
|
region_matched += 1
|
||||||
|
if multi_reg:
|
||||||
|
region_conflict += 1
|
||||||
|
if len(region_samples) < sample_limit:
|
||||||
|
region_samples.append(
|
||||||
|
{
|
||||||
|
"fabric_node_id": n.id,
|
||||||
|
"name": n.name,
|
||||||
|
"ip": n.ip,
|
||||||
|
"region": region_id,
|
||||||
|
"multi_hit": multi_reg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if role is None and region_id is None and len(unmatched_samples) < sample_limit:
|
||||||
|
unmatched_samples.append(
|
||||||
|
{"fabric_node_id": n.id, "name": n.name, "ip": n.ip, "vendor": n.vendor}
|
||||||
|
)
|
||||||
|
|
||||||
|
return ClassifyPreviewOut(
|
||||||
|
total_nodes=len(nodes),
|
||||||
|
role_matched=role_matched,
|
||||||
|
role_unmatched=role_unmatched,
|
||||||
|
role_conflicts=role_conflict,
|
||||||
|
region_matched=region_matched,
|
||||||
|
region_unmatched=region_unmatched,
|
||||||
|
region_conflicts=region_conflict,
|
||||||
|
role_samples=role_samples,
|
||||||
|
region_samples=region_samples,
|
||||||
|
unmatched_samples=unmatched_samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_classify(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
skip_manual: bool = True,
|
||||||
|
fill_empty_only: bool = False,
|
||||||
|
) -> ClassifyApplyOut:
|
||||||
|
role_rules = _enabled_rules(db, "role")
|
||||||
|
region_rules = _enabled_rules(db, "region")
|
||||||
|
nodes = db.query(TopoFabricNode).all()
|
||||||
|
role_updated = region_updated = skipped_manual = 0
|
||||||
|
|
||||||
|
for n in nodes:
|
||||||
|
role, _, _ = _resolve_role_hit(n, role_rules)
|
||||||
|
if role is not None:
|
||||||
|
if skip_manual and str(n.role_source or "") == "manual":
|
||||||
|
skipped_manual += 1
|
||||||
|
elif fill_empty_only and str(n.role or "").strip():
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
n.role = role
|
||||||
|
n.role_source = "rule"
|
||||||
|
n.updated_at = _utcnow()
|
||||||
|
role_updated += 1
|
||||||
|
elif not str(n.role or "").strip() and str(n.role_source or "") != "manual":
|
||||||
|
n.role = "unknown"
|
||||||
|
n.role_source = "rule"
|
||||||
|
n.updated_at = _utcnow()
|
||||||
|
|
||||||
|
region_id, _, _ = _resolve_region_hit(db, n, region_rules, create_missing=True)
|
||||||
|
if region_id is not None and not str(region_id).startswith("new:"):
|
||||||
|
if skip_manual and str(n.region_source or "") == "manual":
|
||||||
|
skipped_manual += 1
|
||||||
|
elif fill_empty_only and str(n.region_folder_id or "").strip():
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
n.region_folder_id = region_id
|
||||||
|
n.region_source = "rule"
|
||||||
|
n.updated_at = _utcnow()
|
||||||
|
region_updated += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return ClassifyApplyOut(
|
||||||
|
role_updated=role_updated,
|
||||||
|
region_updated=region_updated,
|
||||||
|
skipped_manual=skipped_manual,
|
||||||
|
total_nodes=len(nodes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_unmatched(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
kind: str = "any",
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
q = db.query(TopoFabricNode)
|
||||||
|
k = str(kind or "any").strip().lower()
|
||||||
|
role_miss = or_(TopoFabricNode.role == "", TopoFabricNode.role == "unknown")
|
||||||
|
region_miss = or_(
|
||||||
|
TopoFabricNode.region_folder_id.is_(None),
|
||||||
|
TopoFabricNode.region_folder_id == "",
|
||||||
|
)
|
||||||
|
if k == "role":
|
||||||
|
q = q.filter(role_miss)
|
||||||
|
elif k == "region":
|
||||||
|
q = q.filter(region_miss)
|
||||||
|
else:
|
||||||
|
q = q.filter(or_(role_miss, region_miss))
|
||||||
|
total = q.count()
|
||||||
|
rows = (
|
||||||
|
q.order_by(TopoFabricNode.name.asc())
|
||||||
|
.offset(max(0, (page - 1) * page_size))
|
||||||
|
.limit(page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
from .topology_service import _node_out
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [_node_out(n).model_dump() for n in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def patch_fabric_node_tags(
|
||||||
|
db: Session, fabric_node_id: str, body: FabricNodeTagPatch
|
||||||
|
) -> FabricNodeOut:
|
||||||
|
from .topology_service import _node_out
|
||||||
|
|
||||||
|
n = db.get(TopoFabricNode, fabric_node_id)
|
||||||
|
if n is None:
|
||||||
|
raise HTTPException(status_code=404, detail="fabric_node_not_found")
|
||||||
|
if body.role is not None:
|
||||||
|
role = str(body.role or "").strip().lower()
|
||||||
|
if role and role not in _ROLE_VALUES:
|
||||||
|
raise HTTPException(status_code=400, detail="role_invalid")
|
||||||
|
n.role = role
|
||||||
|
n.role_source = "manual"
|
||||||
|
if body.region_folder_id is not None:
|
||||||
|
fid = str(body.region_folder_id or "").strip()
|
||||||
|
if fid:
|
||||||
|
folder = db.get(TopoFolder, fid)
|
||||||
|
if folder is None or str(folder.kind or "") != "region":
|
||||||
|
raise HTTPException(status_code=400, detail="folder_not_found")
|
||||||
|
n.region_folder_id = fid
|
||||||
|
else:
|
||||||
|
n.region_folder_id = None
|
||||||
|
n.region_source = "manual"
|
||||||
|
n.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(n)
|
||||||
|
return _node_out(n)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_regex_matches(
|
||||||
|
db: Session, *, pattern: str, match_field: str
|
||||||
|
) -> list[TopoFabricNode]:
|
||||||
|
cre = _compile_pattern(pattern)
|
||||||
|
mf = str(match_field or "name").strip().lower()
|
||||||
|
if mf not in _MATCH_FIELDS:
|
||||||
|
raise HTTPException(status_code=400, detail="match_field_invalid")
|
||||||
|
out: list[TopoFabricNode] = []
|
||||||
|
for n in db.query(TopoFabricNode).order_by(TopoFabricNode.name.asc()).all():
|
||||||
|
text = _match_text(n, mf)
|
||||||
|
if text and cre.search(text):
|
||||||
|
out.append(n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def match_fabric_nodes(db: Session, body: FabricNodesMatchRequest) -> FabricNodesMatchOut:
|
||||||
|
"""Ephemeral regex lookup — does not persist rules."""
|
||||||
|
from .topology_inventory_lifecycle import fabric_link_status
|
||||||
|
|
||||||
|
matched = _iter_regex_matches(
|
||||||
|
db, pattern=body.pattern, match_field=body.match_field
|
||||||
|
)
|
||||||
|
limit = max(1, min(200, int(body.sample_limit or 50)))
|
||||||
|
samples = [
|
||||||
|
{
|
||||||
|
"fabric_node_id": n.id,
|
||||||
|
"name": n.name,
|
||||||
|
"ip": n.ip,
|
||||||
|
"role": n.role or "",
|
||||||
|
"region_folder_id": n.region_folder_id or "",
|
||||||
|
"link_status": fabric_link_status(n),
|
||||||
|
}
|
||||||
|
for n in matched[:limit]
|
||||||
|
]
|
||||||
|
return FabricNodesMatchOut(
|
||||||
|
pattern=str(body.pattern or "").strip(),
|
||||||
|
match_field=str(body.match_field or "name"),
|
||||||
|
total_matched=len(matched),
|
||||||
|
samples=samples,
|
||||||
|
fabric_node_ids=[n.id for n in matched],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_tag_fabric_nodes(
|
||||||
|
db: Session, body: FabricNodesBulkTagRequest
|
||||||
|
) -> FabricNodesBulkTagOut:
|
||||||
|
"""Assign role/region after user confirms a regex or explicit selection."""
|
||||||
|
if body.role is None and body.region_folder_id is None:
|
||||||
|
raise HTTPException(status_code=400, detail="role_or_region_required")
|
||||||
|
|
||||||
|
role_v: str | None = None
|
||||||
|
if body.role is not None:
|
||||||
|
role_v = str(body.role or "").strip().lower()
|
||||||
|
if role_v and role_v not in _ROLE_VALUES:
|
||||||
|
raise HTTPException(status_code=400, detail="role_invalid")
|
||||||
|
|
||||||
|
region_v: str | None = None
|
||||||
|
if body.region_folder_id is not None:
|
||||||
|
region_v = str(body.region_folder_id or "").strip()
|
||||||
|
if region_v:
|
||||||
|
folder = db.get(TopoFolder, region_v)
|
||||||
|
if folder is None or str(folder.kind or "") != "region":
|
||||||
|
raise HTTPException(status_code=400, detail="folder_not_found")
|
||||||
|
else:
|
||||||
|
region_v = ""
|
||||||
|
|
||||||
|
ids = [str(x).strip() for x in (body.fabric_node_ids or []) if str(x).strip()]
|
||||||
|
if str(body.pattern or "").strip():
|
||||||
|
matched_nodes = _iter_regex_matches(
|
||||||
|
db, pattern=body.pattern, match_field=body.match_field
|
||||||
|
)
|
||||||
|
elif ids:
|
||||||
|
matched_nodes = (
|
||||||
|
db.query(TopoFabricNode)
|
||||||
|
.filter(TopoFabricNode.id.in_(ids))
|
||||||
|
.order_by(TopoFabricNode.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="ids_or_pattern_required")
|
||||||
|
|
||||||
|
samples = [
|
||||||
|
{
|
||||||
|
"fabric_node_id": n.id,
|
||||||
|
"name": n.name,
|
||||||
|
"ip": n.ip,
|
||||||
|
"role": n.role or "",
|
||||||
|
"region_folder_id": n.region_folder_id or "",
|
||||||
|
}
|
||||||
|
for n in matched_nodes[:50]
|
||||||
|
]
|
||||||
|
if body.dry_run:
|
||||||
|
return FabricNodesBulkTagOut(
|
||||||
|
dry_run=True,
|
||||||
|
matched=len(matched_nodes),
|
||||||
|
updated=0,
|
||||||
|
role=role_v,
|
||||||
|
region_folder_id=region_v,
|
||||||
|
samples=samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _utcnow()
|
||||||
|
updated = 0
|
||||||
|
for n in matched_nodes:
|
||||||
|
if role_v is not None:
|
||||||
|
n.role = role_v
|
||||||
|
n.role_source = "manual"
|
||||||
|
if region_v is not None:
|
||||||
|
n.region_folder_id = region_v or None
|
||||||
|
n.region_source = "manual"
|
||||||
|
n.updated_at = now
|
||||||
|
updated += 1
|
||||||
|
db.commit()
|
||||||
|
return FabricNodesBulkTagOut(
|
||||||
|
dry_run=False,
|
||||||
|
matched=len(matched_nodes),
|
||||||
|
updated=updated,
|
||||||
|
role=role_v,
|
||||||
|
region_folder_id=region_v,
|
||||||
|
samples=samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_classify_empty_only(db: Session) -> ClassifyApplyOut:
|
||||||
|
"""Incremental classify for newly synced nodes (fill empty tags only)."""
|
||||||
|
return apply_classify(db, skip_manual=True, fill_empty_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Slice generation -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _active_neighbors(db: Session, seed_ids: set[str], *, hops: int = 1) -> set[str]:
|
||||||
|
if not seed_ids or hops <= 0:
|
||||||
|
return set()
|
||||||
|
frontier = set(seed_ids)
|
||||||
|
found: set[str] = set()
|
||||||
|
for _ in range(hops):
|
||||||
|
if not frontier:
|
||||||
|
break
|
||||||
|
rows = (
|
||||||
|
db.query(TopoFabricEdge)
|
||||||
|
.filter(
|
||||||
|
TopoFabricEdge.layer == "physical",
|
||||||
|
TopoFabricEdge.status == "active",
|
||||||
|
(TopoFabricEdge.a_node_id.in_(frontier) | TopoFabricEdge.b_node_id.in_(frontier)),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
nxt: set[str] = set()
|
||||||
|
for e in rows:
|
||||||
|
for a, b in ((e.a_node_id, e.b_node_id), (e.b_node_id, e.a_node_id)):
|
||||||
|
if a in frontier and b not in seed_ids and b not in found:
|
||||||
|
nxt.add(str(b))
|
||||||
|
found |= nxt
|
||||||
|
frontier = nxt
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _connected_components(db: Session, node_ids: list[str]) -> list[list[str]]:
|
||||||
|
ids = [str(x) for x in node_ids if str(x)]
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
id_set = set(ids)
|
||||||
|
adj: dict[str, set[str]] = {i: set() for i in ids}
|
||||||
|
rows = (
|
||||||
|
db.query(TopoFabricEdge)
|
||||||
|
.filter(
|
||||||
|
TopoFabricEdge.layer == "physical",
|
||||||
|
TopoFabricEdge.status == "active",
|
||||||
|
TopoFabricEdge.a_node_id.in_(ids),
|
||||||
|
TopoFabricEdge.b_node_id.in_(ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for e in rows:
|
||||||
|
a, b = str(e.a_node_id), str(e.b_node_id)
|
||||||
|
if a in id_set and b in id_set:
|
||||||
|
adj[a].add(b)
|
||||||
|
adj[b].add(a)
|
||||||
|
seen: set[str] = set()
|
||||||
|
comps: list[list[str]] = []
|
||||||
|
for nid in ids:
|
||||||
|
if nid in seen:
|
||||||
|
continue
|
||||||
|
stack = [nid]
|
||||||
|
seen.add(nid)
|
||||||
|
comp: list[str] = []
|
||||||
|
while stack:
|
||||||
|
cur = stack.pop()
|
||||||
|
comp.append(cur)
|
||||||
|
for nb in adj.get(cur, ()):
|
||||||
|
if nb not in seen:
|
||||||
|
seen.add(nb)
|
||||||
|
stack.append(nb)
|
||||||
|
comps.append(sorted(comp))
|
||||||
|
return comps
|
||||||
|
|
||||||
|
|
||||||
|
def _nodes_in_region(db: Session, folder_id: str, *, role: str = "") -> list[TopoFabricNode]:
|
||||||
|
q = db.query(TopoFabricNode).filter(TopoFabricNode.region_folder_id == folder_id)
|
||||||
|
if role:
|
||||||
|
q = q.filter(TopoFabricNode.role == role)
|
||||||
|
return q.order_by(TopoFabricNode.name.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def preview_slices(db: Session, body: SliceGenerateRequest) -> SliceGenerateOut:
|
||||||
|
folder = db.get(TopoFolder, body.folder_id)
|
||||||
|
if folder is None or str(folder.kind or "") != "region":
|
||||||
|
raise HTTPException(status_code=400, detail="folder_not_found")
|
||||||
|
template = str(body.template or "").strip().lower()
|
||||||
|
if template not in _SLICE_TEMPLATES:
|
||||||
|
raise HTTPException(status_code=400, detail="template_invalid")
|
||||||
|
max_nodes = max(1, min(2000, int(body.max_nodes or 300)))
|
||||||
|
plans: list[SliceMapPlan] = []
|
||||||
|
overlap_ids: set[str] = set()
|
||||||
|
seen_in_maps: dict[str, int] = {}
|
||||||
|
|
||||||
|
def _track(ids: list[str]) -> None:
|
||||||
|
for i in ids:
|
||||||
|
seen_in_maps[i] = seen_in_maps.get(i, 0) + 1
|
||||||
|
|
||||||
|
if template == "core_only":
|
||||||
|
cores = _nodes_in_region(db, folder.id, role=VIEW_ROLE_CORE)
|
||||||
|
comps = _connected_components(db, [n.id for n in cores]) or [
|
||||||
|
[n.id] for n in cores
|
||||||
|
]
|
||||||
|
for idx, comp in enumerate(comps, start=1):
|
||||||
|
if len(comp) > max_nodes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"slice_exceeds_max_nodes:{len(comp)}>{max_nodes}",
|
||||||
|
)
|
||||||
|
name = f"Core-{idx}" if len(comps) > 1 else "Core"
|
||||||
|
plans.append(
|
||||||
|
SliceMapPlan(
|
||||||
|
name=name,
|
||||||
|
role=VIEW_ROLE_CORE,
|
||||||
|
seed_fabric_node_ids=comp,
|
||||||
|
member_fabric_node_ids=comp,
|
||||||
|
node_count=len(comp),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_track(comp)
|
||||||
|
elif template == "core_agg":
|
||||||
|
cores = _nodes_in_region(db, folder.id, role=VIEW_ROLE_CORE)
|
||||||
|
comps = _connected_components(db, [n.id for n in cores]) or [
|
||||||
|
[n.id] for n in cores
|
||||||
|
]
|
||||||
|
for idx, comp in enumerate(comps, start=1):
|
||||||
|
peers = _active_neighbors(db, set(comp), hops=1)
|
||||||
|
agg_ids = [
|
||||||
|
p
|
||||||
|
for p in peers
|
||||||
|
if (fn := db.get(TopoFabricNode, p)) is not None
|
||||||
|
and str(fn.role or "") == VIEW_ROLE_AGGREGATION
|
||||||
|
and str(fn.region_folder_id or "") == folder.id
|
||||||
|
]
|
||||||
|
members = sorted(set(comp) | set(agg_ids))
|
||||||
|
if len(members) > max_nodes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"slice_exceeds_max_nodes:{len(members)}>{max_nodes}",
|
||||||
|
)
|
||||||
|
name = f"CoreAgg-{idx}" if len(comps) > 1 else "Core+Agg"
|
||||||
|
plans.append(
|
||||||
|
SliceMapPlan(
|
||||||
|
name=name,
|
||||||
|
role=VIEW_ROLE_CORE,
|
||||||
|
seed_fabric_node_ids=comp,
|
||||||
|
member_fabric_node_ids=members,
|
||||||
|
node_count=len(members),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_track(members)
|
||||||
|
else: # agg_access
|
||||||
|
aggs = _nodes_in_region(db, folder.id, role=VIEW_ROLE_AGGREGATION)
|
||||||
|
comps = _connected_components(db, [n.id for n in aggs]) or [
|
||||||
|
[n.id] for n in aggs
|
||||||
|
]
|
||||||
|
for idx, comp in enumerate(comps, start=1):
|
||||||
|
peers = _active_neighbors(db, set(comp), hops=1)
|
||||||
|
acc_ids = [
|
||||||
|
p
|
||||||
|
for p in peers
|
||||||
|
if (fn := db.get(TopoFabricNode, p)) is not None
|
||||||
|
and str(fn.role or "") == VIEW_ROLE_ACCESS
|
||||||
|
and str(fn.region_folder_id or "") == folder.id
|
||||||
|
]
|
||||||
|
members = sorted(set(comp) | set(acc_ids))
|
||||||
|
if len(members) > max_nodes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"slice_exceeds_max_nodes:{len(members)}>{max_nodes}",
|
||||||
|
)
|
||||||
|
name = f"AggAccess-{idx}" if len(comps) > 1 else "Agg+Access"
|
||||||
|
plans.append(
|
||||||
|
SliceMapPlan(
|
||||||
|
name=name,
|
||||||
|
role=VIEW_ROLE_AGGREGATION,
|
||||||
|
seed_fabric_node_ids=comp,
|
||||||
|
member_fabric_node_ids=members,
|
||||||
|
node_count=len(members),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_track(members)
|
||||||
|
|
||||||
|
overlap_ids = {nid for nid, cnt in seen_in_maps.items() if cnt > 1}
|
||||||
|
return SliceGenerateOut(
|
||||||
|
folder_id=folder.id,
|
||||||
|
template=template,
|
||||||
|
dry_run=True,
|
||||||
|
maps=plans,
|
||||||
|
map_count=len(plans),
|
||||||
|
overlap_node_count=len(overlap_ids),
|
||||||
|
created_view_ids=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_slices(db: Session, body: SliceGenerateRequest) -> SliceGenerateOut:
|
||||||
|
from .topology_service import create_view, _place_fabric_ids_on_view
|
||||||
|
|
||||||
|
preview = preview_slices(db, body)
|
||||||
|
if body.dry_run:
|
||||||
|
return preview
|
||||||
|
|
||||||
|
created: list[str] = []
|
||||||
|
for plan in preview.maps:
|
||||||
|
view = create_view(
|
||||||
|
db,
|
||||||
|
TopologyViewCreate(
|
||||||
|
name=plan.name,
|
||||||
|
folder_id=body.folder_id,
|
||||||
|
kind=VIEW_KIND_CUSTOM,
|
||||||
|
role=plan.role,
|
||||||
|
remark=f"slice:{body.template}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mem = {
|
||||||
|
"mode": "hybrid",
|
||||||
|
"seed_fabric_node_ids": list(plan.member_fabric_node_ids),
|
||||||
|
"expand_hops": 0,
|
||||||
|
"max_nodes": int(body.max_nodes or 300),
|
||||||
|
"frozen": True,
|
||||||
|
"managed_ne_ids": [],
|
||||||
|
"tags_any": [],
|
||||||
|
"vendors": [],
|
||||||
|
"device_types": [],
|
||||||
|
"keyword": "",
|
||||||
|
}
|
||||||
|
row = db.get(TopoView, view.id)
|
||||||
|
assert row is not None
|
||||||
|
row.filter = merge_filter_with_membership(
|
||||||
|
dict(row.filter or {}), role=normalize_view_role(plan.role), membership=mem
|
||||||
|
)
|
||||||
|
_place_fabric_ids_on_view(db, row, list(plan.member_fabric_node_ids), existing=set())
|
||||||
|
row.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
created.append(view.id)
|
||||||
|
|
||||||
|
# Optionally seed physical overview with cores only
|
||||||
|
if body.seed_physical_cores:
|
||||||
|
from .topology_service import ensure_region_physical_view
|
||||||
|
|
||||||
|
phys = ensure_region_physical_view(db, body.folder_id, commit=True)
|
||||||
|
cores = [n.id for n in _nodes_in_region(db, body.folder_id, role=VIEW_ROLE_CORE)]
|
||||||
|
existing = {
|
||||||
|
vn.fabric_node_id
|
||||||
|
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == phys.id).all()
|
||||||
|
}
|
||||||
|
to_add = [c for c in cores if c not in existing][: int(body.max_nodes or 300)]
|
||||||
|
if to_add:
|
||||||
|
_place_fabric_ids_on_view(db, phys, to_add, existing=existing)
|
||||||
|
mem = merge_filter_with_membership(
|
||||||
|
dict(phys.filter or {}),
|
||||||
|
role=VIEW_ROLE_CORE,
|
||||||
|
kind="physical",
|
||||||
|
membership={
|
||||||
|
**dict((phys.filter or {}).get("membership") or {}),
|
||||||
|
"frozen": True,
|
||||||
|
"max_nodes": int(body.max_nodes or 500),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
phys.filter = mem
|
||||||
|
phys.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return SliceGenerateOut(
|
||||||
|
folder_id=body.folder_id,
|
||||||
|
template=str(body.template),
|
||||||
|
dry_run=False,
|
||||||
|
maps=preview.maps,
|
||||||
|
map_count=len(preview.maps),
|
||||||
|
overlap_node_count=preview.overlap_node_count,
|
||||||
|
created_view_ids=created,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_fabric_nodes_with_views(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
keyword: str = "",
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from .topology_service import _node_out
|
||||||
|
|
||||||
|
q = db.query(TopoFabricNode)
|
||||||
|
kw = str(keyword or "").strip()
|
||||||
|
if kw:
|
||||||
|
like = f"%{kw}%"
|
||||||
|
q = q.filter(
|
||||||
|
(TopoFabricNode.name.ilike(like))
|
||||||
|
| (TopoFabricNode.ip.ilike(like))
|
||||||
|
| (TopoFabricNode.vendor.ilike(like))
|
||||||
|
)
|
||||||
|
total = q.count()
|
||||||
|
rows = (
|
||||||
|
q.order_by(TopoFabricNode.name.asc())
|
||||||
|
.offset(max(0, (page - 1) * page_size))
|
||||||
|
.limit(page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
node_ids = [n.id for n in rows]
|
||||||
|
placements: dict[str, list[dict[str, Any]]] = {nid: [] for nid in node_ids}
|
||||||
|
if node_ids:
|
||||||
|
vnodes = (
|
||||||
|
db.query(TopoViewNode, TopoView, TopoFolder)
|
||||||
|
.join(TopoView, TopoView.id == TopoViewNode.view_id)
|
||||||
|
.outerjoin(TopoFolder, TopoFolder.id == TopoView.folder_id)
|
||||||
|
.filter(TopoViewNode.fabric_node_id.in_(node_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for vn, view, folder in vnodes:
|
||||||
|
placements.setdefault(vn.fabric_node_id, []).append(
|
||||||
|
{
|
||||||
|
"view_id": view.id,
|
||||||
|
"view_name": view.name,
|
||||||
|
"folder_id": view.folder_id or "",
|
||||||
|
"folder_name": (folder.name if folder else "") or "",
|
||||||
|
"kind": view.kind or "custom",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
for n in rows:
|
||||||
|
d = _node_out(n).model_dump()
|
||||||
|
d["views"] = placements.get(n.id, [])
|
||||||
|
items.append(d)
|
||||||
|
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
||||||
193
netx_api/topology_inventory_lifecycle.py
Normal file
193
netx_api/topology_inventory_lifecycle.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Detach fabric ↔ inventory links without deleting topology placements/edges."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE
|
||||||
|
from .models import ManagedNE, TopoFabricNode, TopoView, UmeInventoryNE
|
||||||
|
|
||||||
|
UME_SYNC_SOURCE = "ume_sync"
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_ids(ids: list[str] | None) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in ids or []:
|
||||||
|
s = str(raw or "").strip()
|
||||||
|
if not s or s in seen:
|
||||||
|
continue
|
||||||
|
seen.add(s)
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fabric_link_status(n: TopoFabricNode) -> str:
|
||||||
|
mid = str(n.managed_ne_id or "").strip()
|
||||||
|
uid = str(n.ume_ne_id or "").strip()
|
||||||
|
if mid and uid:
|
||||||
|
return "both"
|
||||||
|
if mid:
|
||||||
|
return "managed"
|
||||||
|
if uid:
|
||||||
|
return "ume"
|
||||||
|
return "orphaned"
|
||||||
|
|
||||||
|
|
||||||
|
def managed_source_label(source: str | None) -> str:
|
||||||
|
src = str(source or "").strip().lower()
|
||||||
|
if src == UME_SYNC_SOURCE:
|
||||||
|
return "ume_sync"
|
||||||
|
if src == LLDP_DISCOVERED_NE_SOURCE or src == "lldp":
|
||||||
|
return "lldp"
|
||||||
|
if src == WEBCRT_NE_SOURCE or src == "webcrt":
|
||||||
|
return "webcrt"
|
||||||
|
if not src:
|
||||||
|
return "manual"
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_membership_ids(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
managed_ne_ids: set[str] | None = None,
|
||||||
|
ume_ne_ids: set[str] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Remove deleted inventory ids from view membership JSON. Returns views touched."""
|
||||||
|
mids = managed_ne_ids or set()
|
||||||
|
uids = ume_ne_ids or set()
|
||||||
|
if not mids and not uids:
|
||||||
|
return 0
|
||||||
|
touched = 0
|
||||||
|
for view in db.query(TopoView).all():
|
||||||
|
filt = dict(view.filter or {})
|
||||||
|
mem = filt.get("membership")
|
||||||
|
if not isinstance(mem, dict):
|
||||||
|
continue
|
||||||
|
changed = False
|
||||||
|
if mids and isinstance(mem.get("managed_ne_ids"), list):
|
||||||
|
next_m = [str(x).strip() for x in mem["managed_ne_ids"] if str(x).strip() not in mids]
|
||||||
|
if next_m != [str(x).strip() for x in mem["managed_ne_ids"] if str(x).strip()]:
|
||||||
|
mem["managed_ne_ids"] = next_m
|
||||||
|
changed = True
|
||||||
|
if uids and isinstance(mem.get("ume_ne_ids"), list):
|
||||||
|
next_u = [str(x).strip() for x in mem["ume_ne_ids"] if str(x).strip() not in uids]
|
||||||
|
if next_u != [str(x).strip() for x in mem["ume_ne_ids"] if str(x).strip()]:
|
||||||
|
mem["ume_ne_ids"] = next_u
|
||||||
|
changed = True
|
||||||
|
if not changed:
|
||||||
|
continue
|
||||||
|
filt["membership"] = mem
|
||||||
|
view.filter = filt
|
||||||
|
flag_modified(view, "filter")
|
||||||
|
view.updated_at = _utcnow()
|
||||||
|
touched += 1
|
||||||
|
return touched
|
||||||
|
|
||||||
|
|
||||||
|
def detach_fabric_from_managed(db: Session, managed_ne_ids: list[str]) -> dict[str, int]:
|
||||||
|
"""Clear fabric.managed_ne_id for deleted ManagedNEs; keep nodes/placements/edges."""
|
||||||
|
ids = _norm_ids(managed_ne_ids)
|
||||||
|
if not ids:
|
||||||
|
return {"detached_nodes": 0, "membership_views": 0}
|
||||||
|
now = _utcnow()
|
||||||
|
rows = (
|
||||||
|
db.query(TopoFabricNode)
|
||||||
|
.filter(TopoFabricNode.managed_ne_id.in_(ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
row.managed_ne_id = None
|
||||||
|
row.updated_at = now
|
||||||
|
views = _strip_membership_ids(db, managed_ne_ids=set(ids))
|
||||||
|
return {"detached_nodes": len(rows), "membership_views": views}
|
||||||
|
|
||||||
|
|
||||||
|
def detach_fabric_from_ume(db: Session, ume_ne_ids: list[str]) -> dict[str, int]:
|
||||||
|
"""Clear fabric.ume_ne_id for deleted UME inventory rows; keep topology traces."""
|
||||||
|
ids = _norm_ids(ume_ne_ids)
|
||||||
|
if not ids:
|
||||||
|
return {"detached_nodes": 0, "membership_views": 0}
|
||||||
|
now = _utcnow()
|
||||||
|
rows = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.in_(ids)).all()
|
||||||
|
for row in rows:
|
||||||
|
row.ume_ne_id = None
|
||||||
|
row.updated_at = now
|
||||||
|
views = _strip_membership_ids(db, ume_ne_ids=set(ids))
|
||||||
|
return {"detached_nodes": len(rows), "membership_views": views}
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_dangling_fabric_links(db: Session) -> dict[str, int]:
|
||||||
|
"""Unbind fabric ids that no longer exist in managed_ne / ume_inventory_ne."""
|
||||||
|
managed_alive = {str(x[0]) for x in db.query(ManagedNE.id).all() if str(x[0] or "").strip()}
|
||||||
|
ume_alive = {
|
||||||
|
str(x[0]) for x in db.query(UmeInventoryNE.ne_id).all() if str(x[0] or "").strip()
|
||||||
|
}
|
||||||
|
dangling_m: list[str] = []
|
||||||
|
dangling_u: list[str] = []
|
||||||
|
for n in db.query(TopoFabricNode).all():
|
||||||
|
mid = str(n.managed_ne_id or "").strip()
|
||||||
|
uid = str(n.ume_ne_id or "").strip()
|
||||||
|
if mid and mid not in managed_alive:
|
||||||
|
dangling_m.append(mid)
|
||||||
|
if uid and uid not in ume_alive:
|
||||||
|
dangling_u.append(uid)
|
||||||
|
m_stats = detach_fabric_from_managed(db, dangling_m)
|
||||||
|
u_stats = detach_fabric_from_ume(db, dangling_u)
|
||||||
|
return {
|
||||||
|
"dangling_managed_refs": len(set(dangling_m)),
|
||||||
|
"dangling_ume_refs": len(set(dangling_u)),
|
||||||
|
"detached_managed_nodes": int(m_stats.get("detached_nodes") or 0),
|
||||||
|
"detached_ume_nodes": int(u_stats.get("detached_nodes") or 0),
|
||||||
|
"membership_views": int(m_stats.get("membership_views") or 0)
|
||||||
|
+ int(u_stats.get("membership_views") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_fabric_node_dicts(
|
||||||
|
db: Session, items: list[dict[str, Any]]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Add link_status / alive flags / managed_source onto FabricNodeOut dicts."""
|
||||||
|
if not items:
|
||||||
|
return items
|
||||||
|
mids = [str(x.get("managed_ne_id") or "").strip() for x in items]
|
||||||
|
uids = [str(x.get("ume_ne_id") or "").strip() for x in items]
|
||||||
|
mids_f = [x for x in mids if x]
|
||||||
|
uids_f = [x for x in uids if x]
|
||||||
|
managed_by_id: dict[str, ManagedNE] = {}
|
||||||
|
if mids_f:
|
||||||
|
for row in db.query(ManagedNE).filter(ManagedNE.id.in_(mids_f)).all():
|
||||||
|
managed_by_id[str(row.id)] = row
|
||||||
|
ume_alive: set[str] = set()
|
||||||
|
if uids_f:
|
||||||
|
ume_alive = {
|
||||||
|
str(x[0])
|
||||||
|
for x in db.query(UmeInventoryNE.ne_id)
|
||||||
|
.filter(UmeInventoryNE.ne_id.in_(uids_f))
|
||||||
|
.all()
|
||||||
|
if str(x[0] or "").strip()
|
||||||
|
}
|
||||||
|
for item in items:
|
||||||
|
mid = str(item.get("managed_ne_id") or "").strip()
|
||||||
|
uid = str(item.get("ume_ne_id") or "").strip()
|
||||||
|
if mid and uid:
|
||||||
|
item["link_status"] = "both"
|
||||||
|
elif mid:
|
||||||
|
item["link_status"] = "managed"
|
||||||
|
elif uid:
|
||||||
|
item["link_status"] = "ume"
|
||||||
|
else:
|
||||||
|
item["link_status"] = "orphaned"
|
||||||
|
mrow = managed_by_id.get(mid) if mid else None
|
||||||
|
item["managed_alive"] = bool(mrow)
|
||||||
|
item["ume_alive"] = bool(uid and uid in ume_alive)
|
||||||
|
item["managed_source"] = managed_source_label(mrow.source) if mrow else ""
|
||||||
|
return items
|
||||||
141
netx_api/topology_membership.py
Normal file
141
netx_api/topology_membership.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
"""View membership boundaries — keep leaf canvases from sucking in the whole fabric."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
VIEW_ROLE_CORE = "core"
|
||||||
|
VIEW_ROLE_AGGREGATION = "aggregation"
|
||||||
|
VIEW_ROLE_ACCESS = "access"
|
||||||
|
VIEW_ROLES = frozenset({VIEW_ROLE_CORE, VIEW_ROLE_AGGREGATION, VIEW_ROLE_ACCESS})
|
||||||
|
|
||||||
|
VIEW_KIND_PHYSICAL = "physical"
|
||||||
|
VIEW_KIND_CUSTOM = "custom"
|
||||||
|
VIEW_KINDS = frozenset({VIEW_KIND_PHYSICAL, VIEW_KIND_CUSTOM})
|
||||||
|
|
||||||
|
ROLE_DEFAULT_MAX_NODES = {
|
||||||
|
VIEW_ROLE_CORE: 80,
|
||||||
|
VIEW_ROLE_AGGREGATION: 200,
|
||||||
|
VIEW_ROLE_ACCESS: 300,
|
||||||
|
}
|
||||||
|
KIND_DEFAULT_MAX_NODES = {
|
||||||
|
VIEW_KIND_PHYSICAL: 500,
|
||||||
|
VIEW_KIND_CUSTOM: 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
MEMBERSHIP_HARD_CAP = 2000
|
||||||
|
DEFAULT_EXPAND_HOPS = 1
|
||||||
|
MAX_EXPAND_HOPS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_view_role(role: str | None) -> str:
|
||||||
|
r = str(role or "").strip().lower()
|
||||||
|
if r in VIEW_ROLES:
|
||||||
|
return r
|
||||||
|
return VIEW_ROLE_CORE
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_view_kind(kind: str | None) -> str:
|
||||||
|
k = str(kind or "").strip().lower()
|
||||||
|
if k in VIEW_KINDS:
|
||||||
|
return k
|
||||||
|
return VIEW_KIND_CUSTOM
|
||||||
|
|
||||||
|
|
||||||
|
def default_max_nodes_for_role(role: str) -> int:
|
||||||
|
return int(ROLE_DEFAULT_MAX_NODES.get(normalize_view_role(role), 300))
|
||||||
|
|
||||||
|
|
||||||
|
def default_max_nodes_for_kind(kind: str) -> int:
|
||||||
|
return int(KIND_DEFAULT_MAX_NODES.get(normalize_view_kind(kind), 300))
|
||||||
|
|
||||||
|
|
||||||
|
def default_membership(
|
||||||
|
role: str = VIEW_ROLE_CORE, *, kind: str = VIEW_KIND_CUSTOM
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
k = normalize_view_kind(kind)
|
||||||
|
max_nodes = (
|
||||||
|
default_max_nodes_for_kind(k)
|
||||||
|
if k == VIEW_KIND_PHYSICAL
|
||||||
|
else default_max_nodes_for_role(role)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mode": "hybrid",
|
||||||
|
"managed_ne_ids": [],
|
||||||
|
"tags_any": [],
|
||||||
|
"vendors": [],
|
||||||
|
"device_types": [],
|
||||||
|
"keyword": "",
|
||||||
|
"seed_fabric_node_ids": [],
|
||||||
|
"expand_hops": DEFAULT_EXPAND_HOPS,
|
||||||
|
"max_nodes": max_nodes,
|
||||||
|
"frozen": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_membership(
|
||||||
|
filt: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
role: str = VIEW_ROLE_CORE,
|
||||||
|
kind: str = VIEW_KIND_CUSTOM,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
base = default_membership(role, kind=kind)
|
||||||
|
raw = dict(filt or {})
|
||||||
|
mem = raw.get("membership")
|
||||||
|
if not isinstance(mem, dict):
|
||||||
|
return base
|
||||||
|
out = dict(base)
|
||||||
|
out["mode"] = str(mem.get("mode") or base["mode"]).strip() or "hybrid"
|
||||||
|
out["managed_ne_ids"] = [
|
||||||
|
str(x).strip() for x in (mem.get("managed_ne_ids") or []) if str(x).strip()
|
||||||
|
]
|
||||||
|
out["tags_any"] = [str(x).strip() for x in (mem.get("tags_any") or []) if str(x).strip()]
|
||||||
|
out["vendors"] = [str(x).strip() for x in (mem.get("vendors") or []) if str(x).strip()]
|
||||||
|
out["device_types"] = [
|
||||||
|
str(x).strip() for x in (mem.get("device_types") or []) if str(x).strip()
|
||||||
|
]
|
||||||
|
out["keyword"] = str(mem.get("keyword") or "").strip()
|
||||||
|
out["seed_fabric_node_ids"] = [
|
||||||
|
str(x).strip() for x in (mem.get("seed_fabric_node_ids") or []) if str(x).strip()
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
hops = int(mem.get("expand_hops", DEFAULT_EXPAND_HOPS))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
hops = DEFAULT_EXPAND_HOPS
|
||||||
|
out["expand_hops"] = max(0, min(MAX_EXPAND_HOPS, hops))
|
||||||
|
try:
|
||||||
|
mx = int(mem.get("max_nodes", base["max_nodes"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
mx = int(base["max_nodes"])
|
||||||
|
out["max_nodes"] = max(1, min(MEMBERSHIP_HARD_CAP, mx))
|
||||||
|
out["frozen"] = bool(mem.get("frozen", False))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def merge_filter_with_membership(
|
||||||
|
filt: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
kind: str = VIEW_KIND_CUSTOM,
|
||||||
|
membership: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
out = dict(filt or {})
|
||||||
|
layer = str(out.get("layer") or "physical").strip() or "physical"
|
||||||
|
out["layer"] = layer
|
||||||
|
mem = (
|
||||||
|
membership
|
||||||
|
if membership is not None
|
||||||
|
else parse_membership(out, role=role, kind=kind)
|
||||||
|
)
|
||||||
|
out["membership"] = mem
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def has_hard_scope(mem: dict[str, Any]) -> bool:
|
||||||
|
return bool(
|
||||||
|
mem.get("managed_ne_ids")
|
||||||
|
or mem.get("tags_any")
|
||||||
|
or mem.get("vendors")
|
||||||
|
or mem.get("device_types")
|
||||||
|
or mem.get("keyword")
|
||||||
|
)
|
||||||
|
|
@ -36,6 +36,42 @@ def ensure_topology_schema(conn: Connection) -> None:
|
||||||
"ALTER TABLE topo_discover_job ADD COLUMN IF NOT EXISTS trigger_mode VARCHAR(32) DEFAULT 'manual'",
|
"ALTER TABLE topo_discover_job ADD COLUMN IF NOT EXISTS trigger_mode VARCHAR(32) DEFAULT 'manual'",
|
||||||
"ALTER TABLE lldp_collect_policy ADD COLUMN IF NOT EXISTS history_keep INTEGER DEFAULT 30",
|
"ALTER TABLE lldp_collect_policy ADD COLUMN IF NOT EXISTS history_keep INTEGER DEFAULT 30",
|
||||||
"ALTER TABLE lldp_collect_policy ADD COLUMN IF NOT EXISTS interval_hours INTEGER DEFAULT 24",
|
"ALTER TABLE lldp_collect_policy ADD COLUMN IF NOT EXISTS interval_hours INTEGER DEFAULT 24",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS folder_id VARCHAR(64)",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS parent_view_id VARCHAR(64)",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS kind VARCHAR(32) DEFAULT 'custom'",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS role VARCHAR(32) DEFAULT 'core'",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS sort_order INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role VARCHAR(32) DEFAULT ''",
|
||||||
|
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_folder_id VARCHAR(64)",
|
||||||
|
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role_source VARCHAR(16) DEFAULT ''",
|
||||||
|
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_source VARCHAR(16) DEFAULT ''",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS topo_folder (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
parent_id VARCHAR(64),
|
||||||
|
kind VARCHAR(32) DEFAULT 'region',
|
||||||
|
name VARCHAR(256) DEFAULT '',
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
is_system BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS topo_classify_rule (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
scope VARCHAR(16) DEFAULT 'role',
|
||||||
|
name VARCHAR(256) DEFAULT '',
|
||||||
|
pattern VARCHAR(512) DEFAULT '',
|
||||||
|
match_field VARCHAR(32) DEFAULT 'name',
|
||||||
|
priority INTEGER DEFAULT 100,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
payload JSONB DEFAULT '{}',
|
||||||
|
remark VARCHAR(512) DEFAULT '',
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
elif dialect.startswith("sqlite"):
|
elif dialect.startswith("sqlite"):
|
||||||
|
|
@ -45,6 +81,42 @@ def ensure_topology_schema(conn: Connection) -> None:
|
||||||
"ALTER TABLE topo_discover_job ADD COLUMN trigger_mode VARCHAR(32) DEFAULT 'manual'",
|
"ALTER TABLE topo_discover_job ADD COLUMN trigger_mode VARCHAR(32) DEFAULT 'manual'",
|
||||||
"ALTER TABLE lldp_collect_policy ADD COLUMN history_keep INTEGER DEFAULT 30",
|
"ALTER TABLE lldp_collect_policy ADD COLUMN history_keep INTEGER DEFAULT 30",
|
||||||
"ALTER TABLE lldp_collect_policy ADD COLUMN interval_hours INTEGER DEFAULT 24",
|
"ALTER TABLE lldp_collect_policy ADD COLUMN interval_hours INTEGER DEFAULT 24",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN folder_id VARCHAR(64)",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN parent_view_id VARCHAR(64)",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN kind VARCHAR(32) DEFAULT 'custom'",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN role VARCHAR(32) DEFAULT 'core'",
|
||||||
|
"ALTER TABLE topo_view ADD COLUMN sort_order INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE topo_fabric_node ADD COLUMN role VARCHAR(32) DEFAULT ''",
|
||||||
|
"ALTER TABLE topo_fabric_node ADD COLUMN region_folder_id VARCHAR(64)",
|
||||||
|
"ALTER TABLE topo_fabric_node ADD COLUMN role_source VARCHAR(16) DEFAULT ''",
|
||||||
|
"ALTER TABLE topo_fabric_node ADD COLUMN region_source VARCHAR(16) DEFAULT ''",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS topo_folder (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
parent_id VARCHAR(64),
|
||||||
|
kind VARCHAR(32) DEFAULT 'region',
|
||||||
|
name VARCHAR(256) DEFAULT '',
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
is_system BOOLEAN DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS topo_classify_rule (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
scope VARCHAR(16) DEFAULT 'role',
|
||||||
|
name VARCHAR(256) DEFAULT '',
|
||||||
|
pattern VARCHAR(512) DEFAULT '',
|
||||||
|
match_field VARCHAR(32) DEFAULT 'name',
|
||||||
|
priority INTEGER DEFAULT 100,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
payload TEXT DEFAULT '{}',
|
||||||
|
remark VARCHAR(512) DEFAULT '',
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
for sql in alter_stmts:
|
for sql in alter_stmts:
|
||||||
|
|
@ -85,6 +157,13 @@ def ensure_topology_schema(conn: Connection) -> None:
|
||||||
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_edge_layer_seen ON topo_fabric_edge (layer, last_seen_at)",
|
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_edge_layer_seen ON topo_fabric_edge (layer, last_seen_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_edge_active ON topo_fabric_edge (layer) WHERE status = 'active'",
|
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_edge_active ON topo_fabric_edge (layer) WHERE status = 'active'",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_topo_view_node_view ON topo_view_node (view_id)",
|
"CREATE INDEX IF NOT EXISTS ix_topo_view_node_view ON topo_view_node (view_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_topo_view_folder ON topo_view (folder_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_topo_view_parent ON topo_view (parent_view_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_topo_view_kind ON topo_view (kind)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_topo_folder_parent ON topo_folder (parent_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_role ON topo_fabric_node (role)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_region ON topo_fabric_node (region_folder_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_topo_classify_rule_scope ON topo_classify_rule (scope, enabled, priority)",
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_topo_fabric_node_managed_nn ON topo_fabric_node (managed_ne_id) WHERE managed_ne_id IS NOT NULL",
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_topo_fabric_node_managed_nn ON topo_fabric_node (managed_ne_id) WHERE managed_ne_id IS NOT NULL",
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_topo_fabric_node_ume_nn ON topo_fabric_node (ume_ne_id) WHERE ume_ne_id IS NOT NULL",
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_topo_fabric_node_ume_nn ON topo_fabric_node (ume_ne_id) WHERE ume_ne_id IS NOT NULL",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_topo_discover_job_trigger ON topo_discover_job (trigger_mode)",
|
"CREATE INDEX IF NOT EXISTS ix_topo_discover_job_trigger ON topo_discover_job (trigger_mode)",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Topology HTTP routes — fabric + views (final model)."""
|
"""Topology HTTP routes — fabric + folder tree + leaf views."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -8,22 +8,49 @@ from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
|
from .topology_classify import (
|
||||||
|
apply_classify,
|
||||||
|
bulk_tag_fabric_nodes,
|
||||||
|
create_rule,
|
||||||
|
delete_rule,
|
||||||
|
generate_slices,
|
||||||
|
list_rules,
|
||||||
|
list_unmatched,
|
||||||
|
match_fabric_nodes,
|
||||||
|
patch_fabric_node_tags,
|
||||||
|
preview_classify,
|
||||||
|
search_fabric_nodes_with_views,
|
||||||
|
update_rule,
|
||||||
|
)
|
||||||
from .topology_schemas import (
|
from .topology_schemas import (
|
||||||
|
ClassifyRuleCreate,
|
||||||
|
ClassifyRuleUpdate,
|
||||||
FabricDiscoverRequest,
|
FabricDiscoverRequest,
|
||||||
FabricManualEdgeIn,
|
FabricManualEdgeIn,
|
||||||
|
FabricNodesBulkTagRequest,
|
||||||
|
FabricNodesMatchRequest,
|
||||||
|
FabricNodeTagPatch,
|
||||||
|
SliceGenerateRequest,
|
||||||
|
TopologyFolderCreate,
|
||||||
|
TopologyFolderUpdate,
|
||||||
TopologyViewCreate,
|
TopologyViewCreate,
|
||||||
TopologyViewUpdate,
|
TopologyViewUpdate,
|
||||||
ViewEdgeStylePatch,
|
ViewEdgeStylePatch,
|
||||||
ViewNodesAdd,
|
ViewNodesAdd,
|
||||||
|
ViewPopulateRequest,
|
||||||
ViewPositionsPatch,
|
ViewPositionsPatch,
|
||||||
)
|
)
|
||||||
from .topology_service import (
|
from .topology_service import (
|
||||||
add_nodes_to_view,
|
add_nodes_to_view,
|
||||||
|
bootstrap_topology_tree,
|
||||||
|
create_folder,
|
||||||
create_view,
|
create_view,
|
||||||
|
delete_folder,
|
||||||
delete_view,
|
delete_view,
|
||||||
get_discover_job,
|
get_discover_job,
|
||||||
get_fabric_neighborhood,
|
get_fabric_neighborhood,
|
||||||
get_fabric_summary,
|
get_fabric_summary,
|
||||||
|
get_topology_tree,
|
||||||
get_view_graph,
|
get_view_graph,
|
||||||
list_fabric_edges,
|
list_fabric_edges,
|
||||||
list_fabric_nodes,
|
list_fabric_nodes,
|
||||||
|
|
@ -31,10 +58,12 @@ from .topology_service import (
|
||||||
merge_duplicate_fabric_nodes,
|
merge_duplicate_fabric_nodes,
|
||||||
patch_view_edge_style,
|
patch_view_edge_style,
|
||||||
patch_view_positions,
|
patch_view_positions,
|
||||||
|
populate_view,
|
||||||
project_fabric_neighbors_to_view,
|
project_fabric_neighbors_to_view,
|
||||||
refresh_fabric_stats,
|
refresh_fabric_stats,
|
||||||
remove_view_nodes,
|
remove_view_nodes,
|
||||||
start_discover_job,
|
start_discover_job,
|
||||||
|
update_folder,
|
||||||
update_view,
|
update_view,
|
||||||
upsert_fabric_edge,
|
upsert_fabric_edge,
|
||||||
)
|
)
|
||||||
|
|
@ -53,11 +82,27 @@ def api_fabric_summary(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
@router.get("/fabric/nodes")
|
@router.get("/fabric/nodes")
|
||||||
def api_fabric_nodes(
|
def api_fabric_nodes(
|
||||||
keyword: str = "",
|
keyword: str = "",
|
||||||
|
role: str = "",
|
||||||
|
region_folder_id: str = "",
|
||||||
|
unmatched: str = Query(default="", description="any | role | region"),
|
||||||
|
link_status: str = Query(
|
||||||
|
default="",
|
||||||
|
description="linked | orphaned | managed | ume | both",
|
||||||
|
),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = Query(default=100, ge=1, le=2000),
|
page_size: int = Query(default=100, ge=1, le=2000),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return list_fabric_nodes(db, keyword=keyword, page=page, page_size=page_size)
|
return list_fabric_nodes(
|
||||||
|
db,
|
||||||
|
keyword=keyword,
|
||||||
|
role=role,
|
||||||
|
region_folder_id=region_folder_id,
|
||||||
|
unmatched=unmatched,
|
||||||
|
link_status=link_status,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fabric/edges")
|
@router.get("/fabric/edges")
|
||||||
|
|
@ -108,15 +153,19 @@ def api_fabric_manual_edge(
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
refresh_fabric_stats(db)
|
refresh_fabric_stats(db)
|
||||||
return {"ok": True, "action": action, "edge": {
|
return {
|
||||||
"id": edge.id,
|
"ok": True,
|
||||||
"a_node_id": edge.a_node_id,
|
"action": action,
|
||||||
"b_node_id": edge.b_node_id,
|
"edge": {
|
||||||
"a_port": edge.a_port,
|
"id": edge.id,
|
||||||
"b_port": edge.b_port,
|
"a_node_id": edge.a_node_id,
|
||||||
"source": edge.source,
|
"b_node_id": edge.b_node_id,
|
||||||
"status": edge.status,
|
"a_port": edge.a_port,
|
||||||
}}
|
"b_port": edge.b_port,
|
||||||
|
"source": edge.source,
|
||||||
|
"status": edge.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/fabric/discover")
|
@router.post("/fabric/discover")
|
||||||
|
|
@ -130,8 +179,22 @@ def api_fabric_discover(
|
||||||
@router.post("/fabric/cleanup-duplicates")
|
@router.post("/fabric/cleanup-duplicates")
|
||||||
def api_fabric_cleanup_duplicates(db: Session = Depends(get_db)) -> dict[str, Any]:
|
def api_fabric_cleanup_duplicates(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
"""Merge duplicate fabric nodes (same managed/ume/name/ip) and retarget edges."""
|
"""Merge duplicate fabric nodes (same managed/ume/name/ip) and retarget edges."""
|
||||||
|
from .topology_inventory_lifecycle import reconcile_dangling_fabric_links
|
||||||
|
|
||||||
|
link_stats = reconcile_dangling_fabric_links(db)
|
||||||
result = merge_duplicate_fabric_nodes(db)
|
result = merge_duplicate_fabric_nodes(db)
|
||||||
return {"ok": True, **result}
|
db.commit()
|
||||||
|
return {"ok": True, **result, "reconcile_links": link_stats}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fabric/reconcile-links")
|
||||||
|
def api_fabric_reconcile_links(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
"""Detach fabric refs whose managed/UME inventory rows no longer exist."""
|
||||||
|
from .topology_inventory_lifecycle import reconcile_dangling_fabric_links
|
||||||
|
|
||||||
|
stats = reconcile_dangling_fabric_links(db)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, **stats}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fabric/discover/{job_id}")
|
@router.get("/fabric/discover/{job_id}")
|
||||||
|
|
@ -139,7 +202,41 @@ def api_fabric_discover_job(job_id: str, db: Session = Depends(get_db)) -> dict[
|
||||||
return get_discover_job(db, job_id).model_dump()
|
return get_discover_job(db, job_id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
# --- Views ------------------------------------------------------------------
|
# --- Tree / folders ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tree")
|
||||||
|
def api_topology_tree(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
return get_topology_tree(db).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/folders")
|
||||||
|
def api_create_folder(body: TopologyFolderCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
return create_folder(db, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/folders/{folder_id}")
|
||||||
|
def api_patch_folder(
|
||||||
|
folder_id: str, body: TopologyFolderUpdate, db: Session = Depends(get_db)
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return update_folder(db, folder_id, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/folders/{folder_id}")
|
||||||
|
def api_delete_folder(
|
||||||
|
folder_id: str,
|
||||||
|
force: bool = Query(default=False),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return delete_folder(db, folder_id, force=force)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tree/bootstrap")
|
||||||
|
def api_bootstrap_tree(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
return {"ok": True, **bootstrap_topology_tree(db)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Views (leaf canvases) --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/views")
|
@router.get("/views")
|
||||||
|
|
@ -165,8 +262,12 @@ def api_patch_view(
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/views/{view_id}")
|
@router.delete("/views/{view_id}")
|
||||||
def api_delete_view(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def api_delete_view(
|
||||||
return delete_view(db, view_id)
|
view_id: str,
|
||||||
|
force: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return delete_view(db, view_id, force=force)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/views/{view_id}/positions")
|
@router.patch("/views/{view_id}/positions")
|
||||||
|
|
@ -188,6 +289,15 @@ def api_project_neighbors(view_id: str, db: Session = Depends(get_db)) -> dict[s
|
||||||
return project_fabric_neighbors_to_view(db, view_id).model_dump()
|
return project_fabric_neighbors_to_view(db, view_id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/views/{view_id}/populate")
|
||||||
|
def api_populate_view(
|
||||||
|
view_id: str,
|
||||||
|
body: ViewPopulateRequest | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return populate_view(db, view_id, body or ViewPopulateRequest()).model_dump()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/views/{view_id}/nodes/remove")
|
@router.post("/views/{view_id}/nodes/remove")
|
||||||
def api_remove_nodes(
|
def api_remove_nodes(
|
||||||
view_id: str,
|
view_id: str,
|
||||||
|
|
@ -203,3 +313,107 @@ def api_edge_style(
|
||||||
view_id: str, body: ViewEdgeStylePatch, db: Session = Depends(get_db)
|
view_id: str, body: ViewEdgeStylePatch, db: Session = Depends(get_db)
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return patch_view_edge_style(db, view_id, body).model_dump()
|
return patch_view_edge_style(db, view_id, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Classify rules / slices / search ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/classify/rules")
|
||||||
|
def api_list_classify_rules(
|
||||||
|
scope: str = "",
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
items = list_rules(db, scope=scope)
|
||||||
|
return {"items": [x.model_dump() for x in items]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/classify/rules")
|
||||||
|
def api_create_classify_rule(
|
||||||
|
body: ClassifyRuleCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return create_rule(db, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/classify/rules/{rule_id}")
|
||||||
|
def api_patch_classify_rule(
|
||||||
|
rule_id: str,
|
||||||
|
body: ClassifyRuleUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return update_rule(db, rule_id, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/classify/rules/{rule_id}")
|
||||||
|
def api_delete_classify_rule(rule_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
return delete_rule(db, rule_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/classify/preview")
|
||||||
|
def api_classify_preview(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
return preview_classify(db).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/classify/apply")
|
||||||
|
def api_classify_apply(
|
||||||
|
skip_manual: bool = Query(default=True),
|
||||||
|
fill_empty_only: bool = Query(default=False),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return apply_classify(
|
||||||
|
db, skip_manual=skip_manual, fill_empty_only=fill_empty_only
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/classify/unmatched")
|
||||||
|
def api_classify_unmatched(
|
||||||
|
kind: str = Query(default="any", description="any | role | region"),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=50, ge=1, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return list_unmatched(db, kind=kind, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/fabric/nodes/{fabric_node_id}/tags")
|
||||||
|
def api_patch_fabric_node_tags(
|
||||||
|
fabric_node_id: str,
|
||||||
|
body: FabricNodeTagPatch,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return patch_fabric_node_tags(db, fabric_node_id, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fabric/nodes/match")
|
||||||
|
def api_fabric_nodes_match(
|
||||||
|
body: FabricNodesMatchRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Regex match over inventory (ephemeral; not stored as rules)."""
|
||||||
|
return match_fabric_nodes(db, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fabric/nodes/tags/bulk")
|
||||||
|
def api_fabric_nodes_bulk_tag(
|
||||||
|
body: FabricNodesBulkTagRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return bulk_tag_fabric_nodes(db, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/slices/generate")
|
||||||
|
def api_generate_slices(
|
||||||
|
body: SliceGenerateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return generate_slices(db, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fabric/nodes/search")
|
||||||
|
def api_fabric_nodes_search(
|
||||||
|
q: str = "",
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return search_fabric_nodes_with_views(db, keyword=q, page=page, page_size=page_size)
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,17 @@ class FabricNodeOut(BaseModel):
|
||||||
ip: str = ""
|
ip: str = ""
|
||||||
vendor: str = ""
|
vendor: str = ""
|
||||||
device_type: str = ""
|
device_type: str = ""
|
||||||
|
role: str = ""
|
||||||
|
region_folder_id: str | None = None
|
||||||
|
role_source: str = ""
|
||||||
|
region_source: str = ""
|
||||||
attrs: dict[str, Any] = Field(default_factory=dict)
|
attrs: dict[str, Any] = Field(default_factory=dict)
|
||||||
last_seen_at: datetime | None = None
|
last_seen_at: datetime | None = None
|
||||||
|
# Inventory link diagnostics (list/enrich); empty when not enriched.
|
||||||
|
link_status: str = "" # managed | ume | both | orphaned
|
||||||
|
managed_alive: bool = False
|
||||||
|
ume_alive: bool = False
|
||||||
|
managed_source: str = "" # manual | ume_sync | lldp | webcrt | …
|
||||||
|
|
||||||
|
|
||||||
class FabricEdgeOut(BaseModel):
|
class FabricEdgeOut(BaseModel):
|
||||||
|
|
@ -125,14 +134,45 @@ class FabricDiscoverJobOut(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Views
|
# Folders (tree grouping) + Views (leaf canvases)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TopologyFolderCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=256)
|
||||||
|
kind: str = Field(default="region", description="region only from API")
|
||||||
|
parent_id: str | None = None
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TopologyFolderUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=256)
|
||||||
|
parent_id: str | None = None
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TopologyFolderOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
parent_id: str = ""
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
sort_order: int = 0
|
||||||
|
is_system: bool = False
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class TopologyViewCreate(BaseModel):
|
class TopologyViewCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=256)
|
name: str = Field(min_length=1, max_length=256)
|
||||||
remark: str = Field(default="", max_length=1024)
|
remark: str = Field(default="", max_length=1024)
|
||||||
filter: dict[str, Any] = Field(default_factory=dict)
|
filter: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
folder_id: str = Field(..., min_length=1, description="Site/region folder id (required)")
|
||||||
|
kind: str = Field(default="custom", description="physical | custom")
|
||||||
|
role: str = Field(
|
||||||
|
default="core",
|
||||||
|
description="Optional filter preset label (legacy); not a tree level",
|
||||||
|
)
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class TopologyViewUpdate(BaseModel):
|
class TopologyViewUpdate(BaseModel):
|
||||||
|
|
@ -140,12 +180,20 @@ class TopologyViewUpdate(BaseModel):
|
||||||
remark: str | None = Field(default=None, max_length=1024)
|
remark: str | None = Field(default=None, max_length=1024)
|
||||||
filter: dict[str, Any] | None = None
|
filter: dict[str, Any] | None = None
|
||||||
viewport: dict[str, Any] | None = None
|
viewport: dict[str, Any] | None = None
|
||||||
|
folder_id: str | None = None
|
||||||
|
kind: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class TopologyViewOut(BaseModel):
|
class TopologyViewOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
remark: str = ""
|
remark: str = ""
|
||||||
|
folder_id: str = ""
|
||||||
|
kind: str = "custom"
|
||||||
|
role: str = "core"
|
||||||
|
sort_order: int = 0
|
||||||
filter: dict[str, Any] = Field(default_factory=dict)
|
filter: dict[str, Any] = Field(default_factory=dict)
|
||||||
viewport: dict[str, Any] = Field(default_factory=dict)
|
viewport: dict[str, Any] = Field(default_factory=dict)
|
||||||
node_count: int = 0
|
node_count: int = 0
|
||||||
|
|
@ -153,6 +201,39 @@ class TopologyViewOut(BaseModel):
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TopologyTreeViewOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
kind: str = "custom"
|
||||||
|
role: str = "core"
|
||||||
|
sort_order: int = 0
|
||||||
|
node_count: int = 0
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TopologyTreeFolderOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
parent_id: str = ""
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
sort_order: int = 0
|
||||||
|
is_system: bool = False
|
||||||
|
views: list[TopologyTreeViewOut] = Field(default_factory=list)
|
||||||
|
children: list["TopologyTreeFolderOut"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TopologyTreeOut(BaseModel):
|
||||||
|
root: TopologyTreeFolderOut | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ViewPopulateRequest(BaseModel):
|
||||||
|
"""Fill a leaf view from membership rules (optional dry-run)."""
|
||||||
|
|
||||||
|
dry_run: bool = False
|
||||||
|
membership: dict[str, Any] | None = None
|
||||||
|
freeze_after: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ViewNodeIn(BaseModel):
|
class ViewNodeIn(BaseModel):
|
||||||
fabric_node_id: str = Field(min_length=1, max_length=64)
|
fabric_node_id: str = Field(min_length=1, max_length=64)
|
||||||
x: float = 0.0
|
x: float = 0.0
|
||||||
|
|
@ -197,6 +278,19 @@ class TopologyViewGraphOut(BaseModel):
|
||||||
edges: list[ViewEdgeOut]
|
edges: list[ViewEdgeOut]
|
||||||
truncated: bool = False
|
truncated: bool = False
|
||||||
truncate_reason: str = ""
|
truncate_reason: str = ""
|
||||||
|
outside_peers: list[dict[str, str]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewPopulateOut(BaseModel):
|
||||||
|
view_id: str
|
||||||
|
dry_run: bool = False
|
||||||
|
candidate_count: int = 0
|
||||||
|
would_add: int = 0
|
||||||
|
added: int = 0
|
||||||
|
max_nodes: int = 0
|
||||||
|
truncated: bool = False
|
||||||
|
outside_peers: list[dict[str, str]] = Field(default_factory=list)
|
||||||
|
graph: TopologyViewGraphOut | None = None
|
||||||
|
|
||||||
|
|
||||||
class ViewPositionsPatch(BaseModel):
|
class ViewPositionsPatch(BaseModel):
|
||||||
|
|
@ -228,3 +322,130 @@ class FabricManualEdgeIn(BaseModel):
|
||||||
b_node_id: str = Field(min_length=1, max_length=64)
|
b_node_id: str = Field(min_length=1, max_length=64)
|
||||||
a_port: str = ""
|
a_port: str = ""
|
||||||
b_port: str = ""
|
b_port: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Classify rules + slices + search
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifyRuleOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
scope: str = "role"
|
||||||
|
name: str = ""
|
||||||
|
pattern: str = ""
|
||||||
|
match_field: str = "name"
|
||||||
|
priority: int = 100
|
||||||
|
enabled: bool = True
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
remark: str = ""
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifyRuleCreate(BaseModel):
|
||||||
|
scope: str = Field(default="role", description="role | region")
|
||||||
|
name: str = ""
|
||||||
|
pattern: str
|
||||||
|
match_field: str = "name"
|
||||||
|
priority: int = 100
|
||||||
|
enabled: bool = True
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
remark: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifyRuleUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
pattern: str | None = None
|
||||||
|
match_field: str | None = None
|
||||||
|
priority: int | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifyPreviewOut(BaseModel):
|
||||||
|
total_nodes: int = 0
|
||||||
|
role_matched: int = 0
|
||||||
|
role_unmatched: int = 0
|
||||||
|
role_conflicts: int = 0
|
||||||
|
region_matched: int = 0
|
||||||
|
region_unmatched: int = 0
|
||||||
|
region_conflicts: int = 0
|
||||||
|
role_samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
region_samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
unmatched_samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifyApplyOut(BaseModel):
|
||||||
|
role_updated: int = 0
|
||||||
|
region_updated: int = 0
|
||||||
|
skipped_manual: int = 0
|
||||||
|
total_nodes: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FabricNodeTagPatch(BaseModel):
|
||||||
|
role: str | None = None
|
||||||
|
region_folder_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FabricNodesMatchRequest(BaseModel):
|
||||||
|
"""Ephemeral regex match over fabric inventory (not persisted as rules)."""
|
||||||
|
|
||||||
|
pattern: str
|
||||||
|
match_field: str = "name"
|
||||||
|
sample_limit: int = Field(default=50, ge=1, le=200)
|
||||||
|
|
||||||
|
|
||||||
|
class FabricNodesMatchOut(BaseModel):
|
||||||
|
pattern: str
|
||||||
|
match_field: str = "name"
|
||||||
|
total_matched: int = 0
|
||||||
|
samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
fabric_node_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FabricNodesBulkTagRequest(BaseModel):
|
||||||
|
"""Assign role/region to explicit ids or to an ephemeral regex match."""
|
||||||
|
|
||||||
|
fabric_node_ids: list[str] = Field(default_factory=list)
|
||||||
|
pattern: str = ""
|
||||||
|
match_field: str = "name"
|
||||||
|
role: str | None = None
|
||||||
|
region_folder_id: str | None = None
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FabricNodesBulkTagOut(BaseModel):
|
||||||
|
dry_run: bool = False
|
||||||
|
matched: int = 0
|
||||||
|
updated: int = 0
|
||||||
|
role: str | None = None
|
||||||
|
region_folder_id: str | None = None
|
||||||
|
samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SliceGenerateRequest(BaseModel):
|
||||||
|
folder_id: str
|
||||||
|
template: str = Field(description="core_only | core_agg | agg_access")
|
||||||
|
dry_run: bool = True
|
||||||
|
max_nodes: int = Field(default=300, ge=1, le=2000)
|
||||||
|
seed_physical_cores: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SliceMapPlan(BaseModel):
|
||||||
|
name: str
|
||||||
|
role: str = "core"
|
||||||
|
seed_fabric_node_ids: list[str] = Field(default_factory=list)
|
||||||
|
member_fabric_node_ids: list[str] = Field(default_factory=list)
|
||||||
|
node_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SliceGenerateOut(BaseModel):
|
||||||
|
folder_id: str
|
||||||
|
template: str
|
||||||
|
dry_run: bool = True
|
||||||
|
maps: list[SliceMapPlan] = Field(default_factory=list)
|
||||||
|
map_count: int = 0
|
||||||
|
overlap_node_count: int = 0
|
||||||
|
created_view_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import and_, func, or_
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -26,11 +26,21 @@ from .models import (
|
||||||
TopoFabricEdge,
|
TopoFabricEdge,
|
||||||
TopoFabricNode,
|
TopoFabricNode,
|
||||||
TopoFabricStats,
|
TopoFabricStats,
|
||||||
|
TopoFolder,
|
||||||
TopoView,
|
TopoView,
|
||||||
TopoViewEdgeStyle,
|
TopoViewEdgeStyle,
|
||||||
TopoViewNode,
|
TopoViewNode,
|
||||||
UmeInventoryNE,
|
UmeInventoryNE,
|
||||||
)
|
)
|
||||||
|
from .topology_membership import (
|
||||||
|
VIEW_KIND_CUSTOM,
|
||||||
|
VIEW_KIND_PHYSICAL,
|
||||||
|
has_hard_scope,
|
||||||
|
merge_filter_with_membership,
|
||||||
|
normalize_view_kind,
|
||||||
|
normalize_view_role,
|
||||||
|
parse_membership,
|
||||||
|
)
|
||||||
from .ne_exec import execute_managed_ne_commands
|
from .ne_exec import execute_managed_ne_commands
|
||||||
from .topology_lldp import (
|
from .topology_lldp import (
|
||||||
NeighborHit,
|
NeighborHit,
|
||||||
|
|
@ -48,6 +58,12 @@ from .topology_schemas import (
|
||||||
FabricNeighborhoodOut,
|
FabricNeighborhoodOut,
|
||||||
FabricNodeOut,
|
FabricNodeOut,
|
||||||
FabricSummaryOut,
|
FabricSummaryOut,
|
||||||
|
TopologyFolderCreate,
|
||||||
|
TopologyFolderOut,
|
||||||
|
TopologyFolderUpdate,
|
||||||
|
TopologyTreeFolderOut,
|
||||||
|
TopologyTreeOut,
|
||||||
|
TopologyTreeViewOut,
|
||||||
TopologyViewCreate,
|
TopologyViewCreate,
|
||||||
TopologyViewGraphOut,
|
TopologyViewGraphOut,
|
||||||
TopologyViewOut,
|
TopologyViewOut,
|
||||||
|
|
@ -57,9 +73,16 @@ from .topology_schemas import (
|
||||||
ViewNodeIn,
|
ViewNodeIn,
|
||||||
ViewNodeOut,
|
ViewNodeOut,
|
||||||
ViewNodesAdd,
|
ViewNodesAdd,
|
||||||
|
ViewPopulateOut,
|
||||||
|
ViewPopulateRequest,
|
||||||
ViewPositionsPatch,
|
ViewPositionsPatch,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ROOT_FOLDER_NAME = "Network"
|
||||||
|
PHYSICAL_VIEW_NAME = "Physical topology"
|
||||||
|
# Legacy system region name (no longer auto-created; stripped on bootstrap when empty).
|
||||||
|
_LEGACY_UNASSIGNED_NAME = "Unassigned"
|
||||||
|
|
||||||
PAGE_DEFAULT = 100
|
PAGE_DEFAULT = 100
|
||||||
PAGE_MAX = 2000
|
PAGE_MAX = 2000
|
||||||
VIEW_GRAPH_NODE_HARD_CAP = 2000
|
VIEW_GRAPH_NODE_HARD_CAP = 2000
|
||||||
|
|
@ -154,6 +177,10 @@ def _empty_to_none(s: str | None) -> str | None:
|
||||||
|
|
||||||
def _node_out(n: TopoFabricNode) -> FabricNodeOut:
|
def _node_out(n: TopoFabricNode) -> FabricNodeOut:
|
||||||
return FabricNodeOut(
|
return FabricNodeOut(
|
||||||
|
role=str(getattr(n, "role", "") or ""),
|
||||||
|
region_folder_id=str(getattr(n, "region_folder_id", None) or "") or None,
|
||||||
|
role_source=str(getattr(n, "role_source", "") or ""),
|
||||||
|
region_source=str(getattr(n, "region_source", "") or ""),
|
||||||
id=n.id,
|
id=n.id,
|
||||||
managed_ne_id=n.managed_ne_id or "",
|
managed_ne_id=n.managed_ne_id or "",
|
||||||
ume_ne_id=n.ume_ne_id or "",
|
ume_ne_id=n.ume_ne_id or "",
|
||||||
|
|
@ -346,9 +373,15 @@ def list_fabric_nodes(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
keyword: str = "",
|
keyword: str = "",
|
||||||
|
role: str = "",
|
||||||
|
region_folder_id: str = "",
|
||||||
|
unmatched: str = "",
|
||||||
|
link_status: str = "",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = PAGE_DEFAULT,
|
page_size: int = PAGE_DEFAULT,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
from .topology_inventory_lifecycle import enrich_fabric_node_dicts
|
||||||
|
|
||||||
page = max(1, int(page or 1))
|
page = max(1, int(page or 1))
|
||||||
page_size = max(1, min(PAGE_MAX, int(page_size or PAGE_DEFAULT)))
|
page_size = max(1, min(PAGE_MAX, int(page_size or PAGE_DEFAULT)))
|
||||||
q = db.query(TopoFabricNode)
|
q = db.query(TopoFabricNode)
|
||||||
|
|
@ -363,18 +396,69 @@ def list_fabric_nodes(
|
||||||
TopoFabricNode.ume_ne_id.ilike(like),
|
TopoFabricNode.ume_ne_id.ilike(like),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
role_v = str(role or "").strip().lower()
|
||||||
|
if role_v:
|
||||||
|
q = q.filter(TopoFabricNode.role == role_v)
|
||||||
|
region_v = str(region_folder_id or "").strip()
|
||||||
|
if region_v:
|
||||||
|
q = q.filter(TopoFabricNode.region_folder_id == region_v)
|
||||||
|
um = str(unmatched or "").strip().lower()
|
||||||
|
if um == "role":
|
||||||
|
q = q.filter(or_(TopoFabricNode.role == "", TopoFabricNode.role == "unknown"))
|
||||||
|
elif um == "region":
|
||||||
|
q = q.filter(
|
||||||
|
or_(TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id == "")
|
||||||
|
)
|
||||||
|
elif um == "any":
|
||||||
|
q = q.filter(
|
||||||
|
or_(
|
||||||
|
TopoFabricNode.role == "",
|
||||||
|
TopoFabricNode.role == "unknown",
|
||||||
|
TopoFabricNode.region_folder_id.is_(None),
|
||||||
|
TopoFabricNode.region_folder_id == "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ls = str(link_status or "").strip().lower()
|
||||||
|
if ls == "orphaned":
|
||||||
|
q = q.filter(
|
||||||
|
or_(TopoFabricNode.managed_ne_id.is_(None), TopoFabricNode.managed_ne_id == ""),
|
||||||
|
or_(TopoFabricNode.ume_ne_id.is_(None), TopoFabricNode.ume_ne_id == ""),
|
||||||
|
)
|
||||||
|
elif ls == "linked":
|
||||||
|
q = q.filter(
|
||||||
|
or_(
|
||||||
|
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
|
||||||
|
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif ls == "managed":
|
||||||
|
q = q.filter(
|
||||||
|
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
|
||||||
|
or_(TopoFabricNode.ume_ne_id.is_(None), TopoFabricNode.ume_ne_id == ""),
|
||||||
|
)
|
||||||
|
elif ls == "ume":
|
||||||
|
q = q.filter(
|
||||||
|
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
|
||||||
|
or_(TopoFabricNode.managed_ne_id.is_(None), TopoFabricNode.managed_ne_id == ""),
|
||||||
|
)
|
||||||
|
elif ls == "both":
|
||||||
|
q = q.filter(
|
||||||
|
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
|
||||||
|
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
|
||||||
|
)
|
||||||
total = int(q.count())
|
total = int(q.count())
|
||||||
rows = (
|
rows = (
|
||||||
q.order_by(TopoFabricNode.updated_at.desc())
|
q.order_by(TopoFabricNode.name.asc())
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
items = enrich_fabric_node_dicts(db, [_node_out(n).model_dump() for n in rows])
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"items": [_node_out(n).model_dump() for n in rows],
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -632,15 +716,32 @@ def _apply_missing_and_purge(
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Views
|
# Folders (tree) + Views (leaf canvases)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _folder_out(f: TopoFolder) -> TopologyFolderOut:
|
||||||
|
return TopologyFolderOut(
|
||||||
|
id=f.id,
|
||||||
|
parent_id=str(f.parent_id or ""),
|
||||||
|
kind=str(f.kind or "region"),
|
||||||
|
name=f.name or "",
|
||||||
|
sort_order=int(f.sort_order or 0),
|
||||||
|
is_system=bool(f.is_system),
|
||||||
|
created_at=f.created_at,
|
||||||
|
updated_at=f.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _view_out(v: TopoView, *, node_count: int = 0) -> TopologyViewOut:
|
def _view_out(v: TopoView, *, node_count: int = 0) -> TopologyViewOut:
|
||||||
return TopologyViewOut(
|
return TopologyViewOut(
|
||||||
id=v.id,
|
id=v.id,
|
||||||
name=v.name,
|
name=v.name,
|
||||||
remark=v.remark or "",
|
remark=v.remark or "",
|
||||||
|
folder_id=str(v.folder_id or ""),
|
||||||
|
kind=normalize_view_kind(getattr(v, "kind", None)),
|
||||||
|
role=normalize_view_role(v.role),
|
||||||
|
sort_order=int(v.sort_order or 0),
|
||||||
filter=dict(v.filter or {}),
|
filter=dict(v.filter or {}),
|
||||||
viewport=dict(v.viewport or {}),
|
viewport=dict(v.viewport or {}),
|
||||||
node_count=node_count,
|
node_count=node_count,
|
||||||
|
|
@ -657,7 +758,281 @@ def _get_view_or_404(db: Session, view_id: str) -> TopoView:
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _get_folder_or_404(db: Session, folder_id: str) -> TopoFolder:
|
||||||
|
fid = str(folder_id or "").strip()
|
||||||
|
row = db.get(TopoFolder, fid) if fid else None
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="topology_folder_not_found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_region_physical_view(db: Session, folder_id: str, *, commit: bool = True) -> TopoView:
|
||||||
|
"""Ensure a site has exactly one default physical topology map."""
|
||||||
|
fid = str(folder_id or "").strip()
|
||||||
|
folder = _get_folder_or_404(db, fid)
|
||||||
|
if str(folder.kind or "") == "root":
|
||||||
|
raise HTTPException(status_code=400, detail="view_must_hang_under_region")
|
||||||
|
existing = (
|
||||||
|
db.query(TopoView)
|
||||||
|
.filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL)
|
||||||
|
.order_by(TopoView.sort_order.asc(), TopoView.created_at.asc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
now = _utcnow()
|
||||||
|
role = "core"
|
||||||
|
row = TopoView(
|
||||||
|
id=uuid4().hex,
|
||||||
|
folder_id=folder.id,
|
||||||
|
parent_view_id=None,
|
||||||
|
kind=VIEW_KIND_PHYSICAL,
|
||||||
|
role=role,
|
||||||
|
name=PHYSICAL_VIEW_NAME,
|
||||||
|
remark="",
|
||||||
|
sort_order=0,
|
||||||
|
filter=merge_filter_with_membership({}, role=role, kind=VIEW_KIND_PHYSICAL),
|
||||||
|
viewport={},
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
else:
|
||||||
|
db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
||||||
|
"""Ensure hidden system root; flatten legacy nesting; ensure physical map per site."""
|
||||||
|
now = _utcnow()
|
||||||
|
root = (
|
||||||
|
db.query(TopoFolder)
|
||||||
|
.filter(TopoFolder.kind == "root")
|
||||||
|
.order_by(TopoFolder.created_at.asc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if root is None:
|
||||||
|
root = TopoFolder(
|
||||||
|
id=uuid4().hex,
|
||||||
|
parent_id=None,
|
||||||
|
kind="root",
|
||||||
|
name=ROOT_FOLDER_NAME,
|
||||||
|
sort_order=0,
|
||||||
|
is_system=True,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(root)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Drop legacy auto-created Unassigned region when empty; otherwise demote to normal region.
|
||||||
|
legacy = (
|
||||||
|
db.query(TopoFolder)
|
||||||
|
.filter(
|
||||||
|
TopoFolder.kind == "region",
|
||||||
|
TopoFolder.name == _LEGACY_UNASSIGNED_NAME,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for folder in legacy:
|
||||||
|
view_cnt = db.query(TopoView).filter(TopoView.folder_id == folder.id).count()
|
||||||
|
if view_cnt == 0:
|
||||||
|
db.delete(folder)
|
||||||
|
elif bool(folder.is_system):
|
||||||
|
folder.is_system = False
|
||||||
|
folder.updated_at = now
|
||||||
|
|
||||||
|
# Flatten nesting + normalize kind for all views.
|
||||||
|
for v in db.query(TopoView).all():
|
||||||
|
changed = False
|
||||||
|
if v.parent_view_id:
|
||||||
|
v.parent_view_id = None
|
||||||
|
changed = True
|
||||||
|
kind = normalize_view_kind(getattr(v, "kind", None))
|
||||||
|
if str(getattr(v, "kind", "") or "") != kind:
|
||||||
|
v.kind = kind
|
||||||
|
changed = True
|
||||||
|
if not str(v.role or "").strip():
|
||||||
|
v.role = "core"
|
||||||
|
changed = True
|
||||||
|
filt = dict(v.filter or {})
|
||||||
|
if "membership" not in filt:
|
||||||
|
v.filter = merge_filter_with_membership(
|
||||||
|
filt, role=normalize_view_role(v.role), kind=kind
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
v.updated_at = now
|
||||||
|
|
||||||
|
# Ensure every region has a physical map.
|
||||||
|
regions = db.query(TopoFolder).filter(TopoFolder.kind == "region").all()
|
||||||
|
for region in regions:
|
||||||
|
ensure_region_physical_view(db, region.id, commit=False)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"root_id": root.id}
|
||||||
|
|
||||||
|
|
||||||
|
def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
||||||
|
bootstrap_topology_tree(db)
|
||||||
|
name = str(body.name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name_required")
|
||||||
|
kind = str(body.kind or "region").strip().lower()
|
||||||
|
if kind != "region":
|
||||||
|
raise HTTPException(status_code=400, detail="folder_kind_must_be_region")
|
||||||
|
root = db.query(TopoFolder).filter(TopoFolder.kind == "root").first()
|
||||||
|
if root is None:
|
||||||
|
raise HTTPException(status_code=500, detail="topology_root_missing")
|
||||||
|
parent_id = str(body.parent_id or "").strip() or root.id
|
||||||
|
parent = _get_folder_or_404(db, parent_id)
|
||||||
|
if str(parent.kind or "") != "root":
|
||||||
|
raise HTTPException(status_code=400, detail="region_must_hang_under_root")
|
||||||
|
now = _utcnow()
|
||||||
|
row = TopoFolder(
|
||||||
|
id=uuid4().hex,
|
||||||
|
parent_id=root.id,
|
||||||
|
kind="region",
|
||||||
|
name=name[:256],
|
||||||
|
sort_order=int(body.sort_order or 0),
|
||||||
|
is_system=False,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
ensure_region_physical_view(db, row.id, commit=False)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return _folder_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> TopologyFolderOut:
|
||||||
|
row = _get_folder_or_404(db, folder_id)
|
||||||
|
if str(row.kind or "") == "root":
|
||||||
|
if body.parent_id is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="cannot_reparent_root")
|
||||||
|
if body.name is not None:
|
||||||
|
name = str(body.name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name_required")
|
||||||
|
if bool(row.is_system) and str(row.kind or "") == "root":
|
||||||
|
row.name = name[:256]
|
||||||
|
elif bool(row.is_system):
|
||||||
|
raise HTTPException(status_code=400, detail="cannot_rename_system_folder")
|
||||||
|
else:
|
||||||
|
row.name = name[:256]
|
||||||
|
if body.sort_order is not None:
|
||||||
|
row.sort_order = int(body.sort_order)
|
||||||
|
if body.parent_id is not None and str(row.kind or "") == "region":
|
||||||
|
parent = _get_folder_or_404(db, body.parent_id)
|
||||||
|
if str(parent.kind or "") != "root":
|
||||||
|
raise HTTPException(status_code=400, detail="region_must_hang_under_root")
|
||||||
|
row.parent_id = parent.id
|
||||||
|
row.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return _folder_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]:
|
||||||
|
"""Delete a region and cascade-delete its maps.
|
||||||
|
|
||||||
|
Every region has a default physical map, so folder delete must purge views
|
||||||
|
itself (cannot call ``delete_view``, which recreates physical).
|
||||||
|
``force`` is accepted for API compatibility; cascade always runs.
|
||||||
|
"""
|
||||||
|
row = _get_folder_or_404(db, folder_id)
|
||||||
|
if str(row.kind or "") == "root" or bool(row.is_system):
|
||||||
|
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
|
||||||
|
_ = force
|
||||||
|
views = db.query(TopoView).filter(TopoView.folder_id == row.id).all()
|
||||||
|
for v in views:
|
||||||
|
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == v.id).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.query(TopoViewNode).filter(TopoViewNode.view_id == v.id).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.delete(v)
|
||||||
|
db.flush()
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "folder_id": folder_id, "deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
def get_topology_tree(db: Session) -> TopologyTreeOut:
|
||||||
|
bootstrap_topology_tree(db)
|
||||||
|
folders = db.query(TopoFolder).order_by(TopoFolder.sort_order.asc(), TopoFolder.name.asc()).all()
|
||||||
|
views = db.query(TopoView).order_by(TopoView.sort_order.asc(), TopoView.name.asc()).all()
|
||||||
|
nc_map: dict[str, int] = {}
|
||||||
|
for vid, cnt in (
|
||||||
|
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
|
||||||
|
.group_by(TopoViewNode.view_id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
nc_map[str(vid)] = int(cnt or 0)
|
||||||
|
|
||||||
|
by_parent: dict[str, list[TopoFolder]] = {}
|
||||||
|
root: TopoFolder | None = None
|
||||||
|
for f in folders:
|
||||||
|
if str(f.kind or "") == "root":
|
||||||
|
root = f
|
||||||
|
continue
|
||||||
|
pid = str(f.parent_id or "")
|
||||||
|
by_parent.setdefault(pid, []).append(f)
|
||||||
|
|
||||||
|
views_by_folder: dict[str, list[TopoView]] = {}
|
||||||
|
for v in views:
|
||||||
|
views_by_folder.setdefault(str(v.folder_id or ""), []).append(v)
|
||||||
|
|
||||||
|
def _flat_views(folder_views: list[TopoView]) -> list[TopologyTreeViewOut]:
|
||||||
|
# physical first, then custom; stable by sort_order/name.
|
||||||
|
ordered = sorted(
|
||||||
|
folder_views,
|
||||||
|
key=lambda x: (
|
||||||
|
0 if normalize_view_kind(getattr(x, "kind", None)) == VIEW_KIND_PHYSICAL else 1,
|
||||||
|
int(x.sort_order or 0),
|
||||||
|
x.name or "",
|
||||||
|
x.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
TopologyTreeViewOut(
|
||||||
|
id=v.id,
|
||||||
|
name=v.name or "",
|
||||||
|
kind=normalize_view_kind(getattr(v, "kind", None)),
|
||||||
|
role=normalize_view_role(v.role),
|
||||||
|
sort_order=int(v.sort_order or 0),
|
||||||
|
node_count=nc_map.get(v.id, 0),
|
||||||
|
updated_at=v.updated_at,
|
||||||
|
)
|
||||||
|
for v in ordered
|
||||||
|
]
|
||||||
|
|
||||||
|
def _build(folder: TopoFolder) -> TopologyTreeFolderOut:
|
||||||
|
kids = [_build(c) for c in by_parent.get(folder.id, [])]
|
||||||
|
return TopologyTreeFolderOut(
|
||||||
|
id=folder.id,
|
||||||
|
parent_id=str(folder.parent_id or ""),
|
||||||
|
kind=str(folder.kind or "region"),
|
||||||
|
name=folder.name or "",
|
||||||
|
sort_order=int(folder.sort_order or 0),
|
||||||
|
is_system=bool(folder.is_system),
|
||||||
|
views=_flat_views(views_by_folder.get(folder.id, [])),
|
||||||
|
children=kids,
|
||||||
|
)
|
||||||
|
|
||||||
|
if root is None:
|
||||||
|
return TopologyTreeOut(root=None)
|
||||||
|
return TopologyTreeOut(root=_build(root))
|
||||||
|
|
||||||
|
|
||||||
def list_views(db: Session) -> dict[str, Any]:
|
def list_views(db: Session) -> dict[str, Any]:
|
||||||
|
bootstrap_topology_tree(db)
|
||||||
rows = db.query(TopoView).order_by(TopoView.updated_at.desc()).all()
|
rows = db.query(TopoView).order_by(TopoView.updated_at.desc()).all()
|
||||||
items = []
|
items = []
|
||||||
for v in rows:
|
for v in rows:
|
||||||
|
|
@ -670,12 +1045,34 @@ def create_view(db: Session, body: TopologyViewCreate) -> TopologyViewOut:
|
||||||
name = str(body.name or "").strip()
|
name = str(body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name_required")
|
raise HTTPException(status_code=400, detail="name_required")
|
||||||
|
role = normalize_view_role(body.role)
|
||||||
|
kind = normalize_view_kind(body.kind)
|
||||||
|
folder_id = str(body.folder_id or "").strip()
|
||||||
|
if not folder_id:
|
||||||
|
raise HTTPException(status_code=400, detail="folder_id_required")
|
||||||
|
folder = _get_folder_or_404(db, folder_id)
|
||||||
|
if str(folder.kind or "") == "root":
|
||||||
|
raise HTTPException(status_code=400, detail="view_must_hang_under_region")
|
||||||
|
if kind == VIEW_KIND_PHYSICAL:
|
||||||
|
existing = (
|
||||||
|
db.query(TopoView)
|
||||||
|
.filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="region_already_has_physical_view")
|
||||||
|
filt = merge_filter_with_membership(dict(body.filter or {}), role=role, kind=kind)
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
row = TopoView(
|
row = TopoView(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
|
folder_id=folder_id,
|
||||||
|
parent_view_id=None,
|
||||||
|
kind=kind,
|
||||||
|
role=role,
|
||||||
name=name[:256],
|
name=name[:256],
|
||||||
remark=str(body.remark or "")[:1024],
|
remark=str(body.remark or "")[:1024],
|
||||||
filter=dict(body.filter or {}),
|
sort_order=int(body.sort_order or 0),
|
||||||
|
filter=filt,
|
||||||
viewport={},
|
viewport={},
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
|
|
@ -695,8 +1092,38 @@ def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> Topology
|
||||||
row.name = name[:256]
|
row.name = name[:256]
|
||||||
if body.remark is not None:
|
if body.remark is not None:
|
||||||
row.remark = str(body.remark or "")[:1024]
|
row.remark = str(body.remark or "")[:1024]
|
||||||
|
if body.role is not None:
|
||||||
|
row.role = normalize_view_role(body.role)
|
||||||
|
if body.sort_order is not None:
|
||||||
|
row.sort_order = int(body.sort_order)
|
||||||
|
if body.folder_id is not None:
|
||||||
|
fid = str(body.folder_id or "").strip()
|
||||||
|
folder = _get_folder_or_404(db, fid)
|
||||||
|
if str(folder.kind or "") == "root":
|
||||||
|
raise HTTPException(status_code=400, detail="view_must_hang_under_region")
|
||||||
|
row.folder_id = folder.id
|
||||||
|
if body.kind is not None:
|
||||||
|
new_kind = normalize_view_kind(body.kind)
|
||||||
|
if new_kind == VIEW_KIND_PHYSICAL and normalize_view_kind(row.kind) != VIEW_KIND_PHYSICAL:
|
||||||
|
clash = (
|
||||||
|
db.query(TopoView)
|
||||||
|
.filter(
|
||||||
|
TopoView.folder_id == row.folder_id,
|
||||||
|
TopoView.kind == VIEW_KIND_PHYSICAL,
|
||||||
|
TopoView.id != row.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if clash is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="region_already_has_physical_view")
|
||||||
|
row.kind = new_kind
|
||||||
|
row.parent_view_id = None
|
||||||
if body.filter is not None:
|
if body.filter is not None:
|
||||||
row.filter = dict(body.filter or {})
|
row.filter = merge_filter_with_membership(
|
||||||
|
dict(body.filter or {}),
|
||||||
|
role=normalize_view_role(row.role),
|
||||||
|
kind=normalize_view_kind(row.kind),
|
||||||
|
)
|
||||||
if body.viewport is not None:
|
if body.viewport is not None:
|
||||||
row.viewport = dict(body.viewport or {})
|
row.viewport = dict(body.viewport or {})
|
||||||
row.updated_at = _utcnow()
|
row.updated_at = _utcnow()
|
||||||
|
|
@ -706,13 +1133,20 @@ def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> Topology
|
||||||
return _view_out(row, node_count=nc)
|
return _view_out(row, node_count=nc)
|
||||||
|
|
||||||
|
|
||||||
def delete_view(db: Session, view_id: str) -> dict[str, Any]:
|
def delete_view(db: Session, view_id: str, *, force: bool = False) -> dict[str, Any]:
|
||||||
row = _get_view_or_404(db, view_id)
|
row = _get_view_or_404(db, view_id)
|
||||||
|
folder_id = str(row.folder_id or "")
|
||||||
|
is_physical = normalize_view_kind(row.kind) == VIEW_KIND_PHYSICAL
|
||||||
|
if is_physical and not force:
|
||||||
|
raise HTTPException(status_code=400, detail="cannot_delete_physical_view")
|
||||||
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == row.id).delete(
|
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == row.id).delete(
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
db.query(TopoViewNode).filter(TopoViewNode.view_id == row.id).delete(synchronize_session=False)
|
db.query(TopoViewNode).filter(TopoViewNode.view_id == row.id).delete(synchronize_session=False)
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
if folder_id and is_physical:
|
||||||
|
ensure_region_physical_view(db, folder_id, commit=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "view_id": view_id, "deleted": True}
|
return {"ok": True, "view_id": view_id, "deleted": True}
|
||||||
|
|
||||||
|
|
@ -817,15 +1251,132 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
||||||
discovered_at=e.discovered_at,
|
discovered_at=e.discovered_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
outside = _outside_peers_for_view(db, view, member_ids=set(fids), layer=layer)
|
||||||
return TopologyViewGraphOut(
|
return TopologyViewGraphOut(
|
||||||
view=_view_out(view, node_count=len(nodes_out)),
|
view=_view_out(view, node_count=len(nodes_out)),
|
||||||
nodes=nodes_out,
|
nodes=nodes_out,
|
||||||
edges=edges_out,
|
edges=edges_out,
|
||||||
truncated=truncated,
|
truncated=truncated,
|
||||||
truncate_reason=reason,
|
truncate_reason=reason,
|
||||||
|
outside_peers=outside,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _membership_for_view(view: TopoView) -> dict[str, Any]:
|
||||||
|
return parse_membership(dict(view.filter or {}), role=normalize_view_role(view.role))
|
||||||
|
|
||||||
|
|
||||||
|
def _fabric_in_hard_scope(db: Session, fn: TopoFabricNode, mem: dict[str, Any]) -> bool:
|
||||||
|
"""If hard scope filters are set, node must match ALL set dimensions (AND)."""
|
||||||
|
if not has_hard_scope(mem):
|
||||||
|
return True
|
||||||
|
mid = str(fn.managed_ne_id or "").strip()
|
||||||
|
allowed_mids = set(mem.get("managed_ne_ids") or [])
|
||||||
|
if allowed_mids and mid not in allowed_mids:
|
||||||
|
return False
|
||||||
|
vendors = {str(x).lower() for x in (mem.get("vendors") or [])}
|
||||||
|
if vendors and str(fn.vendor or "").strip().lower() not in vendors:
|
||||||
|
return False
|
||||||
|
dtypes = {str(x).lower() for x in (mem.get("device_types") or [])}
|
||||||
|
if dtypes and str(fn.device_type or "").strip().lower() not in dtypes:
|
||||||
|
return False
|
||||||
|
keyword = str(mem.get("keyword") or "").strip().lower()
|
||||||
|
if keyword:
|
||||||
|
blob = f"{fn.name or ''} {fn.ip or ''}".lower()
|
||||||
|
if keyword not in blob:
|
||||||
|
return False
|
||||||
|
tags_any = {str(x).lower() for x in (mem.get("tags_any") or [])}
|
||||||
|
if tags_any:
|
||||||
|
ne = db.get(ManagedNE, mid) if mid else None
|
||||||
|
tag_blob = str(getattr(ne, "tags", "") or "").lower() if ne else ""
|
||||||
|
if not any(t in tag_blob for t in tags_any):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _outside_peers_for_view(
|
||||||
|
db: Session,
|
||||||
|
view: TopoView,
|
||||||
|
*,
|
||||||
|
member_ids: set[str],
|
||||||
|
layer: str,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
if not member_ids:
|
||||||
|
return []
|
||||||
|
edges = (
|
||||||
|
db.query(TopoFabricEdge)
|
||||||
|
.filter(
|
||||||
|
TopoFabricEdge.layer == layer,
|
||||||
|
or_(
|
||||||
|
TopoFabricEdge.a_node_id.in_(list(member_ids)),
|
||||||
|
TopoFabricEdge.b_node_id.in_(list(member_ids)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(5000)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
out: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for e in edges:
|
||||||
|
for peer_id, local_id in ((e.b_node_id, e.a_node_id), (e.a_node_id, e.b_node_id)):
|
||||||
|
if local_id not in member_ids or peer_id in member_ids:
|
||||||
|
continue
|
||||||
|
if peer_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(peer_id)
|
||||||
|
fn = db.get(TopoFabricNode, peer_id)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"fabric_node_id": peer_id,
|
||||||
|
"name": (fn.name if fn else "") or "",
|
||||||
|
"ip": (fn.ip if fn else "") or "",
|
||||||
|
"via_node_id": local_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(out) >= limit:
|
||||||
|
return out
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _place_fabric_ids_on_view(
|
||||||
|
db: Session,
|
||||||
|
view: TopoView,
|
||||||
|
fabric_ids: list[str],
|
||||||
|
*,
|
||||||
|
existing: set[str],
|
||||||
|
) -> int:
|
||||||
|
now = _utcnow()
|
||||||
|
added = 0
|
||||||
|
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||||
|
max_x = max((float(vn.x or 0) for vn in vnodes), default=40.0)
|
||||||
|
base_x = max_x + 200.0
|
||||||
|
cols = max(1, int(len(fabric_ids) ** 0.5) or 1)
|
||||||
|
for i, fid in enumerate(fabric_ids):
|
||||||
|
if fid in existing or db.get(TopoFabricNode, fid) is None:
|
||||||
|
continue
|
||||||
|
x = base_x + (i % cols) * 180.0
|
||||||
|
y = 40.0 + (i // cols) * 120.0
|
||||||
|
db.add(
|
||||||
|
TopoViewNode(
|
||||||
|
id=uuid4().hex,
|
||||||
|
view_id=view.id,
|
||||||
|
fabric_node_id=fid,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
label="",
|
||||||
|
locked=False,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing.add(fid)
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
view.updated_at = now
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
def patch_view_positions(
|
def patch_view_positions(
|
||||||
db: Session, view_id: str, body: ViewPositionsPatch
|
db: Session, view_id: str, body: ViewPositionsPatch
|
||||||
) -> TopologyViewGraphOut:
|
) -> TopologyViewGraphOut:
|
||||||
|
|
@ -878,11 +1429,15 @@ def patch_view_positions(
|
||||||
|
|
||||||
def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> TopologyViewGraphOut:
|
def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> TopologyViewGraphOut:
|
||||||
view = _get_view_or_404(db, view_id)
|
view = _get_view_or_404(db, view_id)
|
||||||
|
mem = _membership_for_view(view)
|
||||||
|
max_nodes = int(mem.get("max_nodes") or 300)
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
existing = {
|
existing = {
|
||||||
vn.fabric_node_id
|
vn.fabric_node_id
|
||||||
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||||
}
|
}
|
||||||
|
if len(existing) >= max_nodes:
|
||||||
|
raise HTTPException(status_code=400, detail="membership_max_nodes")
|
||||||
added_ids: list[str] = []
|
added_ids: list[str] = []
|
||||||
for mid in body.managed_ne_ids or []:
|
for mid in body.managed_ne_ids or []:
|
||||||
mid_s = str(mid or "").strip()
|
mid_s = str(mid or "").strip()
|
||||||
|
|
@ -919,6 +1474,11 @@ def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> Topology
|
||||||
continue
|
continue
|
||||||
added_ids.append(fid_s)
|
added_ids.append(fid_s)
|
||||||
existing.add(fid_s)
|
existing.add(fid_s)
|
||||||
|
# `existing` already includes ids in added_ids; cap new placements.
|
||||||
|
original_count = len(existing) - len(added_ids)
|
||||||
|
room = max(0, max_nodes - original_count)
|
||||||
|
if len(added_ids) > room:
|
||||||
|
added_ids = added_ids[:room]
|
||||||
cols = max(1, int(len(added_ids) ** 0.5) or 1)
|
cols = max(1, int(len(added_ids) ** 0.5) or 1)
|
||||||
for i, fid in enumerate(added_ids):
|
for i, fid in enumerate(added_ids):
|
||||||
x = (i % cols) * 180.0 + 40.0
|
x = (i % cols) * 180.0 + 40.0
|
||||||
|
|
@ -941,19 +1501,51 @@ def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> Topology
|
||||||
return get_view_graph(db, view.id)
|
return get_view_graph(db, view.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _neighbor_ids(
|
||||||
|
db: Session, *, seed_ids: set[str], layer: str, hops: int
|
||||||
|
) -> set[str]:
|
||||||
|
frontier = set(seed_ids)
|
||||||
|
found: set[str] = set()
|
||||||
|
for _ in range(max(0, hops)):
|
||||||
|
if not frontier:
|
||||||
|
break
|
||||||
|
rows = (
|
||||||
|
db.query(TopoFabricEdge)
|
||||||
|
.filter(
|
||||||
|
TopoFabricEdge.layer == layer,
|
||||||
|
or_(
|
||||||
|
TopoFabricEdge.a_node_id.in_(list(frontier)),
|
||||||
|
TopoFabricEdge.b_node_id.in_(list(frontier)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
nxt: set[str] = set()
|
||||||
|
for e in rows:
|
||||||
|
for a, b in ((e.a_node_id, e.b_node_id), (e.b_node_id, e.a_node_id)):
|
||||||
|
if a in frontier and b not in seed_ids and b not in found:
|
||||||
|
nxt.add(b)
|
||||||
|
found |= nxt
|
||||||
|
frontier = nxt
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewGraphOut:
|
def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewGraphOut:
|
||||||
"""Add fabric neighbors of current view nodes onto the view so edges can render."""
|
"""Add in-scope fabric neighbors onto the leaf view (bounded by membership)."""
|
||||||
# Collapse duplicate fabric nodes first (fixes R1/r1 + twin R2 after raced discovers).
|
|
||||||
merge_duplicate_fabric_nodes(db)
|
merge_duplicate_fabric_nodes(db)
|
||||||
view = _get_view_or_404(db, view_id)
|
view = _get_view_or_404(db, view_id)
|
||||||
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
mem = _membership_for_view(view)
|
||||||
|
if bool(mem.get("frozen")):
|
||||||
|
return get_view_graph(db, view.id)
|
||||||
|
|
||||||
# Drop view placements that still point at LLDP orphans (no inventory link).
|
max_nodes = int(mem.get("max_nodes") or 300)
|
||||||
orphan_vns = []
|
hops = int(mem.get("expand_hops") or 1)
|
||||||
for vn in vnodes:
|
filt = dict(view.filter or {})
|
||||||
fn = db.get(TopoFabricNode, vn.fabric_node_id)
|
layer = str(filt.get("layer") or "physical").strip() or "physical"
|
||||||
if fn is None or not _is_inventory_node(fn):
|
|
||||||
orphan_vns.append(vn)
|
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||||
|
# Drop placements pointing at missing fabric rows only (keep LLDP placeholders).
|
||||||
|
orphan_vns = [vn for vn in vnodes if db.get(TopoFabricNode, vn.fabric_node_id) is None]
|
||||||
if orphan_vns:
|
if orphan_vns:
|
||||||
for vn in orphan_vns:
|
for vn in orphan_vns:
|
||||||
db.delete(vn)
|
db.delete(vn)
|
||||||
|
|
@ -964,60 +1556,130 @@ def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewG
|
||||||
existing = {vn.fabric_node_id for vn in vnodes}
|
existing = {vn.fabric_node_id for vn in vnodes}
|
||||||
if not existing:
|
if not existing:
|
||||||
return get_view_graph(db, view.id)
|
return get_view_graph(db, view.id)
|
||||||
|
if len(existing) >= max_nodes:
|
||||||
|
g = get_view_graph(db, view.id)
|
||||||
|
g.truncated = True
|
||||||
|
g.truncate_reason = g.truncate_reason or "membership_cap"
|
||||||
|
return g
|
||||||
|
|
||||||
filt = dict(view.filter or {})
|
peer_ids = _neighbor_ids(db, seed_ids=existing, layer=layer, hops=hops)
|
||||||
layer = str(filt.get("layer") or "physical").strip() or "physical"
|
to_add: list[str] = []
|
||||||
peer_ids: set[str] = set()
|
for peer in sorted(peer_ids):
|
||||||
for fid in existing:
|
if peer in existing:
|
||||||
rows = (
|
continue
|
||||||
db.query(TopoFabricEdge)
|
fn = db.get(TopoFabricNode, peer)
|
||||||
.filter(
|
if fn is None or not _is_inventory_node(fn):
|
||||||
TopoFabricEdge.layer == layer,
|
continue
|
||||||
or_(TopoFabricEdge.a_node_id == fid, TopoFabricEdge.b_node_id == fid),
|
if _fabric_match_score(db, fn) < 2:
|
||||||
|
continue
|
||||||
|
if not _fabric_in_hard_scope(db, fn, mem):
|
||||||
|
continue
|
||||||
|
to_add.append(peer)
|
||||||
|
if len(existing) + len(to_add) >= max_nodes:
|
||||||
|
break
|
||||||
|
|
||||||
|
truncated = len(peer_ids) > len(to_add)
|
||||||
|
if to_add:
|
||||||
|
_place_fabric_ids_on_view(db, view, to_add, existing=existing)
|
||||||
|
db.commit()
|
||||||
|
g = get_view_graph(db, view.id)
|
||||||
|
if truncated:
|
||||||
|
g.truncated = True
|
||||||
|
g.truncate_reason = g.truncate_reason or "membership_cap"
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def populate_view(db: Session, view_id: str, body: ViewPopulateRequest) -> ViewPopulateOut:
|
||||||
|
"""Resolve membership candidates and optionally place them on the leaf view."""
|
||||||
|
view = _get_view_or_404(db, view_id)
|
||||||
|
role = normalize_view_role(view.role)
|
||||||
|
if body.membership is not None:
|
||||||
|
filt = merge_filter_with_membership(
|
||||||
|
dict(view.filter or {}), role=role, membership=parse_membership(
|
||||||
|
{"membership": body.membership}, role=role
|
||||||
)
|
)
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
for edge in rows:
|
if not body.dry_run:
|
||||||
peer = edge.b_node_id if edge.a_node_id == fid else edge.a_node_id
|
view.filter = filt
|
||||||
if not peer or peer in existing:
|
mem = parse_membership(dict(view.filter or {}), role=role)
|
||||||
|
max_nodes = int(mem.get("max_nodes") or 300)
|
||||||
|
hops = int(mem.get("expand_hops") or 1)
|
||||||
|
layer = str((view.filter or {}).get("layer") or "physical").strip() or "physical"
|
||||||
|
|
||||||
|
existing = {
|
||||||
|
vn.fabric_node_id
|
||||||
|
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||||
|
}
|
||||||
|
seeds = set(mem.get("seed_fabric_node_ids") or []) | set(existing)
|
||||||
|
|
||||||
|
# Seed from managed_ne_ids
|
||||||
|
for mid in mem.get("managed_ne_ids") or []:
|
||||||
|
ne = db.get(ManagedNE, mid)
|
||||||
|
if ne is None:
|
||||||
|
continue
|
||||||
|
fn = ensure_fabric_node_for_managed(db, ne)
|
||||||
|
seeds.add(fn.id)
|
||||||
|
|
||||||
|
# Hard-scope scan when filters present
|
||||||
|
candidates: set[str] = set(seeds)
|
||||||
|
if has_hard_scope(mem):
|
||||||
|
for fn in db.query(TopoFabricNode).all():
|
||||||
|
if not _is_inventory_node(fn):
|
||||||
continue
|
continue
|
||||||
|
if _fabric_in_hard_scope(db, fn, mem):
|
||||||
|
candidates.add(fn.id)
|
||||||
|
|
||||||
|
if hops > 0 and seeds:
|
||||||
|
for peer in _neighbor_ids(db, seed_ids=seeds, layer=layer, hops=hops):
|
||||||
fn = db.get(TopoFabricNode, peer)
|
fn = db.get(TopoFabricNode, peer)
|
||||||
# Project real inventory + LLDP placeholders; skip WebCRT twins / orphans.
|
|
||||||
if fn is None or not _is_inventory_node(fn):
|
if fn is None or not _is_inventory_node(fn):
|
||||||
continue
|
continue
|
||||||
|
if has_hard_scope(mem) and not _fabric_in_hard_scope(db, fn, mem):
|
||||||
|
continue
|
||||||
if _fabric_match_score(db, fn) < 2:
|
if _fabric_match_score(db, fn) < 2:
|
||||||
continue
|
continue
|
||||||
peer_ids.add(peer)
|
candidates.add(peer)
|
||||||
|
|
||||||
if not peer_ids:
|
ordered = sorted(candidates)
|
||||||
return get_view_graph(db, view.id)
|
truncated = len(ordered) > max_nodes
|
||||||
|
ordered = ordered[:max_nodes]
|
||||||
|
would_add = [fid for fid in ordered if fid not in existing]
|
||||||
|
|
||||||
now = _utcnow()
|
outside = _outside_peers_for_view(db, view, member_ids=set(ordered), layer=layer)
|
||||||
added = sorted(peer_ids)
|
if body.dry_run:
|
||||||
cols = max(1, int(len(added) ** 0.5) or 1)
|
return ViewPopulateOut(
|
||||||
max_x = max((float(vn.x or 0) for vn in vnodes), default=40.0)
|
view_id=view.id,
|
||||||
base_x = max_x + 200.0
|
dry_run=True,
|
||||||
for i, fid in enumerate(added):
|
candidate_count=len(candidates),
|
||||||
if db.get(TopoFabricNode, fid) is None:
|
would_add=len(would_add),
|
||||||
continue
|
added=0,
|
||||||
x = base_x + (i % cols) * 180.0
|
max_nodes=max_nodes,
|
||||||
y = 40.0 + (i // cols) * 120.0
|
truncated=truncated,
|
||||||
db.add(
|
outside_peers=outside,
|
||||||
TopoViewNode(
|
graph=None,
|
||||||
id=uuid4().hex,
|
|
||||||
view_id=view.id,
|
|
||||||
fabric_node_id=fid,
|
|
||||||
x=x,
|
|
||||||
y=y,
|
|
||||||
label="",
|
|
||||||
locked=False,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
view.updated_at = now
|
|
||||||
|
added = _place_fabric_ids_on_view(db, view, would_add, existing=existing)
|
||||||
|
if body.freeze_after:
|
||||||
|
mem["frozen"] = True
|
||||||
|
view.filter = merge_filter_with_membership(dict(view.filter or {}), role=role, membership=mem)
|
||||||
|
view.updated_at = _utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
return get_view_graph(db, view.id)
|
g = get_view_graph(db, view.id)
|
||||||
|
if truncated:
|
||||||
|
g.truncated = True
|
||||||
|
g.truncate_reason = g.truncate_reason or "membership_cap"
|
||||||
|
return ViewPopulateOut(
|
||||||
|
view_id=view.id,
|
||||||
|
dry_run=False,
|
||||||
|
candidate_count=len(candidates),
|
||||||
|
would_add=len(would_add),
|
||||||
|
added=added,
|
||||||
|
max_nodes=max_nodes,
|
||||||
|
truncated=truncated,
|
||||||
|
outside_peers=g.outside_peers,
|
||||||
|
graph=g,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def remove_view_nodes(db: Session, view_id: str, fabric_node_ids: list[str]) -> TopologyViewGraphOut:
|
def remove_view_nodes(db: Session, view_id: str, fabric_node_ids: list[str]) -> TopologyViewGraphOut:
|
||||||
|
|
|
||||||
|
|
@ -564,14 +564,29 @@ def sync_inventory_full(db: Session, client: UMEClient, *, trigger_mode: str = "
|
||||||
db.flush()
|
db.flush()
|
||||||
deleted_ne = 0
|
deleted_ne = 0
|
||||||
if _snapshot_reconcile_ok(inv_meta):
|
if _snapshot_reconcile_ok(inv_meta):
|
||||||
|
from .topology_inventory_lifecycle import detach_fabric_from_ume
|
||||||
|
|
||||||
if seen_ne_ids:
|
if seen_ne_ids:
|
||||||
|
stale_ids = [
|
||||||
|
str(x[0])
|
||||||
|
for x in db.query(UmeInventoryNE.ne_id)
|
||||||
|
.filter(~UmeInventoryNE.ne_id.in_(list(seen_ne_ids)))
|
||||||
|
.all()
|
||||||
|
if str(x[0] or "").strip()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
stale_ids = [
|
||||||
|
str(x[0])
|
||||||
|
for x in db.query(UmeInventoryNE.ne_id).all()
|
||||||
|
if str(x[0] or "").strip()
|
||||||
|
]
|
||||||
|
if stale_ids:
|
||||||
|
detach_fabric_from_ume(db, stale_ids)
|
||||||
deleted_ne = int(
|
deleted_ne = int(
|
||||||
db.query(UmeInventoryNE)
|
db.query(UmeInventoryNE)
|
||||||
.filter(~UmeInventoryNE.ne_id.in_(list(seen_ne_ids)))
|
.filter(UmeInventoryNE.ne_id.in_(stale_ids))
|
||||||
.delete(synchronize_session=False)
|
.delete(synchronize_session=False)
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
deleted_ne = int(db.query(UmeInventoryNE).delete(synchronize_session=False))
|
|
||||||
|
|
||||||
job.details_json = json.dumps(
|
job.details_json = json.dumps(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,17 @@ from netx_api.models import (
|
||||||
TopoDiscoverJobItem,
|
TopoDiscoverJobItem,
|
||||||
TopoFabricEdge,
|
TopoFabricEdge,
|
||||||
TopoFabricNode,
|
TopoFabricNode,
|
||||||
|
TopoFolder,
|
||||||
TopoView,
|
TopoView,
|
||||||
TopoViewEdgeStyle,
|
TopoViewEdgeStyle,
|
||||||
TopoViewNode,
|
TopoViewNode,
|
||||||
)
|
)
|
||||||
from netx_api.topology_schemas import (
|
from netx_api.topology_schemas import (
|
||||||
FabricDiscoverRequest,
|
FabricDiscoverRequest,
|
||||||
|
TopologyFolderCreate,
|
||||||
TopologyViewCreate,
|
TopologyViewCreate,
|
||||||
ViewNodesAdd,
|
ViewNodesAdd,
|
||||||
|
ViewPopulateRequest,
|
||||||
ViewPositionsPatch,
|
ViewPositionsPatch,
|
||||||
ViewNodeIn,
|
ViewNodeIn,
|
||||||
)
|
)
|
||||||
|
|
@ -99,6 +102,7 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
TopoViewEdgeStyle,
|
TopoViewEdgeStyle,
|
||||||
TopoViewNode,
|
TopoViewNode,
|
||||||
TopoView,
|
TopoView,
|
||||||
|
TopoFolder,
|
||||||
TopoFabricEdge,
|
TopoFabricEdge,
|
||||||
TopoFabricNode,
|
TopoFabricNode,
|
||||||
TopoDiscoverJobItem,
|
TopoDiscoverJobItem,
|
||||||
|
|
@ -110,6 +114,11 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.db.close()
|
self.db.close()
|
||||||
|
|
||||||
|
def _region(self, name: str = "Test-Region") -> str:
|
||||||
|
return svc.create_folder(
|
||||||
|
self.db, TopologyFolderCreate(name=name, kind="region")
|
||||||
|
).id
|
||||||
|
|
||||||
def test_view_crud_and_positions(self) -> None:
|
def test_view_crud_and_positions(self) -> None:
|
||||||
suffix = uuid4().hex[:8]
|
suffix = uuid4().hex[:8]
|
||||||
ne = ManagedNE(
|
ne = ManagedNE(
|
||||||
|
|
@ -122,7 +131,10 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
self.db.add(ne)
|
self.db.add(ne)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}"))
|
view = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"R-{suffix}")),
|
||||||
|
)
|
||||||
graph = svc.add_nodes_to_view(
|
graph = svc.add_nodes_to_view(
|
||||||
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id])
|
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id])
|
||||||
)
|
)
|
||||||
|
|
@ -143,6 +155,307 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
self.db.delete(ne)
|
self.db.delete(ne)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
|
def test_topology_tree_root_region_and_leaf(self) -> None:
|
||||||
|
tree = svc.get_topology_tree(self.db)
|
||||||
|
self.assertIsNotNone(tree.root)
|
||||||
|
assert tree.root is not None
|
||||||
|
self.assertEqual(tree.root.kind, "root")
|
||||||
|
# No default Unassigned region — only user-created regions under hidden root.
|
||||||
|
self.assertEqual(tree.root.children, [])
|
||||||
|
|
||||||
|
region = svc.create_folder(
|
||||||
|
self.db, TopologyFolderCreate(name="East", kind="region")
|
||||||
|
)
|
||||||
|
# Creating a site auto-creates a physical map.
|
||||||
|
tree1 = svc.get_topology_tree(self.db)
|
||||||
|
assert tree1.root is not None
|
||||||
|
east0 = next(c for c in tree1.root.children if c.id == region.id)
|
||||||
|
self.assertTrue(any(v.kind == "physical" for v in east0.views))
|
||||||
|
|
||||||
|
view = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(
|
||||||
|
name="East-Custom",
|
||||||
|
folder_id=region.id,
|
||||||
|
kind="custom",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(view.folder_id, region.id)
|
||||||
|
self.assertEqual(view.kind, "custom")
|
||||||
|
self.assertIn("membership", view.filter)
|
||||||
|
|
||||||
|
tree2 = svc.get_topology_tree(self.db)
|
||||||
|
assert tree2.root is not None
|
||||||
|
east = next(c for c in tree2.root.children if c.id == region.id)
|
||||||
|
self.assertTrue(any(v.id == view.id for v in east.views))
|
||||||
|
# Flat siblings: physical + custom
|
||||||
|
self.assertGreaterEqual(len(east.views), 2)
|
||||||
|
self.assertTrue(all(not getattr(v, "children", None) for v in east.views))
|
||||||
|
|
||||||
|
def test_site_physical_and_custom_flat(self) -> None:
|
||||||
|
region = svc.create_folder(
|
||||||
|
self.db, TopologyFolderCreate(name="Site-R", kind="region")
|
||||||
|
)
|
||||||
|
tree = svc.get_topology_tree(self.db)
|
||||||
|
assert tree.root is not None
|
||||||
|
reg = next(c for c in tree.root.children if c.id == region.id)
|
||||||
|
physicals = [v for v in reg.views if v.kind == "physical"]
|
||||||
|
self.assertEqual(len(physicals), 1)
|
||||||
|
phys = physicals[0]
|
||||||
|
|
||||||
|
custom = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(
|
||||||
|
name="Custom-A",
|
||||||
|
folder_id=region.id,
|
||||||
|
kind="custom",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tree2 = svc.get_topology_tree(self.db)
|
||||||
|
assert tree2.root is not None
|
||||||
|
reg2 = next(c for c in tree2.root.children if c.id == region.id)
|
||||||
|
ids = {v.id for v in reg2.views}
|
||||||
|
self.assertIn(phys.id, ids)
|
||||||
|
self.assertIn(custom.id, ids)
|
||||||
|
# physical first
|
||||||
|
self.assertEqual(reg2.views[0].kind, "physical")
|
||||||
|
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
svc.delete_view(self.db, phys.id)
|
||||||
|
svc.delete_view(self.db, custom.id)
|
||||||
|
# force-delete physical recreates a fresh physical map
|
||||||
|
svc.delete_view(self.db, phys.id, force=True)
|
||||||
|
tree3 = svc.get_topology_tree(self.db)
|
||||||
|
assert tree3.root is not None
|
||||||
|
reg3 = next(c for c in tree3.root.children if c.id == region.id)
|
||||||
|
self.assertEqual(sum(1 for v in reg3.views if v.kind == "physical"), 1)
|
||||||
|
|
||||||
|
def test_delete_region_cascades_views(self) -> None:
|
||||||
|
region = svc.create_folder(
|
||||||
|
self.db, TopologyFolderCreate(name="Del-Region", kind="region")
|
||||||
|
)
|
||||||
|
custom = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(
|
||||||
|
name="Custom-Del",
|
||||||
|
folder_id=region.id,
|
||||||
|
kind="custom",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tree = svc.get_topology_tree(self.db)
|
||||||
|
assert tree.root is not None
|
||||||
|
reg = next(c for c in tree.root.children if c.id == region.id)
|
||||||
|
self.assertGreaterEqual(len(reg.views), 2)
|
||||||
|
|
||||||
|
out = svc.delete_folder(self.db, region.id)
|
||||||
|
self.assertTrue(out.get("deleted"))
|
||||||
|
tree2 = svc.get_topology_tree(self.db)
|
||||||
|
assert tree2.root is not None
|
||||||
|
self.assertFalse(any(c.id == region.id for c in tree2.root.children))
|
||||||
|
self.assertIsNone(self.db.get(TopoView, custom.id))
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.query(TopoView).filter(TopoView.folder_id == region.id).count(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_classify_role_region_and_slices(self) -> None:
|
||||||
|
from netx_api import topology_classify as clf
|
||||||
|
from netx_api.topology_schemas import (
|
||||||
|
ClassifyRuleCreate,
|
||||||
|
SliceGenerateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
suffix = uuid4().hex[:6]
|
||||||
|
region = svc.create_folder(
|
||||||
|
self.db, TopologyFolderCreate(name=f"East-{suffix}", kind="region")
|
||||||
|
)
|
||||||
|
nes = []
|
||||||
|
for name, ip in (
|
||||||
|
(f"CORE-A-{suffix}", "10.1.1.1"),
|
||||||
|
(f"CORE-B-{suffix}", "10.1.1.2"),
|
||||||
|
(f"AGG-A-{suffix}", "10.1.2.1"),
|
||||||
|
(f"ACC-A-{suffix}", "10.1.3.1"),
|
||||||
|
):
|
||||||
|
ne = ManagedNE(
|
||||||
|
id=f"clf-{suffix}-{name[:8]}",
|
||||||
|
name=name,
|
||||||
|
vendor="Cisco",
|
||||||
|
device_type="cisco_ios",
|
||||||
|
ip_address=ip,
|
||||||
|
)
|
||||||
|
self.db.add(ne)
|
||||||
|
nes.append(ne)
|
||||||
|
self.db.commit()
|
||||||
|
nodes = [svc.ensure_fabric_node_for_managed(self.db, ne) for ne in nes]
|
||||||
|
self.db.commit()
|
||||||
|
svc.upsert_fabric_edge(
|
||||||
|
self.db,
|
||||||
|
a_node_id=nodes[0].id,
|
||||||
|
b_node_id=nodes[2].id,
|
||||||
|
a_port="Gi0/0",
|
||||||
|
b_port="Gi0/1",
|
||||||
|
source="lldp",
|
||||||
|
)
|
||||||
|
svc.upsert_fabric_edge(
|
||||||
|
self.db,
|
||||||
|
a_node_id=nodes[2].id,
|
||||||
|
b_node_id=nodes[3].id,
|
||||||
|
a_port="Gi0/2",
|
||||||
|
b_port="Gi0/3",
|
||||||
|
source="lldp",
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
clf.create_rule(
|
||||||
|
self.db,
|
||||||
|
ClassifyRuleCreate(
|
||||||
|
scope="role",
|
||||||
|
name="core",
|
||||||
|
pattern=r"^CORE-",
|
||||||
|
priority=10,
|
||||||
|
payload={"role": "core"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
clf.create_rule(
|
||||||
|
self.db,
|
||||||
|
ClassifyRuleCreate(
|
||||||
|
scope="role",
|
||||||
|
name="agg",
|
||||||
|
pattern=r"^AGG-",
|
||||||
|
priority=20,
|
||||||
|
payload={"role": "aggregation"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
clf.create_rule(
|
||||||
|
self.db,
|
||||||
|
ClassifyRuleCreate(
|
||||||
|
scope="role",
|
||||||
|
name="acc",
|
||||||
|
pattern=r"^ACC-",
|
||||||
|
priority=30,
|
||||||
|
payload={"role": "access"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
clf.create_rule(
|
||||||
|
self.db,
|
||||||
|
ClassifyRuleCreate(
|
||||||
|
scope="region",
|
||||||
|
name="east",
|
||||||
|
pattern=rf"-{suffix}$",
|
||||||
|
priority=10,
|
||||||
|
payload={"folder_id": region.id},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
prev = clf.preview_classify(self.db)
|
||||||
|
self.assertGreaterEqual(prev.role_matched, 4)
|
||||||
|
applied = clf.apply_classify(self.db)
|
||||||
|
self.assertGreaterEqual(applied.role_updated, 4)
|
||||||
|
self.db.refresh(nodes[0])
|
||||||
|
self.assertEqual(nodes[0].role, "core")
|
||||||
|
self.assertEqual(nodes[0].region_folder_id, region.id)
|
||||||
|
|
||||||
|
dry = clf.generate_slices(
|
||||||
|
self.db,
|
||||||
|
SliceGenerateRequest(
|
||||||
|
folder_id=region.id,
|
||||||
|
template="core_agg",
|
||||||
|
dry_run=True,
|
||||||
|
max_nodes=50,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(dry.map_count, 1)
|
||||||
|
self.assertTrue(dry.dry_run)
|
||||||
|
real = clf.generate_slices(
|
||||||
|
self.db,
|
||||||
|
SliceGenerateRequest(
|
||||||
|
folder_id=region.id,
|
||||||
|
template="core_agg",
|
||||||
|
dry_run=False,
|
||||||
|
max_nodes=50,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(real.dry_run)
|
||||||
|
self.assertTrue(real.created_view_ids)
|
||||||
|
search = clf.search_fabric_nodes_with_views(self.db, keyword=f"CORE-A-{suffix}")
|
||||||
|
self.assertGreaterEqual(search["total"], 1)
|
||||||
|
self.assertTrue(search["items"][0].get("views"))
|
||||||
|
|
||||||
|
from netx_api.topology_schemas import FabricNodesBulkTagRequest, FabricNodesMatchRequest
|
||||||
|
|
||||||
|
matched = clf.match_fabric_nodes(
|
||||||
|
self.db,
|
||||||
|
FabricNodesMatchRequest(pattern=rf"^AGG-A-{suffix}$", match_field="name"),
|
||||||
|
)
|
||||||
|
self.assertEqual(matched.total_matched, 1)
|
||||||
|
bulk = clf.bulk_tag_fabric_nodes(
|
||||||
|
self.db,
|
||||||
|
FabricNodesBulkTagRequest(
|
||||||
|
pattern=rf"^ACC-A-{suffix}$",
|
||||||
|
role="access",
|
||||||
|
region_folder_id=region.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(bulk.updated, 1)
|
||||||
|
|
||||||
|
def test_project_neighbors_respects_max_nodes(self) -> None:
|
||||||
|
suffix = uuid4().hex[:8]
|
||||||
|
nes = []
|
||||||
|
for i in range(4):
|
||||||
|
ne = ManagedNE(
|
||||||
|
id=f"cap-{suffix}-{i}",
|
||||||
|
name=f"N{i}-{suffix}",
|
||||||
|
vendor="Cisco",
|
||||||
|
device_type="cisco_ios",
|
||||||
|
ip_address=f"10.9.{(int(suffix[:2], 16) % 200)}.{i + 1}",
|
||||||
|
)
|
||||||
|
self.db.add(ne)
|
||||||
|
nes.append(ne)
|
||||||
|
self.db.commit()
|
||||||
|
nodes = [svc.ensure_fabric_node_for_managed(self.db, ne) for ne in nes]
|
||||||
|
self.db.commit()
|
||||||
|
# Line: 0-1-2-3
|
||||||
|
for a, b in ((0, 1), (1, 2), (2, 3)):
|
||||||
|
svc.upsert_fabric_edge(
|
||||||
|
self.db,
|
||||||
|
a_node_id=nodes[a].id,
|
||||||
|
b_node_id=nodes[b].id,
|
||||||
|
a_port=f"Gi0/{a}",
|
||||||
|
b_port=f"Gi0/{b}",
|
||||||
|
source="lldp",
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
view = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(
|
||||||
|
name=f"Cap-{suffix}",
|
||||||
|
folder_id=self._region(f"CapR-{suffix}"),
|
||||||
|
role="core",
|
||||||
|
filter={
|
||||||
|
"membership": {
|
||||||
|
"expand_hops": 3,
|
||||||
|
"max_nodes": 2,
|
||||||
|
"frozen": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[nes[0].id]))
|
||||||
|
g = svc.project_fabric_neighbors_to_view(self.db, view.id)
|
||||||
|
self.assertLessEqual(len(g.nodes), 2)
|
||||||
|
self.assertTrue(g.truncated or len(g.nodes) == 2)
|
||||||
|
|
||||||
|
pop = svc.populate_view(
|
||||||
|
self.db,
|
||||||
|
view.id,
|
||||||
|
ViewPopulateRequest(
|
||||||
|
dry_run=True,
|
||||||
|
membership={"seed_fabric_node_ids": [nodes[0].id], "expand_hops": 3, "max_nodes": 2},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertLessEqual(pop.candidate_count, 4)
|
||||||
|
self.assertTrue(pop.truncated or pop.candidate_count <= 2 or pop.would_add <= 2)
|
||||||
|
|
||||||
def test_lldp_discover_writes_fabric_edge(self) -> None:
|
def test_lldp_discover_writes_fabric_edge(self) -> None:
|
||||||
suffix = uuid4().hex[:8]
|
suffix = uuid4().hex[:8]
|
||||||
ne_a = ManagedNE(
|
ne_a = ManagedNE(
|
||||||
|
|
@ -250,7 +563,10 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}"))
|
view = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"MR-{suffix}")),
|
||||||
|
)
|
||||||
graph = svc.add_nodes_to_view(
|
graph = svc.add_nodes_to_view(
|
||||||
self.db,
|
self.db,
|
||||||
view.id,
|
view.id,
|
||||||
|
|
@ -377,7 +693,10 @@ Management Addresses:
|
||||||
self.assertEqual(placeholder.device_type, "generic")
|
self.assertEqual(placeholder.device_type, "generic")
|
||||||
self.assertEqual(placeholder.source_ref, "198.51.100.200")
|
self.assertEqual(placeholder.source_ref, "198.51.100.200")
|
||||||
|
|
||||||
view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}"))
|
view = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"PR-{suffix}")),
|
||||||
|
)
|
||||||
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id]))
|
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id]))
|
||||||
projected = svc.project_fabric_neighbors_to_view(self.db, view.id)
|
projected = svc.project_fabric_neighbors_to_view(self.db, view.id)
|
||||||
self.assertEqual(len(projected.nodes), 2)
|
self.assertEqual(len(projected.nodes), 2)
|
||||||
|
|
@ -808,6 +1127,82 @@ Management Addresses:
|
||||||
self.db.delete(ne_b)
|
self.db.delete(ne_b)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
|
def test_delete_managed_detaches_fabric_keeps_topology(self) -> None:
|
||||||
|
from netx_api import ne_service
|
||||||
|
from netx_api.topology_inventory_lifecycle import reconcile_dangling_fabric_links
|
||||||
|
|
||||||
|
suffix = uuid4().hex[:8]
|
||||||
|
ne = ManagedNE(
|
||||||
|
id=f"detach-{suffix}",
|
||||||
|
name=f"DET-{suffix}",
|
||||||
|
vendor="Cisco",
|
||||||
|
device_type="cisco_ios",
|
||||||
|
ip_address=f"10.66.{(int(suffix[:2], 16) % 200) + 1}.1",
|
||||||
|
)
|
||||||
|
self.db.add(ne)
|
||||||
|
self.db.commit()
|
||||||
|
region_id = self._region(f"Detach-{suffix}")
|
||||||
|
view = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(name=f"DV-{suffix}", folder_id=region_id),
|
||||||
|
)
|
||||||
|
graph = svc.add_nodes_to_view(
|
||||||
|
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id])
|
||||||
|
)
|
||||||
|
fid = graph.nodes[0].fabric_node_id
|
||||||
|
peer = TopoFabricNode(
|
||||||
|
id=f"peer-{suffix}",
|
||||||
|
name=f"PEER-{suffix}",
|
||||||
|
ip=f"10.66.{(int(suffix[:2], 16) % 200) + 1}.2",
|
||||||
|
)
|
||||||
|
self.db.add(peer)
|
||||||
|
self.db.commit()
|
||||||
|
edge, _ = svc.upsert_fabric_edge(
|
||||||
|
self.db,
|
||||||
|
a_node_id=fid,
|
||||||
|
b_node_id=peer.id,
|
||||||
|
a_port="Gi0/0",
|
||||||
|
b_port="Gi0/1",
|
||||||
|
source="lldp",
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
ne_service.delete_managed_ne(self.db, ne.id)
|
||||||
|
|
||||||
|
fab = self.db.get(TopoFabricNode, fid)
|
||||||
|
self.assertIsNotNone(fab)
|
||||||
|
assert fab is not None
|
||||||
|
self.assertFalse(str(fab.managed_ne_id or "").strip())
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.query(TopoViewNode)
|
||||||
|
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == fid)
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self.db.get(TopoFabricEdge, edge.id))
|
||||||
|
|
||||||
|
orphaned = svc.list_fabric_nodes(self.db, link_status="orphaned", page_size=500)
|
||||||
|
self.assertTrue(any(x["id"] == fid for x in orphaned["items"]))
|
||||||
|
hit = next(x for x in orphaned["items"] if x["id"] == fid)
|
||||||
|
self.assertEqual(hit["link_status"], "orphaned")
|
||||||
|
self.assertFalse(hit["managed_alive"])
|
||||||
|
|
||||||
|
# Historical dangling ref (pre-lifecycle) is cleared by reconcile.
|
||||||
|
ghost_id = f"ghost-{suffix}"
|
||||||
|
dangling = TopoFabricNode(
|
||||||
|
id=f"dang-{suffix}",
|
||||||
|
managed_ne_id=ghost_id,
|
||||||
|
name=f"DANG-{suffix}",
|
||||||
|
ip="10.66.9.9",
|
||||||
|
)
|
||||||
|
self.db.add(dangling)
|
||||||
|
self.db.commit()
|
||||||
|
stats = reconcile_dangling_fabric_links(self.db)
|
||||||
|
self.db.commit()
|
||||||
|
self.assertGreaterEqual(int(stats["detached_managed_nodes"]), 1)
|
||||||
|
self.db.refresh(dangling)
|
||||||
|
self.assertFalse(str(dangling.managed_ne_id or "").strip())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
23
web/WEB.md
23
web/WEB.md
|
|
@ -123,15 +123,28 @@ src/
|
||||||
- 采集日志:`GET /v1/port-traffic/devices/{id}/events`;失败写入 `port_traffic_event`,列表操作可查看
|
- 采集日志:`GET /v1/port-traffic/devices/{id}/events`;失败写入 `port_traffic_event`,列表操作可查看
|
||||||
- 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元
|
- 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元
|
||||||
|
|
||||||
## 拓扑管理(Fabric + View)
|
## 拓扑管理(Fabric + 站点目录,对齐厂商)
|
||||||
|
|
||||||
|
- 三层库存:**运维** `managed_ne`(凭据/采集/WebCRT)· **EMS** `ume_inventory_ne` · **拓扑投影** `topo_fabric_node`(上图/LLDP/分类)。Fabric 由 ensure 按需创建,与运维表不是同一张表。
|
||||||
|
- 删除运维网元:解绑 `fabric.managed_ne_id`(及 view membership),**不删**上图点/边;UME 库存 reconcile 对称解绑 `ume_ne_id`。历史悬空引用:`POST /fabric/reconcile-links`(亦并入 `cleanup-duplicates`)。
|
||||||
- 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
|
- 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
|
||||||
- 视图:`topo_view` + `topo_view_node`(坐标);旧 `topology_map/node/edge` 已退役
|
- 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」)
|
||||||
- API:`/v1/topology/fabric/*`(summary / nodes / edges / neighborhood / discover job)、`/v1/topology/views*`
|
- 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建站自动建物理拓扑)+ `topo_view_node`
|
||||||
- 前端:`@xyflow/react`;侧栏上图、拖拽保存坐标(PATCH positions)、布局(dagre/力导向/网格/环形)、轮询 LLDP job
|
- 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网
|
||||||
- MCP:`getTopologySummary`、`queryTopologyNodes/Edges`、`getTopologyNeighborhood`、`runLldpDiscover`、`getLldpDiscoverJob`、`listTopologyViews`、`getTopologyView`
|
- API:`/v1/topology/tree`、`/folders*`、`/fabric/*`、`/views*`(含 `populate`、`kind`)
|
||||||
|
- 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」上图
|
||||||
|
- MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web
|
||||||
- BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO
|
- BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO
|
||||||
|
|
||||||
|
## 分类与切片(清单打标)
|
||||||
|
|
||||||
|
- Fabric 标签:`topo_fabric_node.role` / `region_folder_id`(`role_source` / `region_source`)
|
||||||
|
- 主流程:网元清单表(Fabric 全量)+ 临时正则查找 → 确认后批量写角色/区域;支持单行编辑
|
||||||
|
- 关联状态:`link_status`=`managed|ume|both|orphaned`;筛选 `link_status=linked|orphaned|…`
|
||||||
|
- API:`GET /fabric/nodes`(keyword/role/region/unmatched/link_status)、`POST /fabric/nodes/match`、`POST /fabric/nodes/tags/bulk`、`PATCH /fabric/nodes/{id}/tags`
|
||||||
|
- 切片:`POST /v1/topology/slices/generate`(`core_only` / `core_agg` / `agg_access`;dry_run;可重叠上图)
|
||||||
|
- 前端:网络管理 →「分类与切片」
|
||||||
|
|
||||||
## WebCRT
|
## WebCRT
|
||||||
|
|
||||||
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`;可选 `encoding`/`post_login_commands`/`async_connect`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
|
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`;可选 `encoding`/`post_login_commands`/`async_connect`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage";
|
||||||
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
|
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
|
||||||
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
|
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
|
||||||
import { LldpLinksPage } from "./pages/network/LldpLinksPage";
|
import { LldpLinksPage } from "./pages/network/LldpLinksPage";
|
||||||
|
import { TopologyClassifyPage } from "./pages/network/TopologyClassifyPage";
|
||||||
import { PortTrafficPage } from "./pages/network/PortTrafficPage";
|
import { PortTrafficPage } from "./pages/network/PortTrafficPage";
|
||||||
import { PortTrafficBoardListPage } from "./pages/network/PortTrafficBoardListPage";
|
import { PortTrafficBoardListPage } from "./pages/network/PortTrafficBoardListPage";
|
||||||
import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage";
|
import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage";
|
||||||
|
|
@ -106,6 +107,7 @@ function ProtectedApp() {
|
||||||
<Route path="alarms" element={<NetworkAlarmsPage />} />
|
<Route path="alarms" element={<NetworkAlarmsPage />} />
|
||||||
<Route path="configs" element={<NetworkConfigsPage />} />
|
<Route path="configs" element={<NetworkConfigsPage />} />
|
||||||
<Route path="topology/lldp" element={<LldpLinksPage />} />
|
<Route path="topology/lldp" element={<LldpLinksPage />} />
|
||||||
|
<Route path="topology/classify" element={<TopologyClassifyPage />} />
|
||||||
<Route path="topology" element={<Navigate to="/network/topology/lldp" replace />} />
|
<Route path="topology" element={<Navigate to="/network/topology/lldp" replace />} />
|
||||||
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
|
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
|
||||||
<Route path="tasks/collect" element={<CollectPage />} />
|
<Route path="tasks/collect" element={<CollectPage />} />
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
|
||||||
labelKey: "network.nav.lldpLinks",
|
labelKey: "network.nav.lldpLinks",
|
||||||
group: "topologyMgmt",
|
group: "topologyMgmt",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "topo-classify",
|
||||||
|
path: "/network/topology/classify",
|
||||||
|
labelKey: "network.nav.topoClassify",
|
||||||
|
group: "topologyMgmt",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,16 @@ export const queryKeys = {
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
) => ["umeCurrentAlarms", severity, cleared, hostName, keyword, page, pageSize] as const,
|
) => ["umeCurrentAlarms", severity, cleared, hostName, keyword, page, pageSize] as const,
|
||||||
topologyViews: ["topologyViews"] as const,
|
topologyViews: ["topologyViews"] as const,
|
||||||
|
topologyTree: ["topologyTree"] as const,
|
||||||
|
fabricNodeInventory: (
|
||||||
|
keyword: string,
|
||||||
|
role: string,
|
||||||
|
regionFolderId: string,
|
||||||
|
unmatched: string,
|
||||||
|
linkStatus: string,
|
||||||
|
page: number,
|
||||||
|
) => ["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page] as const,
|
||||||
|
fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const,
|
||||||
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
|
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
|
||||||
fabricSummary: ["fabricSummary"] as const,
|
fabricSummary: ["fabricSummary"] as const,
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const en = {
|
||||||
devices: "Device list",
|
devices: "Device list",
|
||||||
topology: "Topology",
|
topology: "Topology",
|
||||||
lldpLinks: "LLDP links",
|
lldpLinks: "LLDP links",
|
||||||
|
topoClassify: "Classify & slices",
|
||||||
alarms: "Alarms",
|
alarms: "Alarms",
|
||||||
configs: "Configurations",
|
configs: "Configurations",
|
||||||
webcrt: "WebCRT",
|
webcrt: "WebCRT",
|
||||||
|
|
@ -473,8 +474,8 @@ const en = {
|
||||||
selectedCount: "{{count}} selected",
|
selectedCount: "{{count}} selected",
|
||||||
clearSelected: "Clear selection",
|
clearSelected: "Clear selection",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
pickTitle: "Eligible NEs (connectivity passed)",
|
pickTitle: "Eligible NEs",
|
||||||
pickHint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
|
pickHint: "All managed and UME inventory NEs are selectable (any connect status). UME uses CLI connect profile credentials.",
|
||||||
pickEmpty: "No matching NEs.",
|
pickEmpty: "No matching NEs.",
|
||||||
filterKeyword: "Filter",
|
filterKeyword: "Filter",
|
||||||
filterKeywordPh: "Name / IP / vendor / device type",
|
filterKeywordPh: "Name / IP / vendor / device type",
|
||||||
|
|
@ -488,9 +489,9 @@ const en = {
|
||||||
meta: "{{ne}} NE(s) selected · {{cmd}} command(s)",
|
meta: "{{ne}} NE(s) selected · {{cmd}} command(s)",
|
||||||
},
|
},
|
||||||
eligible: {
|
eligible: {
|
||||||
title: "Eligible NEs (connectivity passed)",
|
title: "Eligible NEs",
|
||||||
hint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
|
hint: "All managed and UME inventory NEs are selectable (any connect status).",
|
||||||
empty: "No NEs with passed connectivity test.",
|
empty: "No NEs.",
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
title: "Collection setup",
|
title: "Collection setup",
|
||||||
|
|
@ -591,6 +592,7 @@ const en = {
|
||||||
name: "Name",
|
name: "Name",
|
||||||
vendor: "Vendor",
|
vendor: "Vendor",
|
||||||
deviceType: "Device type",
|
deviceType: "Device type",
|
||||||
|
source: "Source",
|
||||||
ip: "Management IP",
|
ip: "Management IP",
|
||||||
port: "Port",
|
port: "Port",
|
||||||
protocol: "Protocol",
|
protocol: "Protocol",
|
||||||
|
|
@ -602,6 +604,12 @@ const en = {
|
||||||
testedAt: "Tested at",
|
testedAt: "Tested at",
|
||||||
actions: "Actions",
|
actions: "Actions",
|
||||||
},
|
},
|
||||||
|
source: {
|
||||||
|
manual: "Manual",
|
||||||
|
ume_sync: "UME sync",
|
||||||
|
webcrt: "WebCRT",
|
||||||
|
lldp: "LLDP placeholder",
|
||||||
|
},
|
||||||
connect: {
|
connect: {
|
||||||
run: "Connectivity test",
|
run: "Connectivity test",
|
||||||
running: "Testing…",
|
running: "Testing…",
|
||||||
|
|
@ -1185,6 +1193,43 @@ const en = {
|
||||||
topology: {
|
topology: {
|
||||||
title: "Topology",
|
title: "Topology",
|
||||||
maps: "Maps",
|
maps: "Maps",
|
||||||
|
tree: "Topology tree",
|
||||||
|
treeLoading: "Loading tree…",
|
||||||
|
treeHint: "Regions hold a physical map plus optional custom maps; open a map to edit the canvas.",
|
||||||
|
rootName: "Regions",
|
||||||
|
selectRegion: "Select a region",
|
||||||
|
openMap: "Open map",
|
||||||
|
kindPhysical: "Physical",
|
||||||
|
kindCustom: "Custom",
|
||||||
|
regionNodeHint: "{{count}} map(s)",
|
||||||
|
layerNodeHint: "{{count}} NE(s)",
|
||||||
|
layerNodeEmpty: "Click to create & open",
|
||||||
|
rootCanvasHint: "Pick a region from the left tree.",
|
||||||
|
regionCanvasHint: "Open a map to edit devices, or create a custom map.",
|
||||||
|
regionBrowseHint: "Region folder: physical + custom maps. Click to open.",
|
||||||
|
browserTitle: "Topology folder",
|
||||||
|
browserRegionSub: "{{count}} map(s)",
|
||||||
|
browserEmptyMaps: "No maps in this region yet.",
|
||||||
|
addNe: "Add NE",
|
||||||
|
backUp: "Back to region",
|
||||||
|
newRegion: "New region",
|
||||||
|
newRegionName: "New region",
|
||||||
|
newRegionPrompt: "Region name",
|
||||||
|
regionCreated: "Region created",
|
||||||
|
regionDeleted: "Region deleted",
|
||||||
|
deleteRegion: "Delete region",
|
||||||
|
deleteRegionConfirm: "Delete region \"{{name}}\"? Its physical and custom maps will also be deleted.",
|
||||||
|
renameRegion: "Rename region",
|
||||||
|
renameRegionPrompt: "Region name",
|
||||||
|
regionRenamed: "Region renamed",
|
||||||
|
collapseRegion: "Collapse region",
|
||||||
|
expandRegion: "Expand region",
|
||||||
|
newCustom: "Custom map",
|
||||||
|
newCustomName: "Custom topology",
|
||||||
|
newCustomPrompt: "Custom map name",
|
||||||
|
folderHint: "Regions group maps. Each region gets a physical map; add custom maps as needed.",
|
||||||
|
folderHintShort: "Region folder",
|
||||||
|
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
||||||
newMap: "New",
|
newMap: "New",
|
||||||
newMapName: "New topology",
|
newMapName: "New topology",
|
||||||
rename: "Rename",
|
rename: "Rename",
|
||||||
|
|
@ -1193,7 +1238,7 @@ const en = {
|
||||||
renamed: "Topology map renamed",
|
renamed: "Topology map renamed",
|
||||||
deleteMap: "Delete map",
|
deleteMap: "Delete map",
|
||||||
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
|
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
|
||||||
emptyMaps: "No maps yet. Create one to start.",
|
emptyMaps: "No regions yet. Create a region first.",
|
||||||
palette: "NE library",
|
palette: "NE library",
|
||||||
paletteHint: "Click or drag onto the canvas",
|
paletteHint: "Click or drag onto the canvas",
|
||||||
filterPh: "Type to filter: name / IP / vendor",
|
filterPh: "Type to filter: name / IP / vendor",
|
||||||
|
|
@ -1214,7 +1259,7 @@ const en = {
|
||||||
sourcePort: "Local port",
|
sourcePort: "Local port",
|
||||||
targetPort: "Remote port",
|
targetPort: "Remote port",
|
||||||
canvasHint:
|
canvasHint:
|
||||||
"Add NEs from the palette, drag then save positions; LLDP discovery writes the global fabric. Blue dashed=discovered, red=stale.",
|
"Left tree lists regions with a physical map and optional custom maps. Open a map to edit devices; use Add NE and save positions.",
|
||||||
selected: "Selected",
|
selected: "Selected",
|
||||||
selectedEdge: "Selected edge",
|
selectedEdge: "Selected edge",
|
||||||
selectedCount: "{{count}} selected",
|
selectedCount: "{{count}} selected",
|
||||||
|
|
@ -1337,6 +1382,66 @@ const en = {
|
||||||
staleRemoved: "Removed {{count}} missing edge(s)",
|
staleRemoved: "Removed {{count}} missing edge(s)",
|
||||||
connectHint: "Connect mode: click source node, then target (Esc or Select to exit)",
|
connectHint: "Connect mode: click source node, then target (Esc or Select to exit)",
|
||||||
},
|
},
|
||||||
|
topoClassify: {
|
||||||
|
title: "Classify & slices",
|
||||||
|
openTopo: "Topology",
|
||||||
|
inventory: "NE inventory",
|
||||||
|
keywordPh: "Filter by name/IP",
|
||||||
|
filterRoleAll: "All roles",
|
||||||
|
filterRegionAll: "All regions",
|
||||||
|
filterUnmatchedOff: "No unmatched filter",
|
||||||
|
kindAny: "Role or region empty",
|
||||||
|
kindRole: "Role empty",
|
||||||
|
kindRegion: "Region empty",
|
||||||
|
filterLinkAll: "All link states",
|
||||||
|
filterLinkLinked: "Linked inventory",
|
||||||
|
filterLinkOrphaned: "Detached",
|
||||||
|
colLink: "Link",
|
||||||
|
linkManaged: "Managed",
|
||||||
|
linkUme: "UME",
|
||||||
|
linkBoth: "Managed+UME",
|
||||||
|
linkOrphaned: "Detached",
|
||||||
|
regexTitle: "Regex find",
|
||||||
|
patternPh: "Regex, e.g. ^CORE- or -BJ-",
|
||||||
|
fieldName: "Name",
|
||||||
|
fieldNameIp: "Name+IP",
|
||||||
|
findMatches: "Find matches",
|
||||||
|
matchOk: "Matched {{count}}; selected",
|
||||||
|
matchHint: "{{count}} matched",
|
||||||
|
assignRole: "Role only",
|
||||||
|
assignRegion: "Region only",
|
||||||
|
assignBoth: "Role + region",
|
||||||
|
clearRegion: "Clear region",
|
||||||
|
confirmAssign: "Confirm assign",
|
||||||
|
bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?",
|
||||||
|
bulkOk: "Updated {{count}}",
|
||||||
|
selectedCount: "{{count}} selected",
|
||||||
|
selectPage: "Select page",
|
||||||
|
colNe: "NE",
|
||||||
|
colRole: "Role",
|
||||||
|
colRegion: "Region",
|
||||||
|
colActions: "Actions",
|
||||||
|
edit: "Edit",
|
||||||
|
save: "Save",
|
||||||
|
cancel: "Cancel",
|
||||||
|
rowSaved: "Saved",
|
||||||
|
loading: "Loading…",
|
||||||
|
empty: "No NEs.",
|
||||||
|
showSlices: "Show slice maps",
|
||||||
|
hideSlices: "Hide slice maps",
|
||||||
|
slices: "Slice maps",
|
||||||
|
pickRegion: "Pick region",
|
||||||
|
tplCore: "Core only",
|
||||||
|
tplCoreAgg: "Core + neighbor Agg",
|
||||||
|
tplAggAcc: "Agg + neighbor Access",
|
||||||
|
seedPhysical: "Also place Cores on physical overview",
|
||||||
|
slicePreview: "Preview slices",
|
||||||
|
sliceApply: "Generate maps",
|
||||||
|
sliceConfirm: "Create custom maps and freeze placements?",
|
||||||
|
sliceOk: "Created {{count}} slice map(s)",
|
||||||
|
sliceStats: "{{maps}} map(s) planned, {{overlap}} NE(s) on multiple maps",
|
||||||
|
nodeCount: "{{count}} NE(s)",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const zh = {
|
||||||
devices: "设备列表",
|
devices: "设备列表",
|
||||||
topology: "拓扑信息",
|
topology: "拓扑信息",
|
||||||
lldpLinks: "LLDP 链路",
|
lldpLinks: "LLDP 链路",
|
||||||
|
topoClassify: "分类与切片",
|
||||||
alarms: "告警信息",
|
alarms: "告警信息",
|
||||||
configs: "配置信息",
|
configs: "配置信息",
|
||||||
webcrt: "WebCRT",
|
webcrt: "WebCRT",
|
||||||
|
|
@ -472,8 +473,8 @@ const zh = {
|
||||||
selectedCount: "共 {{count}} 台",
|
selectedCount: "共 {{count}} 台",
|
||||||
clearSelected: "清空已选",
|
clearSelected: "清空已选",
|
||||||
remove: "移除",
|
remove: "移除",
|
||||||
pickTitle: "可选网元(连通性已通过)",
|
pickTitle: "可选网元",
|
||||||
pickHint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
|
pickHint: "可选网元管理与 UME 清单中的全部网元(不限连通性状态)。UME 使用 CLI 连接模板凭据。",
|
||||||
pickEmpty: "没有匹配的网元。",
|
pickEmpty: "没有匹配的网元。",
|
||||||
filterKeyword: "筛选",
|
filterKeyword: "筛选",
|
||||||
filterKeywordPh: "名称 / IP / 厂商 / 设备类型",
|
filterKeywordPh: "名称 / IP / 厂商 / 设备类型",
|
||||||
|
|
@ -487,9 +488,9 @@ const zh = {
|
||||||
meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令",
|
meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令",
|
||||||
},
|
},
|
||||||
eligible: {
|
eligible: {
|
||||||
title: "可选网元(连通性已通过)",
|
title: "可选网元",
|
||||||
hint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
|
hint: "可选网元管理与 UME 清单中的全部网元(不限连通性状态)。",
|
||||||
empty: "暂无已通过连通性测试的网元。",
|
empty: "暂无网元。",
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
title: "采集配置",
|
title: "采集配置",
|
||||||
|
|
@ -589,6 +590,7 @@ const zh = {
|
||||||
name: "名称",
|
name: "名称",
|
||||||
vendor: "厂商",
|
vendor: "厂商",
|
||||||
deviceType: "设备类型",
|
deviceType: "设备类型",
|
||||||
|
source: "来源",
|
||||||
ip: "管理地址",
|
ip: "管理地址",
|
||||||
port: "端口",
|
port: "端口",
|
||||||
protocol: "协议",
|
protocol: "协议",
|
||||||
|
|
@ -600,6 +602,12 @@ const zh = {
|
||||||
testedAt: "测试时间",
|
testedAt: "测试时间",
|
||||||
actions: "操作",
|
actions: "操作",
|
||||||
},
|
},
|
||||||
|
source: {
|
||||||
|
manual: "手工",
|
||||||
|
ume_sync: "UME 同步",
|
||||||
|
webcrt: "WebCRT",
|
||||||
|
lldp: "LLDP 占位",
|
||||||
|
},
|
||||||
connect: {
|
connect: {
|
||||||
run: "连通性测试",
|
run: "连通性测试",
|
||||||
running: "测试中…",
|
running: "测试中…",
|
||||||
|
|
@ -1182,6 +1190,43 @@ const zh = {
|
||||||
topology: {
|
topology: {
|
||||||
title: "拓扑管理",
|
title: "拓扑管理",
|
||||||
maps: "拓扑图",
|
maps: "拓扑图",
|
||||||
|
tree: "拓扑树",
|
||||||
|
treeLoading: "加载拓扑树…",
|
||||||
|
treeHint: "区域下为物理拓扑与自定义图;点图打开设备画布。",
|
||||||
|
rootName: "区域",
|
||||||
|
selectRegion: "请选择区域",
|
||||||
|
openMap: "打开本图",
|
||||||
|
kindPhysical: "物理拓扑",
|
||||||
|
kindCustom: "自定义图",
|
||||||
|
regionNodeHint: "{{count}} 张图",
|
||||||
|
layerNodeHint: "{{count}} 个网元",
|
||||||
|
layerNodeEmpty: "点击创建并打开",
|
||||||
|
rootCanvasHint: "从左侧选择区域进入目录。",
|
||||||
|
regionCanvasHint: "点击图打开设备画布,或新建自定义图。",
|
||||||
|
regionBrowseHint: "区域目录:物理拓扑 + 自定义图。点击打开。",
|
||||||
|
browserTitle: "拓扑目录",
|
||||||
|
browserRegionSub: "共 {{count}} 张拓扑图",
|
||||||
|
browserEmptyMaps: "此区域还没有拓扑图。",
|
||||||
|
addNe: "添加网元",
|
||||||
|
backUp: "返回区域",
|
||||||
|
newRegion: "新建区域",
|
||||||
|
newRegionName: "新区域",
|
||||||
|
newRegionPrompt: "区域名称",
|
||||||
|
regionCreated: "区域已创建",
|
||||||
|
regionDeleted: "区域已删除",
|
||||||
|
deleteRegion: "删除区域",
|
||||||
|
deleteRegionConfirm: "删除区域「{{name}}」?其下物理拓扑与自定义图将一并删除。",
|
||||||
|
renameRegion: "重命名区域",
|
||||||
|
renameRegionPrompt: "区域名称",
|
||||||
|
regionRenamed: "区域已重命名",
|
||||||
|
collapseRegion: "折叠区域",
|
||||||
|
expandRegion: "展开区域",
|
||||||
|
newCustom: "自定义图",
|
||||||
|
newCustomName: "自定义拓扑",
|
||||||
|
newCustomPrompt: "自定义拓扑图名称",
|
||||||
|
folderHint: "区域用于分组。每区有一张物理拓扑,可再加自定义图。",
|
||||||
|
folderHintShort: "区域目录",
|
||||||
|
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
||||||
newMap: "新建",
|
newMap: "新建",
|
||||||
newMapName: "新拓扑图",
|
newMapName: "新拓扑图",
|
||||||
rename: "重命名",
|
rename: "重命名",
|
||||||
|
|
@ -1190,7 +1235,7 @@ const zh = {
|
||||||
renamed: "拓扑图已重命名",
|
renamed: "拓扑图已重命名",
|
||||||
deleteMap: "删除图",
|
deleteMap: "删除图",
|
||||||
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
|
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
|
||||||
emptyMaps: "还没有拓扑图,先新建一张。",
|
emptyMaps: "还没有区域,先点「新建区域」。",
|
||||||
palette: "网元库",
|
palette: "网元库",
|
||||||
paletteHint: "点击或拖到画布添加",
|
paletteHint: "点击或拖到画布添加",
|
||||||
filterPh: "输入即筛:名称 / IP / 厂商",
|
filterPh: "输入即筛:名称 / IP / 厂商",
|
||||||
|
|
@ -1211,7 +1256,7 @@ const zh = {
|
||||||
sourcePort: "本端端口",
|
sourcePort: "本端端口",
|
||||||
targetPort: "对端端口",
|
targetPort: "对端端口",
|
||||||
canvasHint:
|
canvasHint:
|
||||||
"从网元库上图,拖拽后保存坐标;LLDP 发现写入全局 Fabric。蓝虚线=发现链路,红虚线=stale。",
|
"左侧为区域目录(物理拓扑 + 自定义图);打开本图后进入设备画布,用「添加网元」上图并保存。",
|
||||||
selected: "已选节点",
|
selected: "已选节点",
|
||||||
selectedEdge: "已选链路",
|
selectedEdge: "已选链路",
|
||||||
selectedCount: "已选 {{count}}",
|
selectedCount: "已选 {{count}}",
|
||||||
|
|
@ -1333,6 +1378,66 @@ const zh = {
|
||||||
staleRemoved: "已删除 {{count}} 条未发现链路",
|
staleRemoved: "已删除 {{count}} 条未发现链路",
|
||||||
connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)",
|
connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)",
|
||||||
},
|
},
|
||||||
|
topoClassify: {
|
||||||
|
title: "分类与切片",
|
||||||
|
openTopo: "拓扑画布",
|
||||||
|
inventory: "网元清单",
|
||||||
|
keywordPh: "关键字筛选名称/IP",
|
||||||
|
filterRoleAll: "全部角色",
|
||||||
|
filterRegionAll: "全部区域",
|
||||||
|
filterUnmatchedOff: "不过滤未分类",
|
||||||
|
kindAny: "角色或区域未填",
|
||||||
|
kindRole: "角色未填",
|
||||||
|
kindRegion: "区域未填",
|
||||||
|
filterLinkAll: "全部关联",
|
||||||
|
filterLinkLinked: "已关联库存",
|
||||||
|
filterLinkOrphaned: "已解绑",
|
||||||
|
colLink: "关联",
|
||||||
|
linkManaged: "Managed",
|
||||||
|
linkUme: "UME",
|
||||||
|
linkBoth: "Managed+UME",
|
||||||
|
linkOrphaned: "已解绑",
|
||||||
|
regexTitle: "正则查找",
|
||||||
|
patternPh: "正则,如 ^CORE- 或 -BJ-",
|
||||||
|
fieldName: "名称",
|
||||||
|
fieldNameIp: "名称+IP",
|
||||||
|
findMatches: "查找匹配",
|
||||||
|
matchOk: "匹配 {{count}} 条,已勾选",
|
||||||
|
matchHint: "当前匹配 {{count}} 条",
|
||||||
|
assignRole: "只改角色",
|
||||||
|
assignRegion: "只改区域",
|
||||||
|
assignBoth: "角色+区域",
|
||||||
|
clearRegion: "清空区域",
|
||||||
|
confirmAssign: "确认写入",
|
||||||
|
bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?",
|
||||||
|
bulkOk: "已更新 {{count}} 条",
|
||||||
|
selectedCount: "已选 {{count}}",
|
||||||
|
selectPage: "全选本页",
|
||||||
|
colNe: "网元",
|
||||||
|
colRole: "角色",
|
||||||
|
colRegion: "区域",
|
||||||
|
colActions: "操作",
|
||||||
|
edit: "编辑",
|
||||||
|
save: "保存",
|
||||||
|
cancel: "取消",
|
||||||
|
rowSaved: "已保存",
|
||||||
|
loading: "加载中…",
|
||||||
|
empty: "没有网元。",
|
||||||
|
showSlices: "展开切片建图",
|
||||||
|
hideSlices: "收起切片建图",
|
||||||
|
slices: "切片建图",
|
||||||
|
pickRegion: "选择区域",
|
||||||
|
tplCore: "仅 Core",
|
||||||
|
tplCoreAgg: "Core + 邻接 Agg",
|
||||||
|
tplAggAcc: "Agg + 邻接 Access",
|
||||||
|
seedPhysical: "同时给物理总览上 Core",
|
||||||
|
slicePreview: "预览切片",
|
||||||
|
sliceApply: "生成切片图",
|
||||||
|
sliceConfirm: "将创建自定义拓扑图并上图冻结,确定?",
|
||||||
|
sliceOk: "已创建 {{count}} 张切片图",
|
||||||
|
sliceStats: "计划 {{maps}} 张图,重复上图网元 {{overlap}} 个",
|
||||||
|
nodeCount: "{{count}} 个网元",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default zh;
|
export default zh;
|
||||||
|
|
|
||||||
|
|
@ -3461,12 +3461,24 @@ pre {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 6px 0;
|
padding: 8px 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--nm-muted);
|
color: var(--nm-muted);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__rail .topo-sidebar__rail-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__rail:hover .topo-sidebar__rail-icon {
|
||||||
|
border-color: var(--nm-nav-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-sidebar__rail:hover {
|
.topo-sidebar__rail:hover {
|
||||||
background: var(--nm-nav-hover);
|
background: var(--nm-nav-hover);
|
||||||
color: var(--nm-brand);
|
color: var(--nm-brand);
|
||||||
|
|
@ -3548,6 +3560,557 @@ pre {
|
||||||
box-shadow: inset 0 0 0 1px #b8c4d2;
|
box-shadow: inset 0 0 0 1px #b8c4d2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-page .topo-sidebar__section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.topo-dir {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #c8d2de;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
.topo-dir__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.topo-dir__list--nested {
|
||||||
|
margin: 0 0 2px 10px;
|
||||||
|
padding: 2px 0 4px;
|
||||||
|
border-left: 1px solid #d5dde8;
|
||||||
|
}
|
||||||
|
.topo-dir__block {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.topo-dir__block--sep {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid #d5dde8;
|
||||||
|
}
|
||||||
|
.topo-dir__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 0 4px 0 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.topo-dir__row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
.topo-dir__row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.topo-dir__row--root {
|
||||||
|
min-height: 32px;
|
||||||
|
background: #eef3f9;
|
||||||
|
border-bottom: 1px solid #dbe3ee;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.topo-dir__row--file {
|
||||||
|
min-height: 26px;
|
||||||
|
}
|
||||||
|
.topo-dir__row.is-active {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: inset 0 0 0 1px #b7c9e2;
|
||||||
|
}
|
||||||
|
.topo-dir__row.is-active .topo-dir__name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nm-accent);
|
||||||
|
}
|
||||||
|
.topo-dir__twist {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 18px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #64748b;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.topo-dir__indent {
|
||||||
|
width: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.topo-dir__item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.topo-dir__icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.topo-dir__icon--root {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.topo-dir__icon--region {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
.topo-dir__icon--core {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
.topo-dir__icon--aggregation {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.topo-dir__icon--access {
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
.topo-dir__name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--nm-text);
|
||||||
|
}
|
||||||
|
.topo-dir__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.topo-dir__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.topo-dir__kind {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 4.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #64748b;
|
||||||
|
background: #e8eef5;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.topo-dir__kind.is-physical {
|
||||||
|
color: #9a3412;
|
||||||
|
background: #ffedd5;
|
||||||
|
}
|
||||||
|
.topo-dir__badge {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
min-width: 1.1rem;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: #e8eef5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.topo-dir__action {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #94a3b8;
|
||||||
|
opacity: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.topo-dir__row:hover .topo-dir__action,
|
||||||
|
.topo-dir__row.is-active .topo-dir__action {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.topo-dir__action:hover {
|
||||||
|
color: #334155;
|
||||||
|
background: #e8eef5;
|
||||||
|
}
|
||||||
|
.topo-dir__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 6px 6px 20px;
|
||||||
|
}
|
||||||
|
.topo-dir__toolbar button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--nm-accent);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.topo-dir__toolbar button:hover {
|
||||||
|
background: rgba(26, 86, 168, 0.08);
|
||||||
|
}
|
||||||
|
.topo-dir__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.topo-dir__icon svg,
|
||||||
|
.topo-tree__glyph-svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.topo-browser {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #f4f7fb 0%, #eef2f7 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.topo-browser__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 18px 10px;
|
||||||
|
border-bottom: 1px solid #dbe3ee;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.topo-browser__head strong {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--nm-brand);
|
||||||
|
}
|
||||||
|
.topo-browser__sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.topo-browser__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.topo-browser__actions .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.topo-browser__empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.topo-browser__empty p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.topo-browser__empty-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d5dde8;
|
||||||
|
color: #0f766e;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 39, 68, 0.05);
|
||||||
|
}
|
||||||
|
.topo-browser__grid {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 16px 18px 20px;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.22) 1px, transparent 0);
|
||||||
|
background-size: 18px 18px;
|
||||||
|
}
|
||||||
|
.topo-map-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 168px;
|
||||||
|
border: 1px solid #cfd8e6;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 39, 68, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.topo-map-card:hover {
|
||||||
|
border-color: #9eb4d0;
|
||||||
|
box-shadow: 0 4px 14px rgba(15, 39, 68, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.topo-map-card.is-physical {
|
||||||
|
border-color: #f0c48a;
|
||||||
|
background: linear-gradient(180deg, #fffdf8 0%, #fff 42%);
|
||||||
|
}
|
||||||
|
.topo-map-card__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 14px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.topo-map-card__icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #eef3f9;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #dbe3ee;
|
||||||
|
}
|
||||||
|
.topo-map-card.is-physical .topo-map-card__icon {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #b45309;
|
||||||
|
border-color: #fed7aa;
|
||||||
|
}
|
||||||
|
.topo-map-card__kind {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #e8eef8;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
.topo-map-card__kind.is-physical {
|
||||||
|
color: #9a3412;
|
||||||
|
background: #ffedd5;
|
||||||
|
}
|
||||||
|
.topo-map-card__name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--nm-brand);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.topo-map-card__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #64748b;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.topo-map-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
.topo-map-card__actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.topo-map-card--add {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 168px;
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: #b7c4d4;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
color: var(--nm-accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.topo-map-card--add:hover {
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--nm-accent);
|
||||||
|
color: var(--nm-brand);
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 39, 68, 0.06);
|
||||||
|
}
|
||||||
|
.topo-map-card__add-plus {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px dashed currentColor;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.topo-breadcrumb__crumb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.topo-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topo-breadcrumb__link {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nm-accent, #2563eb);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.topo-breadcrumb__sep {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.topo-breadcrumb__current {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.topo-toolbar__level {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.topo-nav-node {
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #c9d4e2;
|
||||||
|
background: linear-gradient(165deg, #ffffff 0%, #f3f7fb 100%);
|
||||||
|
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.07);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.topo-nav-node.is-selected,
|
||||||
|
.topo-nav-node:hover {
|
||||||
|
border-color: var(--nm-accent, #2563eb);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--nm-accent, #2563eb) 22%, transparent),
|
||||||
|
0 8px 18px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
.topo-nav-node__icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: color-mix(in srgb, #2563eb 12%, #fff);
|
||||||
|
}
|
||||||
|
.topo-nav-node--region .topo-nav-node__icon {
|
||||||
|
color: #0f766e;
|
||||||
|
background: color-mix(in srgb, #0d9488 14%, #fff);
|
||||||
|
}
|
||||||
|
.topo-nav-node--core .topo-nav-node__icon {
|
||||||
|
color: #b45309;
|
||||||
|
background: color-mix(in srgb, #f59e0b 16%, #fff);
|
||||||
|
}
|
||||||
|
.topo-nav-node--aggregation .topo-nav-node__icon {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: color-mix(in srgb, #3b82f6 14%, #fff);
|
||||||
|
}
|
||||||
|
.topo-nav-node--access .topo-nav-node__icon {
|
||||||
|
color: #6d28d9;
|
||||||
|
background: color-mix(in srgb, #8b5cf6 14%, #fff);
|
||||||
|
}
|
||||||
|
.topo-nav-node__body {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.topo-nav-node__badge {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.55;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.topo-nav-node__title {
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.topo-nav-node__sub,
|
||||||
|
.topo-nav-node__meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.68;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.topo-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.topo-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
.topo-modal__panel {
|
||||||
|
position: relative;
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
max-height: min(70vh, 640px);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.22);
|
||||||
|
}
|
||||||
|
.topo-modal__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.topo-modal__list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
.topo-map-list,
|
.topo-map-list,
|
||||||
.topo-palette {
|
.topo-palette {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
@ -3558,6 +4121,160 @@ pre {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-classify .topo-classify__head-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.topo-classify__section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #d5dde8;
|
||||||
|
}
|
||||||
|
.topo-classify__section--first {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.topo-classify__regex-bar,
|
||||||
|
.topo-classify__assign-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #d5dde8;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.topo-classify__regex-input {
|
||||||
|
min-width: 220px;
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
.topo-classify__table tr.is-match {
|
||||||
|
background: #eef6ff;
|
||||||
|
}
|
||||||
|
.topo-classify__row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.topo-classify__section h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--nm-brand, #0f2744);
|
||||||
|
}
|
||||||
|
.topo-classify__section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.topo-classify__section-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.topo-classify__draft {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.topo-classify__draft .input {
|
||||||
|
min-width: 140px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.topo-classify__preview {
|
||||||
|
margin: 10px 0 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d5dde8;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.topo-classify__pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.topo-classify__check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.topo-classify__slice-list,
|
||||||
|
.topo-classify__view-links {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
.topo-classify__view-links {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.topo-classify__view-links a {
|
||||||
|
color: var(--nm-accent, #1a56a8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list__block {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list__row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list__block.is-active > .topo-region-list__row {
|
||||||
|
border-color: var(--nm-accent);
|
||||||
|
background: var(--nm-nav-active);
|
||||||
|
box-shadow: inset 2px 0 0 var(--nm-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions {
|
||||||
|
border-left-color: #9bb6d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list__glyph {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list__maps {
|
||||||
|
margin: 4px 0 0 18px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list__maps > li {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list .topo-map-list__actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-region-list .topo-map-list__name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-map-list li,
|
.topo-map-list li,
|
||||||
.topo-palette > li {
|
.topo-palette > li {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
|
|
@ -4793,33 +5510,50 @@ pre {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
/* Default asset is blue (= ZTE). */
|
/* Base asset /topo/ne-router.png is navy-blue; tints approximate brand hues. */
|
||||||
--topo-icon-filter: none;
|
--topo-icon-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cisco — cyan / light blue (#049FD9) */
|
||||||
.topo-node--cisco {
|
.topo-node--cisco {
|
||||||
--topo-icon-filter: hue-rotate(-8deg) saturate(1.15);
|
--topo-icon-filter: hue-rotate(-18deg) saturate(1.25) brightness(1.08);
|
||||||
}
|
}
|
||||||
|
/* Huawei — red (#CF0A2C); was incorrectly greenish at 155deg */
|
||||||
.topo-node--huawei {
|
.topo-node--huawei {
|
||||||
--topo-icon-filter: hue-rotate(155deg) saturate(1.35);
|
--topo-icon-filter: hue-rotate(128deg) saturate(1.85) brightness(1.02);
|
||||||
}
|
}
|
||||||
|
/* ZTE — keep navy-blue base (#0091DA family) */
|
||||||
.topo-node--zte {
|
.topo-node--zte {
|
||||||
--topo-icon-filter: none;
|
--topo-icon-filter: saturate(1.15) brightness(1.02);
|
||||||
}
|
}
|
||||||
|
/* Juniper — green (#84B135) */
|
||||||
.topo-node--juniper {
|
.topo-node--juniper {
|
||||||
--topo-icon-filter: hue-rotate(95deg) saturate(1.2);
|
--topo-icon-filter: hue-rotate(78deg) saturate(1.35) brightness(1.05);
|
||||||
}
|
}
|
||||||
|
/* Nokia / Alcatel — deep blue-purple (#124191) */
|
||||||
.topo-node--nokia {
|
.topo-node--nokia {
|
||||||
--topo-icon-filter: hue-rotate(-28deg) saturate(1.1) brightness(0.98);
|
--topo-icon-filter: hue-rotate(-35deg) saturate(1.2) brightness(0.92);
|
||||||
}
|
}
|
||||||
|
/* Ericsson — dark navy */
|
||||||
.topo-node--ericsson {
|
.topo-node--ericsson {
|
||||||
--topo-icon-filter: hue-rotate(175deg) saturate(1.45) brightness(1.05);
|
--topo-icon-filter: hue-rotate(-8deg) saturate(1.1) brightness(0.82);
|
||||||
}
|
}
|
||||||
|
/* H3C — green (#7AC143) */
|
||||||
.topo-node--h3c {
|
.topo-node--h3c {
|
||||||
--topo-icon-filter: hue-rotate(165deg) saturate(1.4);
|
--topo-icon-filter: hue-rotate(88deg) saturate(1.45) brightness(1.06);
|
||||||
}
|
}
|
||||||
|
/* Ruijie — purple */
|
||||||
|
.topo-node--ruijie {
|
||||||
|
--topo-icon-filter: hue-rotate(45deg) saturate(1.4) brightness(1.0);
|
||||||
|
}
|
||||||
|
/* MikroTik — red-orange */
|
||||||
|
.topo-node--mikrotik {
|
||||||
|
--topo-icon-filter: hue-rotate(145deg) saturate(1.7) brightness(1.05);
|
||||||
|
}
|
||||||
|
/* Unmanaged / unknown vendor */
|
||||||
|
.topo-node--gray,
|
||||||
.topo-node--other {
|
.topo-node--other {
|
||||||
--topo-icon-filter: grayscale(0.85) brightness(0.95);
|
--topo-icon-filter: grayscale(1) brightness(0.92) contrast(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-node__glyph {
|
.topo-node__glyph {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ import { formatSystemTime } from "../utils/time";
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
const ELIGIBLE_PAGE_SIZE = 20;
|
const ELIGIBLE_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function eligibleKey(row: Pick<EligibleNeItem, "id" | "source">): string {
|
||||||
|
const src = String(row.source || "managed").trim().toLowerCase() || "managed";
|
||||||
|
return `${src}:${row.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
function collectionErrorMessage(err: unknown, t: (key: string) => string): string {
|
function collectionErrorMessage(err: unknown, t: (key: string) => string): string {
|
||||||
const raw = String(err);
|
const raw = String(err);
|
||||||
if (raw.includes("collection_ne_busy")) return t("collect.neBusy");
|
if (raw.includes("collection_ne_busy")) return t("collect.neBusy");
|
||||||
|
|
@ -140,12 +145,20 @@ export function CollectPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => {
|
||||||
createNeCollection({
|
const ne_ids: string[] = [];
|
||||||
|
const ume_ne_ids: string[] = [];
|
||||||
|
for (const row of selectedList) {
|
||||||
|
if (String(row.source || "managed").toLowerCase() === "ume") ume_ne_ids.push(row.id);
|
||||||
|
else ne_ids.push(row.id);
|
||||||
|
}
|
||||||
|
return createNeCollection({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
commands,
|
commands,
|
||||||
ne_ids: selectedIds,
|
ne_ids,
|
||||||
}),
|
ume_ne_ids,
|
||||||
|
});
|
||||||
|
},
|
||||||
onSuccess: async (job) => {
|
onSuccess: async (job) => {
|
||||||
showOk(t("collect.created", { id: job.id }));
|
showOk(t("collect.created", { id: job.id }));
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
|
|
@ -157,7 +170,8 @@ export function CollectPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const addNe = (row: EligibleNeItem) => {
|
const addNe = (row: EligibleNeItem) => {
|
||||||
setSelectedMap((prev) => (prev[row.id] ? prev : { ...prev, [row.id]: row }));
|
const key = eligibleKey(row);
|
||||||
|
setSelectedMap((prev) => (prev[key] ? prev : { ...prev, [key]: row }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addBatchNe = (rows: EligibleNeItem[]) => {
|
const addBatchNe = (rows: EligibleNeItem[]) => {
|
||||||
|
|
@ -165,16 +179,17 @@ export function CollectPage() {
|
||||||
setSelectedMap((prev) => {
|
setSelectedMap((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!next[row.id]) next[row.id] = row;
|
const key = eligibleKey(row);
|
||||||
|
if (!next[key]) next[key] = row;
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeNe = (id: string) => {
|
const removeNe = (key: string) => {
|
||||||
setSelectedMap((prev) => {
|
setSelectedMap((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[id];
|
delete next[key];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -182,8 +197,10 @@ export function CollectPage() {
|
||||||
const clearSelected = () => setSelectedMap({});
|
const clearSelected = () => setSelectedMap({});
|
||||||
|
|
||||||
const eligibleItems = eligibleQuery.data?.items ?? [];
|
const eligibleItems = eligibleQuery.data?.items ?? [];
|
||||||
const selectablePickItems = eligibleItems.filter((row) => !selectedMap[row.id]);
|
const selectablePickItems = eligibleItems.filter((row) => !selectedMap[eligibleKey(row)]);
|
||||||
const allPickSelected = selectablePickItems.length > 0 && selectablePickItems.every((row) => pickSelectedIds.includes(row.id));
|
const allPickSelected =
|
||||||
|
selectablePickItems.length > 0 &&
|
||||||
|
selectablePickItems.every((row) => pickSelectedIds.includes(eligibleKey(row)));
|
||||||
const batchPickCount = pickSelectedIds.length;
|
const batchPickCount = pickSelectedIds.length;
|
||||||
const neTotal = eligibleQuery.data?.total ?? 0;
|
const neTotal = eligibleQuery.data?.total ?? 0;
|
||||||
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
|
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
|
||||||
|
|
@ -257,20 +274,25 @@ export function CollectPage() {
|
||||||
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
|
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="collect-selected-list">
|
<div className="collect-selected-list">
|
||||||
{selectedList.map((row) => (
|
{selectedList.map((row) => {
|
||||||
<div key={row.id} className="collect-selected-chip">
|
const key = eligibleKey(row);
|
||||||
|
const src = String(row.source || "managed").toLowerCase();
|
||||||
|
return (
|
||||||
|
<div key={key} className="collect-selected-chip">
|
||||||
<span className="collect-selected-chip__main">
|
<span className="collect-selected-chip__main">
|
||||||
<strong>{row.name || row.ip_address}</strong>
|
<strong>{row.name || row.ip_address}</strong>
|
||||||
<span className="collect-selected-chip__meta">
|
<span className="collect-selected-chip__meta">
|
||||||
{row.ip_address}
|
{src}
|
||||||
|
{row.ip_address ? ` · ${row.ip_address}` : ""}
|
||||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" className="link-btn" onClick={() => removeNe(row.id)}>
|
<button type="button" className="link-btn" onClick={() => removeNe(key)}>
|
||||||
{t("collect.create.remove")}
|
{t("collect.create.remove")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,7 +309,9 @@ export function CollectPage() {
|
||||||
className="link-btn"
|
className="link-btn"
|
||||||
disabled={batchPickCount === 0}
|
disabled={batchPickCount === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const rows = eligibleItems.filter((x) => pickSelectedIds.includes(x.id) && !selectedMap[x.id]);
|
const rows = eligibleItems.filter(
|
||||||
|
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
|
||||||
|
);
|
||||||
addBatchNe(rows);
|
addBatchNe(rows);
|
||||||
setPickSelectedIds([]);
|
setPickSelectedIds([]);
|
||||||
}}
|
}}
|
||||||
|
|
@ -327,7 +351,7 @@ export function CollectPage() {
|
||||||
checked={allPickSelected}
|
checked={allPickSelected}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
setPickSelectedIds(selectablePickItems.map((row) => row.id));
|
setPickSelectedIds(selectablePickItems.map((row) => eligibleKey(row)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPickSelectedIds([]);
|
setPickSelectedIds([]);
|
||||||
|
|
@ -335,6 +359,7 @@ export function CollectPage() {
|
||||||
aria-label="pick all"
|
aria-label="pick all"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
|
<th>{t("managedNe.col.source")}</th>
|
||||||
<th>{t("managedNe.col.name")}</th>
|
<th>{t("managedNe.col.name")}</th>
|
||||||
<th>{t("managedNe.col.vendor")}</th>
|
<th>{t("managedNe.col.vendor")}</th>
|
||||||
<th>{t("managedNe.col.ip")}</th>
|
<th>{t("managedNe.col.ip")}</th>
|
||||||
|
|
@ -344,22 +369,27 @@ export function CollectPage() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{eligibleItems.map((row) => {
|
{eligibleItems.map((row) => {
|
||||||
const picked = Boolean(selectedMap[row.id]);
|
const key = eligibleKey(row);
|
||||||
|
const picked = Boolean(selectedMap[key]);
|
||||||
|
const src = String(row.source || "managed").toLowerCase();
|
||||||
return (
|
return (
|
||||||
<tr key={row.id}>
|
<tr key={key}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={pickSelectedIds.includes(row.id)}
|
checked={pickSelectedIds.includes(key)}
|
||||||
disabled={picked}
|
disabled={picked}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (picked) return;
|
if (picked) return;
|
||||||
setPickSelectedIds((prev) =>
|
setPickSelectedIds((prev) =>
|
||||||
e.target.checked ? [...new Set([...prev, row.id])] : prev.filter((id) => id !== row.id),
|
e.target.checked ? [...new Set([...prev, key])] : prev.filter((id) => id !== key),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="table-tag">{src}</span>
|
||||||
|
</td>
|
||||||
<td>{row.name || row.ip_address}</td>
|
<td>{row.name || row.ip_address}</td>
|
||||||
<td>{row.vendor}</td>
|
<td>{row.vendor}</td>
|
||||||
<td>{row.ip_address}</td>
|
<td>{row.ip_address}</td>
|
||||||
|
|
@ -373,7 +403,7 @@ export function CollectPage() {
|
||||||
disabled={picked}
|
disabled={picked}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
addNe(row);
|
addNe(row);
|
||||||
setPickSelectedIds((prev) => prev.filter((id) => id !== row.id));
|
setPickSelectedIds((prev) => prev.filter((id) => id !== key));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{picked ? t("collect.create.added") : t("collect.create.add")}
|
{picked ? t("collect.create.added") : t("collect.create.add")}
|
||||||
|
|
@ -703,6 +733,7 @@ function JobRunsPanel({
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>{t("managedNe.col.source")}</th>
|
||||||
<th>{t("managedNe.col.name")}</th>
|
<th>{t("managedNe.col.name")}</th>
|
||||||
<th>{t("managedNe.col.ip")}</th>
|
<th>{t("managedNe.col.ip")}</th>
|
||||||
<th>{t("collect.runs.status")}</th>
|
<th>{t("collect.runs.status")}</th>
|
||||||
|
|
@ -713,6 +744,9 @@ function JobRunsPanel({
|
||||||
<tbody>
|
<tbody>
|
||||||
{runs.map((run) => (
|
{runs.map((run) => (
|
||||||
<tr key={run.id}>
|
<tr key={run.id}>
|
||||||
|
<td>
|
||||||
|
<span className="table-tag">{run.ne_source || "managed"}</span>
|
||||||
|
</td>
|
||||||
<td>{run.ne_name}</td>
|
<td>{run.ne_name}</td>
|
||||||
<td>{run.ne_ip}</td>
|
<td>{run.ne_ip}</td>
|
||||||
<td>{run.status}</td>
|
<td>{run.status}</td>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ import {
|
||||||
type HopVendor,
|
type HopVendor,
|
||||||
} from "../utils/hopProxy";
|
} from "../utils/hopProxy";
|
||||||
|
|
||||||
|
/** Hide UME→managed sync/delete controls until needed again. APIs remain available. */
|
||||||
|
const SHOW_UME_MANAGED_SYNC = false;
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
name: string;
|
name: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
|
|
@ -97,6 +100,13 @@ function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force
|
||||||
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) };
|
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function managedSourceKey(source: string | undefined): "manual" | "ume_sync" | "webcrt" | "lldp" | "" {
|
||||||
|
const s = String(source || "").trim().toLowerCase();
|
||||||
|
if (!s) return "manual";
|
||||||
|
if (s === "ume_sync" || s === "webcrt" || s === "lldp") return s;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span className="form-label">
|
<span className="form-label">
|
||||||
|
|
@ -571,24 +581,32 @@ export function NePage() {
|
||||||
<button type="button" onClick={openCreate} disabled={!credsOk}>
|
<button type="button" onClick={openCreate} disabled={!credsOk}>
|
||||||
{t("managedNe.add")}
|
{t("managedNe.add")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{SHOW_UME_MANAGED_SYNC ? (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => umeSyncMutation.mutate()}
|
<button
|
||||||
disabled={umeSyncMutation.isPending}
|
type="button"
|
||||||
>
|
onClick={() => umeSyncMutation.mutate()}
|
||||||
{umeSyncMutation.isPending ? t("managedNe.umeSync.syncing") : t("managedNe.umeSync.sync")}
|
disabled={umeSyncMutation.isPending}
|
||||||
</button>
|
>
|
||||||
<button
|
{umeSyncMutation.isPending
|
||||||
type="button"
|
? t("managedNe.umeSync.syncing")
|
||||||
className="btn btn--danger"
|
: t("managedNe.umeSync.sync")}
|
||||||
onClick={() => {
|
</button>
|
||||||
if (!window.confirm(t("managedNe.umeSync.deleteConfirm"))) return;
|
<button
|
||||||
umeDeleteMutation.mutate();
|
type="button"
|
||||||
}}
|
className="btn btn--danger"
|
||||||
disabled={umeDeleteMutation.isPending}
|
onClick={() => {
|
||||||
>
|
if (!window.confirm(t("managedNe.umeSync.deleteConfirm"))) return;
|
||||||
{umeDeleteMutation.isPending ? t("managedNe.umeSync.deleting") : t("managedNe.umeSync.delete")}
|
umeDeleteMutation.mutate();
|
||||||
</button>
|
}}
|
||||||
|
disabled={umeDeleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{umeDeleteMutation.isPending
|
||||||
|
? t("managedNe.umeSync.deleting")
|
||||||
|
: t("managedNe.umeSync.delete")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -746,6 +764,7 @@ export function NePage() {
|
||||||
<th>{t("managedNe.col.name")}</th>
|
<th>{t("managedNe.col.name")}</th>
|
||||||
<th>{t("managedNe.col.vendor")}</th>
|
<th>{t("managedNe.col.vendor")}</th>
|
||||||
<th>{t("managedNe.col.deviceType")}</th>
|
<th>{t("managedNe.col.deviceType")}</th>
|
||||||
|
<th>{t("managedNe.col.source")}</th>
|
||||||
<th>{t("managedNe.col.tags")}</th>
|
<th>{t("managedNe.col.tags")}</th>
|
||||||
<th>{t("managedNe.col.ip")}</th>
|
<th>{t("managedNe.col.ip")}</th>
|
||||||
<th>{t("managedNe.col.user")}</th>
|
<th>{t("managedNe.col.user")}</th>
|
||||||
|
|
@ -755,7 +774,12 @@ export function NePage() {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{listItems.map((row) => (
|
{listItems.map((row) => {
|
||||||
|
const srcKey = managedSourceKey(row.source);
|
||||||
|
const srcLabel = srcKey
|
||||||
|
? t(`managedNe.source.${srcKey}`)
|
||||||
|
: String(row.source || "").trim() || t("managedNe.source.manual");
|
||||||
|
return (
|
||||||
<tr key={row.id}>
|
<tr key={row.id}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
|
|
@ -767,6 +791,9 @@ export function NePage() {
|
||||||
<td className="pt-list-task-name">{row.name || row.ip_address}</td>
|
<td className="pt-list-task-name">{row.name || row.ip_address}</td>
|
||||||
<td>{row.vendor}</td>
|
<td>{row.vendor}</td>
|
||||||
<td>{row.device_type}</td>
|
<td>{row.device_type}</td>
|
||||||
|
<td title={row.source_ref || undefined}>
|
||||||
|
<span className="table-tag">{srcLabel}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{row.tags
|
{row.tags
|
||||||
? row.tags.split(/\s+/).map((tag) => (
|
? row.tags.split(/\s+/).map((tag) => (
|
||||||
|
|
@ -839,7 +866,8 @@ export function NePage() {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3208,7 +3208,6 @@ export function WebcrtPage() {
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 id="webcrt-new-session-title">{t("webcrt.newSession.title")}</h3>
|
<h3 id="webcrt-new-session-title">{t("webcrt.newSession.title")}</h3>
|
||||||
<p className="form-hint">{t("webcrt.newSession.hint")}</p>
|
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<label>
|
<label>
|
||||||
<FormLabel required>{t("webcrt.newSession.protocol")}</FormLabel>
|
<FormLabel required>{t("webcrt.newSession.protocol")}</FormLabel>
|
||||||
|
|
|
||||||
615
web/src/pages/network/TopologyClassifyPage.tsx
Normal file
615
web/src/pages/network/TopologyClassifyPage.tsx
Normal file
|
|
@ -0,0 +1,615 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
bulkTagFabricNodes,
|
||||||
|
fetchFabricNodes,
|
||||||
|
fetchTopologyTree,
|
||||||
|
generateTopologySlices,
|
||||||
|
matchFabricNodes,
|
||||||
|
patchFabricNodeTags,
|
||||||
|
} from "../../services/api";
|
||||||
|
import { queryKeys } from "../../constants/queryKeys";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { useToast } from "../../hooks/useToast";
|
||||||
|
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||||
|
import type { FabricNodeSearchHit, SliceGenerateResult, TopologyTreeFolderItem } from "../../types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
|
||||||
|
if (!root) return [];
|
||||||
|
return (root.children || []).filter((c) => String(c.kind) === "region");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopologyClassifyPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { showOk, showError } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [filterRole, setFilterRole] = useState("");
|
||||||
|
const [filterRegion, setFilterRegion] = useState("");
|
||||||
|
const [unmatched, setUnmatched] = useState("");
|
||||||
|
const [linkStatus, setLinkStatus] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const linkLabel = (status: string | undefined) => {
|
||||||
|
switch (String(status || "").toLowerCase()) {
|
||||||
|
case "managed":
|
||||||
|
return t("topoClassify.linkManaged");
|
||||||
|
case "ume":
|
||||||
|
return t("topoClassify.linkUme");
|
||||||
|
case "both":
|
||||||
|
return t("topoClassify.linkBoth");
|
||||||
|
case "orphaned":
|
||||||
|
return t("topoClassify.linkOrphaned");
|
||||||
|
default:
|
||||||
|
return status || "—";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [regex, setRegex] = useState("");
|
||||||
|
const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name");
|
||||||
|
const [matchedIds, setMatchedIds] = useState<string[]>([]);
|
||||||
|
const [matchTotal, setMatchTotal] = useState(0);
|
||||||
|
const [assignRole, setAssignRole] = useState("core");
|
||||||
|
const [assignRegion, setAssignRegion] = useState("");
|
||||||
|
const [assignWhat, setAssignWhat] = useState<"role" | "region" | "both">("role");
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||||
|
const [editingId, setEditingId] = useState("");
|
||||||
|
const [editRole, setEditRole] = useState("");
|
||||||
|
const [editRegion, setEditRegion] = useState("");
|
||||||
|
|
||||||
|
const [sliceFolderId, setSliceFolderId] = useState("");
|
||||||
|
const [sliceTemplate, setSliceTemplate] = useState<"core_only" | "core_agg" | "agg_access">(
|
||||||
|
"core_agg",
|
||||||
|
);
|
||||||
|
const [slicePreview, setSlicePreview] = useState<SliceGenerateResult | null>(null);
|
||||||
|
const [seedPhysical, setSeedPhysical] = useState(true);
|
||||||
|
const [showSlices, setShowSlices] = useState(false);
|
||||||
|
|
||||||
|
const treeQuery = useQuery({
|
||||||
|
queryKey: queryKeys.topologyTree,
|
||||||
|
queryFn: fetchTopologyTree,
|
||||||
|
});
|
||||||
|
const regions = useMemo(() => flattenRegions(treeQuery.data?.root), [treeQuery.data?.root]);
|
||||||
|
const regionName = useMemo(() => {
|
||||||
|
const m: Record<string, string> = {};
|
||||||
|
for (const r of regions) m[r.id] = r.name;
|
||||||
|
return m;
|
||||||
|
}, [regions]);
|
||||||
|
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: queryKeys.fabricNodeInventory(
|
||||||
|
keyword,
|
||||||
|
filterRole,
|
||||||
|
filterRegion,
|
||||||
|
unmatched,
|
||||||
|
linkStatus,
|
||||||
|
page,
|
||||||
|
),
|
||||||
|
queryFn: () =>
|
||||||
|
fetchFabricNodes({
|
||||||
|
keyword,
|
||||||
|
role: filterRole,
|
||||||
|
regionFolderId: filterRegion,
|
||||||
|
unmatched,
|
||||||
|
linkStatus,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateList = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["fabricNodeInventory"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
matchFabricNodes({ pattern: regex, match_field: matchField, sample_limit: 80 }),
|
||||||
|
onSuccess: (out) => {
|
||||||
|
setMatchedIds(out.fabric_node_ids || []);
|
||||||
|
setMatchTotal(out.total_matched || 0);
|
||||||
|
const next: Record<string, boolean> = {};
|
||||||
|
for (const id of out.fabric_node_ids || []) next[id] = true;
|
||||||
|
setSelected(next);
|
||||||
|
showOk(t("topoClassify.matchOk").replace("{{count}}", String(out.total_matched)));
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkMut = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const ids = Object.keys(selected).filter((k) => selected[k]);
|
||||||
|
const body: Parameters<typeof bulkTagFabricNodes>[0] = {
|
||||||
|
fabric_node_ids: ids.length ? ids : matchedIds,
|
||||||
|
dry_run: false,
|
||||||
|
};
|
||||||
|
if (assignWhat === "role" || assignWhat === "both") body.role = assignRole;
|
||||||
|
if (assignWhat === "region" || assignWhat === "both") {
|
||||||
|
body.region_folder_id = assignRegion || "";
|
||||||
|
}
|
||||||
|
return bulkTagFabricNodes(body);
|
||||||
|
},
|
||||||
|
onSuccess: async (out) => {
|
||||||
|
showOk(t("topoClassify.bulkOk").replace("{{count}}", String(out.updated)));
|
||||||
|
setMatchedIds([]);
|
||||||
|
setMatchTotal(0);
|
||||||
|
setSelected({});
|
||||||
|
await invalidateList();
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
patchFabricNodeTags(editingId, {
|
||||||
|
role: editRole,
|
||||||
|
region_folder_id: editRegion || "",
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
showOk(t("topoClassify.rowSaved"));
|
||||||
|
setEditingId("");
|
||||||
|
await invalidateList();
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const slicePreviewMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
generateTopologySlices({
|
||||||
|
folder_id: sliceFolderId,
|
||||||
|
template: sliceTemplate,
|
||||||
|
dry_run: true,
|
||||||
|
max_nodes: 300,
|
||||||
|
seed_physical_cores: seedPhysical,
|
||||||
|
}),
|
||||||
|
onSuccess: (out) => setSlicePreview(out),
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliceApplyMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
generateTopologySlices({
|
||||||
|
folder_id: sliceFolderId,
|
||||||
|
template: sliceTemplate,
|
||||||
|
dry_run: false,
|
||||||
|
max_nodes: 300,
|
||||||
|
seed_physical_cores: seedPhysical,
|
||||||
|
}),
|
||||||
|
onSuccess: async (out) => {
|
||||||
|
showOk(t("topoClassify.sliceOk").replace("{{count}}", String(out.created_view_ids.length)));
|
||||||
|
setSlicePreview(out);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = listQuery.data?.items || [];
|
||||||
|
const total = listQuery.data?.total || 0;
|
||||||
|
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const selectedCount = Object.values(selected).filter(Boolean).length;
|
||||||
|
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
|
||||||
|
|
||||||
|
const startEdit = (n: FabricNodeSearchHit) => {
|
||||||
|
setEditingId(n.id);
|
||||||
|
setEditRole(n.role || "");
|
||||||
|
setEditRegion(n.region_folder_id || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRow = (id: string) => {
|
||||||
|
setSelected((s) => ({ ...s, [id]: !s[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePage = (on: boolean) => {
|
||||||
|
setSelected((s) => {
|
||||||
|
const next = { ...s };
|
||||||
|
for (const n of items) next[n.id] = on;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel topo-classify">
|
||||||
|
<div className="panel__toolbar">
|
||||||
|
<h2>{t("topoClassify.title")}</h2>
|
||||||
|
<div className="btn-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => openOrFocusModule({ moduleId: "topology", path: "/topology" })}
|
||||||
|
>
|
||||||
|
{t("topoClassify.openTopo")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="topo-classify__section topo-classify__section--first">
|
||||||
|
<h3>{t("topoClassify.inventory")}</h3>
|
||||||
|
<div className="topo-classify__draft">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={t("topoClassify.keywordPh")}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setKeyword(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={filterRole}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilterRole(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("topoClassify.filterRoleAll")}</option>
|
||||||
|
<option value="core">core</option>
|
||||||
|
<option value="aggregation">aggregation</option>
|
||||||
|
<option value="access">access</option>
|
||||||
|
<option value="unknown">unknown</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={filterRegion}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilterRegion(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("topoClassify.filterRegionAll")}</option>
|
||||||
|
{regions.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={unmatched}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUnmatched(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("topoClassify.filterUnmatchedOff")}</option>
|
||||||
|
<option value="any">{t("topoClassify.kindAny")}</option>
|
||||||
|
<option value="role">{t("topoClassify.kindRole")}</option>
|
||||||
|
<option value="region">{t("topoClassify.kindRegion")}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={linkStatus}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLinkStatus(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("topoClassify.filterLinkAll")}</option>
|
||||||
|
<option value="linked">{t("topoClassify.filterLinkLinked")}</option>
|
||||||
|
<option value="orphaned">{t("topoClassify.filterLinkOrphaned")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topo-classify__regex-bar">
|
||||||
|
<strong>{t("topoClassify.regexTitle")}</strong>
|
||||||
|
<input
|
||||||
|
className="input topo-classify__regex-input"
|
||||||
|
placeholder={t("topoClassify.patternPh")}
|
||||||
|
value={regex}
|
||||||
|
onChange={(e) => setRegex(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={matchField}
|
||||||
|
onChange={(e) => setMatchField(e.target.value as "name" | "ip" | "name_ip")}
|
||||||
|
>
|
||||||
|
<option value="name">{t("topoClassify.fieldName")}</option>
|
||||||
|
<option value="ip">IP</option>
|
||||||
|
<option value="name_ip">{t("topoClassify.fieldNameIp")}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={!regex.trim() || matchMut.isPending}
|
||||||
|
onClick={() => matchMut.mutate()}
|
||||||
|
>
|
||||||
|
{t("topoClassify.findMatches")}
|
||||||
|
</button>
|
||||||
|
{matchTotal > 0 ? (
|
||||||
|
<span className="panel__hint">
|
||||||
|
{t("topoClassify.matchHint").replace("{{count}}", String(matchTotal))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topo-classify__assign-bar">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={assignWhat}
|
||||||
|
onChange={(e) => setAssignWhat(e.target.value as "role" | "region" | "both")}
|
||||||
|
>
|
||||||
|
<option value="role">{t("topoClassify.assignRole")}</option>
|
||||||
|
<option value="region">{t("topoClassify.assignRegion")}</option>
|
||||||
|
<option value="both">{t("topoClassify.assignBoth")}</option>
|
||||||
|
</select>
|
||||||
|
{assignWhat !== "region" ? (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={assignRole}
|
||||||
|
onChange={(e) => setAssignRole(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="core">core</option>
|
||||||
|
<option value="aggregation">aggregation</option>
|
||||||
|
<option value="access">access</option>
|
||||||
|
<option value="unknown">unknown</option>
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{assignWhat !== "role" ? (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={assignRegion}
|
||||||
|
onChange={(e) => setAssignRegion(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("topoClassify.clearRegion")}</option>
|
||||||
|
{regions.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)}
|
||||||
|
onClick={() => {
|
||||||
|
const n = selectedCount || matchedIds.length;
|
||||||
|
const msg = t("topoClassify.bulkConfirm").replace("{{count}}", String(n));
|
||||||
|
if (window.confirm(msg)) bulkMut.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("topoClassify.confirmAssign")}
|
||||||
|
</button>
|
||||||
|
<span className="panel__hint">
|
||||||
|
{t("topoClassify.selectedCount").replace("{{count}}", String(selectedCount))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="data-table topo-classify__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={items.length > 0 && items.every((n) => selected[n.id])}
|
||||||
|
onChange={(e) => togglePage(e.target.checked)}
|
||||||
|
aria-label={t("topoClassify.selectPage")}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th>{t("topoClassify.colNe")}</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>{t("topoClassify.colLink")}</th>
|
||||||
|
<th>{t("topoClassify.colRole")}</th>
|
||||||
|
<th>{t("topoClassify.colRegion")}</th>
|
||||||
|
<th>{t("topoClassify.colActions")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((n) => {
|
||||||
|
const editing = editingId === n.id;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={n.id}
|
||||||
|
className={highlight.has(n.id) || selected[n.id] ? "is-match" : undefined}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(selected[n.id])}
|
||||||
|
onChange={() => toggleRow(n.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{n.name || n.id}</td>
|
||||||
|
<td>{n.ip}</td>
|
||||||
|
<td>
|
||||||
|
{linkLabel(n.link_status)}
|
||||||
|
{n.managed_source ? ` · ${n.managed_source}` : ""}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editing ? (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={editRole}
|
||||||
|
onChange={(e) => setEditRole(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="core">core</option>
|
||||||
|
<option value="aggregation">aggregation</option>
|
||||||
|
<option value="access">access</option>
|
||||||
|
<option value="unknown">unknown</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
n.role || "-"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editing ? (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={editRegion}
|
||||||
|
onChange={(e) => setEditRegion(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">-</option>
|
||||||
|
{regions.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
(n.region_folder_id && regionName[n.region_folder_id]) ||
|
||||||
|
n.region_folder_id ||
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editing ? (
|
||||||
|
<div className="topo-classify__row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={patchMut.isPending}
|
||||||
|
onClick={() => patchMut.mutate()}
|
||||||
|
>
|
||||||
|
{t("topoClassify.save")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => setEditingId("")}
|
||||||
|
>
|
||||||
|
{t("topoClassify.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => startEdit(n)}
|
||||||
|
>
|
||||||
|
{t("topoClassify.edit")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="panel__hint">
|
||||||
|
{listQuery.isLoading ? t("topoClassify.loading") : t("topoClassify.empty")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="topo-classify__pager">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
{t("common.prevPage")}
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
{t("common.pagerMeta")
|
||||||
|
.replace("{{total}}", String(total))
|
||||||
|
.replace("{{page}}", String(page))
|
||||||
|
.replace("{{pages}}", String(pages))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={page >= pages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
{t("common.nextPage")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="topo-classify__section">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => setShowSlices((v) => !v)}
|
||||||
|
>
|
||||||
|
{showSlices ? t("topoClassify.hideSlices") : t("topoClassify.showSlices")}
|
||||||
|
</button>
|
||||||
|
{showSlices ? (
|
||||||
|
<>
|
||||||
|
<h3>{t("topoClassify.slices")}</h3>
|
||||||
|
<div className="topo-classify__draft">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={sliceFolderId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSliceFolderId(e.target.value);
|
||||||
|
setSlicePreview(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("topoClassify.pickRegion")}</option>
|
||||||
|
{regions.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={sliceTemplate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSliceTemplate(e.target.value as "core_only" | "core_agg" | "agg_access")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="core_only">{t("topoClassify.tplCore")}</option>
|
||||||
|
<option value="core_agg">{t("topoClassify.tplCoreAgg")}</option>
|
||||||
|
<option value="agg_access">{t("topoClassify.tplAggAcc")}</option>
|
||||||
|
</select>
|
||||||
|
<label className="topo-classify__check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seedPhysical}
|
||||||
|
onChange={(e) => setSeedPhysical(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("topoClassify.seedPhysical")}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={!sliceFolderId || slicePreviewMut.isPending}
|
||||||
|
onClick={() => slicePreviewMut.mutate()}
|
||||||
|
>
|
||||||
|
{t("topoClassify.slicePreview")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={!sliceFolderId || sliceApplyMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t("topoClassify.sliceConfirm"))) sliceApplyMut.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("topoClassify.sliceApply")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{slicePreview ? (
|
||||||
|
<div className="topo-classify__preview">
|
||||||
|
<p className="panel__hint">
|
||||||
|
{t("topoClassify.sliceStats")
|
||||||
|
.replace("{{maps}}", String(slicePreview.map_count))
|
||||||
|
.replace("{{overlap}}", String(slicePreview.overlap_node_count))}
|
||||||
|
</p>
|
||||||
|
<ul className="topo-classify__slice-list">
|
||||||
|
{slicePreview.maps.map((m) => (
|
||||||
|
<li key={m.name}>
|
||||||
|
<strong>{m.name}</strong>
|
||||||
|
{" · "}
|
||||||
|
{m.role}
|
||||||
|
{" · "}
|
||||||
|
{t("topoClassify.nodeCount").replace("{{count}}", String(m.node_count))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,8 @@ import type {
|
||||||
FabricEdge,
|
FabricEdge,
|
||||||
FabricSummary,
|
FabricSummary,
|
||||||
TopologyDiscoverJob,
|
TopologyDiscoverJob,
|
||||||
|
TopologyOutsidePeer,
|
||||||
|
TopologyTree,
|
||||||
TopologyViewGraph,
|
TopologyViewGraph,
|
||||||
TopologyViewItem,
|
TopologyViewItem,
|
||||||
LldpCollectDashboard,
|
LldpCollectDashboard,
|
||||||
|
|
@ -442,7 +444,12 @@ export const fetchEligibleNe = (params: { page: number; pageSize: number; keywor
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createNeCollection = (body: { title?: string; commands: string; ne_ids: string[] }) =>
|
export const createNeCollection = (body: {
|
||||||
|
title?: string;
|
||||||
|
commands: string;
|
||||||
|
ne_ids?: string[];
|
||||||
|
ume_ne_ids?: string[];
|
||||||
|
}) =>
|
||||||
apiPost<CollectionJobItem>("/v1/ne-collections", body);
|
apiPost<CollectionJobItem>("/v1/ne-collections", body);
|
||||||
|
|
||||||
export const fetchNeCollections = (params: { page: number; pageSize: number }) => {
|
export const fetchNeCollections = (params: { page: number; pageSize: number }) => {
|
||||||
|
|
@ -852,20 +859,81 @@ export const fetchUmeCurrentAlarms = (params: {
|
||||||
export const fetchTopologyViews = () =>
|
export const fetchTopologyViews = () =>
|
||||||
apiGet<{ total: number; items: TopologyViewItem[] }>("/v1/topology/views");
|
apiGet<{ total: number; items: TopologyViewItem[] }>("/v1/topology/views");
|
||||||
|
|
||||||
export const createTopologyView = (body: { name: string; remark?: string; filter?: Record<string, unknown> }) =>
|
export const fetchTopologyTree = () => apiGet<TopologyTree>("/v1/topology/tree");
|
||||||
apiPost<TopologyViewItem>("/v1/topology/views", body);
|
|
||||||
|
export const createTopologyFolder = (body: { name: string; kind?: string; parent_id?: string; sort_order?: number }) =>
|
||||||
|
apiPost<{
|
||||||
|
id: string;
|
||||||
|
parent_id: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_system: boolean;
|
||||||
|
}>("/v1/topology/folders", body);
|
||||||
|
|
||||||
|
export const updateTopologyFolder = (
|
||||||
|
folderId: string,
|
||||||
|
body: { name?: string; parent_id?: string; sort_order?: number },
|
||||||
|
) =>
|
||||||
|
apiPatch<{
|
||||||
|
id: string;
|
||||||
|
parent_id: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_system: boolean;
|
||||||
|
}>(`/v1/topology/folders/${encodeURIComponent(folderId)}`, body);
|
||||||
|
|
||||||
|
export const deleteTopologyFolder = (folderId: string, force = false) =>
|
||||||
|
apiDelete<{ ok: boolean; folder_id: string; deleted: boolean }>(
|
||||||
|
`/v1/topology/folders/${encodeURIComponent(folderId)}?force=${force ? "true" : "false"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createTopologyView = (body: {
|
||||||
|
name: string;
|
||||||
|
remark?: string;
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
folder_id?: string;
|
||||||
|
kind?: string;
|
||||||
|
role?: string;
|
||||||
|
sort_order?: number;
|
||||||
|
}) => apiPost<TopologyViewItem>("/v1/topology/views", body);
|
||||||
|
|
||||||
|
export const populateTopologyView = (
|
||||||
|
viewId: string,
|
||||||
|
body?: { dry_run?: boolean; membership?: Record<string, unknown>; freeze_after?: boolean },
|
||||||
|
) =>
|
||||||
|
apiPost<{
|
||||||
|
view_id: string;
|
||||||
|
dry_run: boolean;
|
||||||
|
candidate_count: number;
|
||||||
|
would_add: number;
|
||||||
|
added: number;
|
||||||
|
max_nodes: number;
|
||||||
|
truncated: boolean;
|
||||||
|
outside_peers: TopologyOutsidePeer[];
|
||||||
|
graph: TopologyViewGraph | null;
|
||||||
|
}>(`/v1/topology/views/${encodeURIComponent(viewId)}/populate`, body || {});
|
||||||
|
|
||||||
export const fetchTopologyViewGraph = (viewId: string) =>
|
export const fetchTopologyViewGraph = (viewId: string) =>
|
||||||
apiGet<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}`);
|
apiGet<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}`);
|
||||||
|
|
||||||
export const updateTopologyView = (
|
export const updateTopologyView = (
|
||||||
viewId: string,
|
viewId: string,
|
||||||
body: { name?: string; remark?: string; filter?: Record<string, unknown>; viewport?: Record<string, unknown> },
|
body: {
|
||||||
|
name?: string;
|
||||||
|
remark?: string;
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
viewport?: Record<string, unknown>;
|
||||||
|
folder_id?: string;
|
||||||
|
role?: string;
|
||||||
|
sort_order?: number;
|
||||||
|
},
|
||||||
) => apiPatch<TopologyViewItem>(`/v1/topology/views/${encodeURIComponent(viewId)}`, body);
|
) => apiPatch<TopologyViewItem>(`/v1/topology/views/${encodeURIComponent(viewId)}`, body);
|
||||||
|
|
||||||
export const deleteTopologyView = (viewId: string) =>
|
export const deleteTopologyView = (viewId: string, force = false) =>
|
||||||
apiDelete<{ ok: boolean; view_id: string; deleted: boolean }>(
|
apiDelete<{ ok: boolean; view_id: string; deleted: boolean }>(
|
||||||
`/v1/topology/views/${encodeURIComponent(viewId)}`,
|
`/v1/topology/views/${encodeURIComponent(viewId)}?force=${force ? "true" : "false"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const patchTopologyPositions = (
|
export const patchTopologyPositions = (
|
||||||
|
|
@ -908,6 +976,91 @@ export const patchTopologyEdgeStyle = (
|
||||||
|
|
||||||
export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabric/summary");
|
export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabric/summary");
|
||||||
|
|
||||||
|
export const fetchFabricNodes = (params?: {
|
||||||
|
keyword?: string;
|
||||||
|
role?: string;
|
||||||
|
regionFolderId?: string;
|
||||||
|
unmatched?: string;
|
||||||
|
linkStatus?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (params?.keyword) p.set("keyword", params.keyword);
|
||||||
|
if (params?.role) p.set("role", params.role);
|
||||||
|
if (params?.regionFolderId) p.set("region_folder_id", params.regionFolderId);
|
||||||
|
if (params?.unmatched) p.set("unmatched", params.unmatched);
|
||||||
|
if (params?.linkStatus) p.set("link_status", params.linkStatus);
|
||||||
|
p.set("page", String(Math.max(1, Number(params?.page || 1))));
|
||||||
|
p.set("page_size", String(Math.max(1, Math.min(500, Number(params?.pageSize || 50)))));
|
||||||
|
return apiGet<{
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: import("../types").FabricNodeSearchHit[];
|
||||||
|
}>(`/v1/topology/fabric/nodes?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const matchFabricNodes = (body: {
|
||||||
|
pattern: string;
|
||||||
|
match_field?: string;
|
||||||
|
sample_limit?: number;
|
||||||
|
}) =>
|
||||||
|
apiPost<{
|
||||||
|
pattern: string;
|
||||||
|
match_field: string;
|
||||||
|
total_matched: number;
|
||||||
|
samples: Array<Record<string, string>>;
|
||||||
|
fabric_node_ids: string[];
|
||||||
|
}>("/v1/topology/fabric/nodes/match", body);
|
||||||
|
|
||||||
|
export const bulkTagFabricNodes = (body: {
|
||||||
|
fabric_node_ids?: string[];
|
||||||
|
pattern?: string;
|
||||||
|
match_field?: string;
|
||||||
|
role?: string | null;
|
||||||
|
region_folder_id?: string | null;
|
||||||
|
dry_run?: boolean;
|
||||||
|
}) =>
|
||||||
|
apiPost<{
|
||||||
|
dry_run: boolean;
|
||||||
|
matched: number;
|
||||||
|
updated: number;
|
||||||
|
role?: string | null;
|
||||||
|
region_folder_id?: string | null;
|
||||||
|
samples: Array<Record<string, string>>;
|
||||||
|
}>("/v1/topology/fabric/nodes/tags/bulk", body);
|
||||||
|
|
||||||
|
export const patchFabricNodeTags = (
|
||||||
|
fabricNodeId: string,
|
||||||
|
body: { role?: string | null; region_folder_id?: string | null },
|
||||||
|
) =>
|
||||||
|
apiPatch<import("../types").FabricNodeSearchHit>(
|
||||||
|
`/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const generateTopologySlices = (body: {
|
||||||
|
folder_id: string;
|
||||||
|
template: "core_only" | "core_agg" | "agg_access";
|
||||||
|
dry_run?: boolean;
|
||||||
|
max_nodes?: number;
|
||||||
|
seed_physical_cores?: boolean;
|
||||||
|
}) => apiPost<import("../types").SliceGenerateResult>("/v1/topology/slices/generate", body);
|
||||||
|
|
||||||
|
export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize?: number }) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (params?.q) p.set("q", params.q);
|
||||||
|
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 || 50)))));
|
||||||
|
return apiGet<{
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: import("../types").FabricNodeSearchHit[];
|
||||||
|
}>(`/v1/topology/fabric/nodes/search?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchFabricEdges = (params?: {
|
export const fetchFabricEdges = (params?: {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
|
||||||
119
web/src/types.ts
119
web/src/types.ts
|
|
@ -188,6 +188,8 @@ export type ManagedNeItem = {
|
||||||
connect_tested_at: string | null;
|
connect_tested_at: string | null;
|
||||||
tags: string;
|
tags: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
source?: string;
|
||||||
|
source_ref?: string;
|
||||||
hop_enabled: boolean;
|
hop_enabled: boolean;
|
||||||
hop_vendor: string;
|
hop_vendor: string;
|
||||||
hop_host: string;
|
hop_host: string;
|
||||||
|
|
@ -287,6 +289,7 @@ export type UmeCliOverrideItem = {
|
||||||
|
|
||||||
export type EligibleNeItem = {
|
export type EligibleNeItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
source?: "managed" | "ume" | string;
|
||||||
name: string;
|
name: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
device_type: string;
|
device_type: string;
|
||||||
|
|
@ -315,6 +318,7 @@ export type CollectionRunItem = {
|
||||||
id: string;
|
id: string;
|
||||||
job_id: string;
|
job_id: string;
|
||||||
ne_id: string;
|
ne_id: string;
|
||||||
|
ne_source?: string;
|
||||||
ne_name: string;
|
ne_name: string;
|
||||||
ne_ip: string;
|
ne_ip: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|
@ -348,17 +352,124 @@ export type UmeTokenStatus = {
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TopologyViewRole = "core" | "aggregation" | "access";
|
||||||
|
export type TopologyViewKind = "physical" | "custom";
|
||||||
|
|
||||||
export type TopologyViewItem = {
|
export type TopologyViewItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
folder_id?: string;
|
||||||
|
kind?: TopologyViewKind | string;
|
||||||
|
role?: TopologyViewRole | string;
|
||||||
|
sort_order?: number;
|
||||||
filter?: Record<string, unknown>;
|
filter?: Record<string, unknown>;
|
||||||
viewport?: Record<string, unknown>;
|
viewport?: Record<string, unknown>;
|
||||||
node_count: number;
|
node_count: number;
|
||||||
|
edge_count?: number;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TopologyTreeViewItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: TopologyViewKind | string;
|
||||||
|
role?: TopologyViewRole | string;
|
||||||
|
sort_order: number;
|
||||||
|
node_count: number;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyTreeFolderItem = {
|
||||||
|
id: string;
|
||||||
|
parent_id: string;
|
||||||
|
kind: "root" | "region" | string;
|
||||||
|
name: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_system: boolean;
|
||||||
|
views: TopologyTreeViewItem[];
|
||||||
|
children: TopologyTreeFolderItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyTree = {
|
||||||
|
root: TopologyTreeFolderItem | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClassifyRuleScope = "role" | "region";
|
||||||
|
|
||||||
|
export type ClassifyRule = {
|
||||||
|
id: string;
|
||||||
|
scope: ClassifyRuleScope | string;
|
||||||
|
name: string;
|
||||||
|
pattern: string;
|
||||||
|
match_field: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
remark?: string;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClassifyPreview = {
|
||||||
|
total_nodes: number;
|
||||||
|
role_matched: number;
|
||||||
|
role_unmatched: number;
|
||||||
|
role_conflicts: number;
|
||||||
|
region_matched: number;
|
||||||
|
region_unmatched: number;
|
||||||
|
region_conflicts: number;
|
||||||
|
role_samples: Array<Record<string, unknown>>;
|
||||||
|
region_samples: Array<Record<string, unknown>>;
|
||||||
|
unmatched_samples: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClassifyApplyResult = {
|
||||||
|
role_updated: number;
|
||||||
|
region_updated: number;
|
||||||
|
skipped_manual: number;
|
||||||
|
total_nodes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FabricNodeSearchHit = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
vendor: string;
|
||||||
|
managed_ne_id?: string;
|
||||||
|
ume_ne_id?: string;
|
||||||
|
role?: string;
|
||||||
|
region_folder_id?: string | null;
|
||||||
|
link_status?: "managed" | "ume" | "both" | "orphaned" | string;
|
||||||
|
managed_alive?: boolean;
|
||||||
|
ume_alive?: boolean;
|
||||||
|
managed_source?: string;
|
||||||
|
views?: Array<{
|
||||||
|
view_id: string;
|
||||||
|
view_name: string;
|
||||||
|
folder_id: string;
|
||||||
|
folder_name: string;
|
||||||
|
kind: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SliceGenerateResult = {
|
||||||
|
folder_id: string;
|
||||||
|
template: string;
|
||||||
|
dry_run: boolean;
|
||||||
|
map_count: number;
|
||||||
|
overlap_node_count: number;
|
||||||
|
created_view_ids: string[];
|
||||||
|
maps: Array<{
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
node_count: number;
|
||||||
|
seed_fabric_node_ids: string[];
|
||||||
|
member_fabric_node_ids: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type TopologyViewNodeItem = {
|
export type TopologyViewNodeItem = {
|
||||||
fabric_node_id: string;
|
fabric_node_id: string;
|
||||||
managed_ne_id: string;
|
managed_ne_id: string;
|
||||||
|
|
@ -389,12 +500,20 @@ export type TopologyViewEdgeItem = {
|
||||||
discovered_at?: string | null;
|
discovered_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TopologyOutsidePeer = {
|
||||||
|
fabric_node_id: string;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
via_node_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TopologyViewGraph = {
|
export type TopologyViewGraph = {
|
||||||
view: TopologyViewItem;
|
view: TopologyViewItem;
|
||||||
nodes: TopologyViewNodeItem[];
|
nodes: TopologyViewNodeItem[];
|
||||||
edges: TopologyViewEdgeItem[];
|
edges: TopologyViewEdgeItem[];
|
||||||
truncated?: boolean;
|
truncated?: boolean;
|
||||||
truncate_reason?: string;
|
truncate_reason?: string;
|
||||||
|
outside_peers?: TopologyOutsidePeer[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FabricSummary = {
|
export type FabricSummary = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue