mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:43:17 +08:00
Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""Topology classify preview/apply and fabric node tagging."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .models import TopoFabricNode, TopoFolder
|
|
from .topology_classify_common import (
|
|
_MATCH_FIELDS,
|
|
_compile_pattern,
|
|
_enabled_rules,
|
|
_match_text,
|
|
_resolve_level_hit,
|
|
_resolve_region_hit,
|
|
_utcnow,
|
|
apply_level_fields,
|
|
)
|
|
from .topology_level import level_to_role, normalize_level, role_to_level
|
|
from .topology_schemas import (
|
|
ClassifyApplyOut,
|
|
ClassifyPreviewOut,
|
|
FabricNodeOut,
|
|
FabricNodesBulkTagOut,
|
|
FabricNodesBulkTagRequest,
|
|
FabricNodesMatchOut,
|
|
FabricNodesMatchRequest,
|
|
FabricNodeTagPatch,
|
|
)
|
|
|
|
|
|
def preview_classify(db: Session, *, sample_limit: int = 20) -> ClassifyPreviewOut:
|
|
level_rules = _enabled_rules(db, "level")
|
|
region_rules = _enabled_rules(db, "region")
|
|
nodes = db.query(TopoFabricNode).order_by(TopoFabricNode.name.asc()).all()
|
|
level_matched = level_unmatched = level_conflict = 0
|
|
region_matched = region_unmatched = region_conflict = 0
|
|
level_samples: list[dict[str, Any]] = []
|
|
region_samples: list[dict[str, Any]] = []
|
|
unmatched_samples: list[dict[str, Any]] = []
|
|
|
|
for n in nodes:
|
|
level, _rid, multi_r = _resolve_level_hit(n, level_rules)
|
|
if level is None:
|
|
level_unmatched += 1
|
|
else:
|
|
level_matched += 1
|
|
if multi_r:
|
|
level_conflict += 1
|
|
if len(level_samples) < sample_limit:
|
|
level_samples.append(
|
|
{
|
|
"fabric_node_id": n.id,
|
|
"name": n.name,
|
|
"ip": n.ip,
|
|
"level": level,
|
|
"role": level_to_role(level),
|
|
"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 level 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),
|
|
level_matched=level_matched,
|
|
level_unmatched=level_unmatched,
|
|
level_conflicts=level_conflict,
|
|
role_matched=level_matched,
|
|
role_unmatched=level_unmatched,
|
|
role_conflicts=level_conflict,
|
|
region_matched=region_matched,
|
|
region_unmatched=region_unmatched,
|
|
region_conflicts=region_conflict,
|
|
level_samples=level_samples,
|
|
role_samples=level_samples,
|
|
region_samples=region_samples,
|
|
unmatched_samples=unmatched_samples,
|
|
)
|
|
|
|
|
|
def apply_classify(
|
|
db: Session,
|
|
*,
|
|
skip_manual: bool = True,
|
|
fill_empty_only: bool = False,
|
|
) -> ClassifyApplyOut:
|
|
level_rules = _enabled_rules(db, "level")
|
|
region_rules = _enabled_rules(db, "region")
|
|
nodes = db.query(TopoFabricNode).all()
|
|
level_updated = region_updated = skipped_manual = 0
|
|
|
|
for n in nodes:
|
|
level, _, _ = _resolve_level_hit(n, level_rules)
|
|
if level is not None:
|
|
if skip_manual and str(n.role_source or "") == "manual":
|
|
skipped_manual += 1
|
|
elif fill_empty_only and n.level is not None:
|
|
pass
|
|
else:
|
|
apply_level_fields(n, level, source="rule")
|
|
n.updated_at = _utcnow()
|
|
level_updated += 1
|
|
|
|
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(
|
|
level_updated=level_updated,
|
|
role_updated=level_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()
|
|
if k == "role":
|
|
k = "level"
|
|
level_miss = TopoFabricNode.level.is_(None)
|
|
region_miss = or_(
|
|
TopoFabricNode.region_folder_id.is_(None),
|
|
TopoFabricNode.region_folder_id == "",
|
|
)
|
|
if k == "level":
|
|
q = q.filter(level_miss)
|
|
elif k == "region":
|
|
q = q.filter(region_miss)
|
|
else:
|
|
q = q.filter(or_(level_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")
|
|
data = body.model_dump(exclude_unset=True)
|
|
if "level" in data or "role" in data:
|
|
try:
|
|
if "level" in data:
|
|
lv = normalize_level(data.get("level"))
|
|
else:
|
|
lv = role_to_level(data.get("role"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
apply_level_fields(n, lv, source="manual")
|
|
if "region_folder_id" in data:
|
|
fid = str(data.get("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,
|
|
"level": n.level,
|
|
"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 level/region after user confirms a regex or explicit selection."""
|
|
data = body.model_dump(exclude_unset=True)
|
|
level_v: float | None | object = Ellipsis
|
|
if "level" in data:
|
|
try:
|
|
level_v = normalize_level(data.get("level"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
elif "role" in data:
|
|
try:
|
|
level_v = role_to_level(data.get("role"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
region_v: str | None | object = Ellipsis
|
|
if "region_folder_id" in data:
|
|
region_v = str(data.get("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 = ""
|
|
|
|
if level_v is Ellipsis and region_v is Ellipsis:
|
|
raise HTTPException(status_code=400, detail="level_or_region_required")
|
|
|
|
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")
|
|
|
|
role_alias = level_to_role(level_v) if isinstance(level_v, float) else (
|
|
"" if level_v is None else None
|
|
)
|
|
if level_v is Ellipsis:
|
|
role_alias = None
|
|
|
|
samples = [
|
|
{
|
|
"fabric_node_id": n.id,
|
|
"name": n.name,
|
|
"ip": n.ip,
|
|
"level": n.level,
|
|
"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,
|
|
level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type]
|
|
role=role_alias,
|
|
region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type]
|
|
samples=samples,
|
|
)
|
|
|
|
now = _utcnow()
|
|
updated = 0
|
|
for n in matched_nodes:
|
|
if level_v is not Ellipsis:
|
|
apply_level_fields(n, level_v, source="manual") # type: ignore[arg-type]
|
|
if region_v is not Ellipsis:
|
|
n.region_folder_id = region_v or None # type: ignore[operator]
|
|
n.region_source = "manual"
|
|
n.updated_at = now
|
|
updated += 1
|
|
db.commit()
|
|
return FabricNodesBulkTagOut(
|
|
dry_run=False,
|
|
matched=len(matched_nodes),
|
|
updated=updated,
|
|
level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type]
|
|
role=role_alias,
|
|
region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type]
|
|
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)
|