Improve managed NE filters/validation and simplify the workbench UI.

Add column filters and clearer API error mapping for NE management, tighten port validation, and slim the workbench into reusable modules with cleaner styles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-13 00:26:58 +08:00
parent 3c9e0efd2b
commit 312f6f2f96
25 changed files with 1484 additions and 2016 deletions

View file

@ -42,6 +42,7 @@ from .ne_service import (
update_managed_ne,
)
from .models import ManagedNE
from .table_filters import TableFilterParams, table_filter_params
router = APIRouter(prefix="/v1/managed-ne", tags=["managed-ne"])
@ -63,6 +64,7 @@ def api_list_managed_ne(
connect_status: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db),
):
return list_managed_ne(
@ -72,6 +74,7 @@ def api_list_managed_ne(
connect_status=connect_status,
page=page,
page_size=page_size,
table_filters=table_filters,
)

View file

@ -17,7 +17,7 @@ class ManagedNeCreate(BaseModel):
vendor: str
device_type: str
ip_address: str
port: int = 22
port: int = Field(default=22, ge=1, le=65535)
protocol: str = "ssh"
username: str
password: str = ""
@ -27,7 +27,7 @@ class ManagedNeCreate(BaseModel):
hop_enabled: bool = False
hop_vendor: str = "zte"
hop_host: str = ""
hop_port: int = 22
hop_port: int = Field(default=22, ge=1, le=65535)
hop_protocol: str = "ssh"
hop_username: str = ""
hop_password: str = ""
@ -63,7 +63,7 @@ class ManagedNeUpdate(BaseModel):
vendor: str | None = None
device_type: str | None = None
ip_address: str | None = None
port: int | None = None
port: int | None = Field(default=None, ge=1, le=65535)
protocol: str | None = None
username: str | None = None
password: str | None = None
@ -73,7 +73,7 @@ class ManagedNeUpdate(BaseModel):
hop_enabled: bool | None = None
hop_vendor: str | None = None
hop_host: str | None = None
hop_port: int | None = None
hop_port: int | None = Field(default=None, ge=1, le=65535)
hop_protocol: str | None = None
hop_username: str | None = None
hop_password: str | None = None
@ -194,7 +194,7 @@ class HopProxyConfig(BaseModel):
hop_vendor: str = "zte"
hop_host: str
hop_port: int = 22
hop_port: int = Field(default=22, ge=1, le=65535)
hop_protocol: str = "ssh"
hop_username: str
hop_password: str = ""

View file

@ -60,6 +60,63 @@ def _normalize_ip(ip: str) -> str:
return str(ip or "").strip()
_HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,62})(?:\.[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,62}))*\.?$")
def _is_valid_mgmt_address(ip: str) -> bool:
"""Accept an IPv4/IPv6 literal or a DNS hostname (no spaces, ports or schemes)."""
import ipaddress
text = str(ip or "").strip()
if not text:
return False
try:
ipaddress.ip_address(text)
return True
except ValueError:
pass
# Dotted-quad lookalikes (e.g. 10.0.0.300) are typos, not hostnames.
if re.fullmatch(r"[0-9.]+", text):
return False
return bool(_HOSTNAME_RE.match(text))
def _require_mgmt_address(ip: str) -> str:
text = _normalize_ip(ip)
if not text:
raise HTTPException(status_code=400, detail="ip_address_required")
if not _is_valid_mgmt_address(text):
raise HTTPException(status_code=400, detail="invalid_ip_address")
return text
CONNECT_FIELDS = (
"ip_address", "port", "protocol", "username", "password_enc", "device_type",
"hop_enabled", "hop_vendor", "hop_host", "hop_port", "hop_protocol", "hop_username",
"hop_password_enc", "hop_command_template", "hop_vrf", "hop_target_auth_mode",
"hop_enter_system_view",
)
def connect_snapshot(row: ManagedNE) -> tuple[Any, ...]:
return tuple(getattr(row, f, None) for f in CONNECT_FIELDS)
def reset_connect_status_if_changed(row: ManagedNE, before: tuple[Any, ...]) -> None:
"""A previous pass/fail no longer describes the device once login settings change."""
if connect_snapshot(row) != before and str(row.connect_status or "") != "unknown":
row.connect_status = "unknown"
row.connect_message = ""
def hop_password_optional(hop_vendor: str, hop_target_auth_mode: str) -> bool:
"""Only a bastion that manages target auth may run without a stored hop password."""
return (
_normalize_hop_vendor(hop_vendor) == "bastion"
and _normalize_hop_target_auth_mode(hop_target_auth_mode) == "bastion_managed"
)
def _normalize_protocol(protocol: str) -> str:
p = str(protocol or "ssh").strip().lower()
return p if p in ("ssh", "telnet") else "ssh"
@ -213,9 +270,8 @@ def _apply_hop_update(row: ManagedNE, data: dict[str, Any]) -> None:
raise HTTPException(status_code=400, detail="hop_host_required")
if not str(row.hop_username or "").strip():
raise HTTPException(status_code=400, detail="hop_username_required")
if (
not str(row.hop_password_enc or "").strip()
and _normalize_hop_target_auth_mode(row.hop_target_auth_mode) != "bastion_managed"
if not str(row.hop_password_enc or "").strip() and not hop_password_optional(
str(row.hop_vendor or ""), str(row.hop_target_auth_mode or "")
):
raise HTTPException(status_code=400, detail="hop_password_required")

View file

@ -5,10 +5,10 @@ from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy import false, func, literal, or_
from sqlalchemy.orm import Session
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS, WEBCRT_DEVICE_TYPES, is_placeholder_ne_source
from .device_types import SUPPORTED_VENDORS, WEBCRT_DEVICE_TYPES, is_placeholder_ne_source
from .models import ManagedNE
from .ne_crypto import encrypt_secret
from .ne_hop_templates import default_bastion_username_template, default_hop_command_template
@ -24,8 +24,11 @@ from .ne_service_common import (
_apply_hop_update,
_normalize_hop_target_auth_mode,
_normalize_hop_vendor,
_normalize_ip,
_normalize_protocol,
_require_mgmt_address,
connect_snapshot,
hop_password_optional,
reset_connect_status_if_changed,
_normalize_saved_hop_endpoint,
_normalize_vendor,
_now,
@ -33,16 +36,43 @@ from .ne_service_common import (
_validate_hop_on_create,
row_to_out,
)
from .table_filters import TableFilterParams, filter_query
from .ne_exec_guard import (
EXEC_POLICY_READONLY,
allows_open_exec_policy,
require_exec_policy_writable,
)
# Inventory create stays strict; updates must also accept WebCRT/LLDP placeholder types
# (generic/linux) so operators can open the form and promote them to zte_zxros etc.
# Create and update share the form's device-type list, which includes linux/generic
# (Linux hosts and WebCRT/LLDP placeholders promoted to inventory).
_ALLOWED_UPDATE_DEVICE_TYPES = frozenset(WEBCRT_DEVICE_TYPES)
def managed_ne_filter_expressions() -> dict[str, Any]:
"""Column filter fields for the NE management table (tags handled separately)."""
return {
"name": func.coalesce(func.nullif(ManagedNE.name, ""), ManagedNE.ip_address),
"vendor": ManagedNE.vendor,
"deviceType": ManagedNE.device_type,
"source": func.coalesce(func.nullif(ManagedNE.source, ""), "manual"),
"ip": ManagedNE.ip_address,
"username": ManagedNE.username,
"connectStatus": ManagedNE.connect_status,
}
def _filter_by_tags(stmt: Any, value: dict[str, Any]) -> Any:
"""Tags are space-separated; a row matches when any selected tag is present ("" = untagged)."""
tags_col = func.coalesce(ManagedNE.tags, "")
padded = literal(" ") + tags_col + literal(" ")
conds = []
for tag in value["values"]:
tag = str(tag).strip()
escaped = tag.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conds.append(func.trim(tags_col) == "" if not tag else padded.like(f"% {escaped} %", escape="\\"))
matched = or_(*conds) if conds else false()
return stmt.filter(matched if value["mode"] == "include" else ~matched)
def list_managed_ne(
db: Session,
*,
@ -51,6 +81,7 @@ def list_managed_ne(
connect_status: str | None = None,
page: int = 1,
page_size: int = 50,
table_filters: TableFilterParams | None = None,
) -> dict[str, Any]:
stmt = db.query(ManagedNE)
kw = str(keyword or "").strip()
@ -72,6 +103,22 @@ def list_managed_ne(
stmt = stmt.filter(ManagedNE.vendor == v)
if cs:
stmt = stmt.filter(ManagedNE.connect_status == cs)
params = table_filters or TableFilterParams()
tags_filter = params.filters.get("tags")
if tags_filter and params.field != "tags":
stmt = _filter_by_tags(stmt, tags_filter)
if params.field == "tags":
stmt, _ = filter_query(stmt, managed_ne_filter_expressions(), TableFilterParams(
{k: v for k, v in params.filters.items() if k != "tags"}))
options: set[str] = set()
for (tags_val,) in stmt.with_entities(ManagedNE.tags).all():
parts = str(tags_val or "").split()
options.update(parts if parts else [""])
return {"field": "tags", "options": sorted(options)}
stmt, options = filter_query(stmt, managed_ne_filter_expressions(), TableFilterParams(
{k: v for k, v in params.filters.items() if k != "tags"}, params.field))
if options is not None:
return options
total = int(stmt.count())
rows = (
stmt.order_by(ManagedNE.updated_at.desc())
@ -97,10 +144,8 @@ def get_managed_ne(db: Session, ne_id: str) -> ManagedNeOut:
def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut:
_require_crypto()
_validate_hop_on_create(body)
ip = _normalize_ip(body.ip_address)
if not ip:
raise HTTPException(status_code=400, detail="ip_address_required")
if body.device_type not in SUPPORTED_DEVICE_TYPES:
ip = _require_mgmt_address(body.ip_address)
if body.device_type not in _ALLOWED_UPDATE_DEVICE_TYPES:
raise HTTPException(status_code=400, detail="unsupported_device_type")
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
if existing:
@ -140,10 +185,9 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
if not row:
raise HTTPException(status_code=404, detail="managed_ne_not_found")
data = body.model_dump(exclude_unset=True)
before = connect_snapshot(row)
if "ip_address" in data:
ip = _normalize_ip(data["ip_address"])
if not ip:
raise HTTPException(status_code=400, detail="ip_address_required")
ip = _require_mgmt_address(data["ip_address"])
other = db.query(ManagedNE).filter(ManagedNE.ip_address == ip, ManagedNE.id != ne_id).first()
if other:
raise HTTPException(status_code=400, detail="ip_address_exists")
@ -157,7 +201,7 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
v = str(data["vendor"] or "").strip()
row.vendor = v if v in SUPPORTED_VENDORS else "Other"
if "name" in data:
row.name = str(data["name"] or "").strip()
row.name = str(data["name"] or "").strip() or str(row.ip_address or "")
if "port" in data and data["port"] is not None:
row.port = int(data["port"])
if "protocol" in data and data["protocol"] is not None:
@ -198,6 +242,7 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
if "ip_address" in data and str(row.ip_address or "").strip():
if is_placeholder_ne_source(row.source):
row.source = ""
reset_connect_status_if_changed(row, before)
row.updated_at = _now()
# Keep linked fabric node identity in sync (name / IP / vendor).
if any(k in data for k in ("ip_address", "name", "vendor", "device_type")):
@ -225,8 +270,6 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d
if not hop_user:
raise HTTPException(status_code=400, detail="hop_username_required")
hop_auth_mode = _normalize_hop_target_auth_mode(hop.hop_target_auth_mode)
if not hop_pass and hop_auth_mode != "bastion_managed":
raise HTTPException(status_code=400, detail="hop_password_required")
template = str(hop.hop_command_template or "").strip()
if hop_vendor == "bastion" and not template:
@ -243,9 +286,15 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d
missing = [x for x in ne_ids if x not in found_ids]
if missing:
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
# Blank password keeps each NE's stored hop password; NEs without one cannot hop.
if not hop_pass and not hop_password_optional(hop_vendor, hop_auth_mode):
lacking = [str(r.name or r.ip_address) for r in rows if not str(r.hop_password_enc or "").strip()]
if lacking:
raise HTTPException(status_code=400, detail=f"hop_password_required: {','.join(lacking[:5])}")
now = _now()
for row in rows:
before = connect_snapshot(row)
row.hop_enabled = True
row.hop_vendor = hop_vendor
row.hop_host = hop_host
@ -258,6 +307,7 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d
row.hop_vrf = str(hop.hop_vrf or "").strip()
row.hop_target_auth_mode = hop_auth_mode
row.hop_enter_system_view = bool(getattr(hop, "hop_enter_system_view", False))
reset_connect_status_if_changed(row, before)
row.updated_at = now
db.commit()
return {"ok": True, "updated": len(rows)}
@ -283,10 +333,12 @@ def batch_apply_account(db: Session, ids: list[str], account: BatchAccountConfig
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
now = _now()
for row in rows:
before = connect_snapshot(row)
if user:
row.username = user
if pwd:
row.password_enc = pwd_enc
reset_connect_status_if_changed(row, before)
row.updated_at = now
db.commit()
return {"ok": True, "updated": len(rows)}

View file

@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from .models import ManagedNE, UmeInventoryNE
from .ne_crypto import CredentialCryptoError, encrypt_secret
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS, is_placeholder_ne_source
from .ne_schemas import (
ImportFailure,
ImportResult,
@ -23,6 +23,9 @@ from .ne_service_common import (
UME_SYNC_TAG,
_import_cell_str,
_infer_managed_ne_type_vendor,
_is_valid_mgmt_address,
connect_snapshot,
reset_connect_status_if_changed,
_merge_tags,
_normalize_ip,
_normalize_protocol,
@ -188,25 +191,38 @@ def import_managed_ne(db: Session, content: bytes, filename: str) -> ImportResul
failed: list[ImportFailure] = []
for idx, row in df.iterrows():
row_no = int(idx) + 2
# Savepoint per row: a failing row must not leave a half-written NE behind.
savepoint = db.begin_nested()
try:
ip = _normalize_ip(_import_cell_str(row.get("ip", "")))
if not ip:
failed.append(ImportFailure(row=row_no, reason="ip_required"))
savepoint.rollback()
continue
if not _is_valid_mgmt_address(ip):
failed.append(ImportFailure(row=row_no, reason="invalid_ip_address"))
savepoint.rollback()
continue
device_type = _import_cell_str(row.get("device_type", ""))
if device_type not in SUPPORTED_DEVICE_TYPES:
failed.append(ImportFailure(row=row_no, reason="unsupported_device_type"))
savepoint.rollback()
continue
username = _import_cell_str(row.get("username", ""))
password = _import_cell_str(row.get("password", ""))
if not username:
failed.append(ImportFailure(row=row_no, reason="username_required"))
savepoint.rollback()
continue
port_raw = row.get("port", 22)
try:
port = int(port_raw)
except (TypeError, ValueError):
port = 22
if not 1 <= port <= 65535:
failed.append(ImportFailure(row=row_no, reason="invalid_port"))
savepoint.rollback()
continue
protocol = _normalize_protocol(str(row.get("protocol", "ssh")))
display_name = _import_cell_str(row.get("name", "")) or ip
vendor_raw = _import_cell_str(row.get("vendor", "")) or "Other"
@ -217,29 +233,43 @@ def import_managed_ne(db: Session, content: bytes, filename: str) -> ImportResul
break
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
now = _now()
is_new = existing is None
if existing is None:
existing = ManagedNE(ip_address=ip, created_at=now)
existing = ManagedNE(ip_address=ip, created_at=now, connect_status="unknown")
db.add(existing)
inserted += 1
else:
updated += 1
before = connect_snapshot(existing)
existing.name = display_name
existing.vendor = vendor
existing.device_type = device_type
existing.port = port
existing.protocol = protocol
existing.username = username
existing.password_enc = encrypt_secret(password) if password else ""
# Blank password on an existing NE keeps the stored one (template row 2 is blank).
if password:
existing.password_enc = encrypt_secret(password)
elif is_new:
existing.password_enc = ""
tags_val = _import_cell_str(row.get("tags", ""))
remark_val = _import_cell_str(row.get("remark", ""))
if tags_val:
existing.tags = tags_val
if remark_val:
existing.remark = remark_val
if is_placeholder_ne_source(existing.source):
existing.source = ""
if not is_new:
reset_connect_status_if_changed(existing, before)
existing.updated_at = now
savepoint.commit()
if is_new:
inserted += 1
else:
updated += 1
except CredentialCryptoError as exc:
savepoint.rollback()
failed.append(ImportFailure(row=row_no, reason=str(exc)))
except Exception as exc:
savepoint.rollback()
failed.append(ImportFailure(row=row_no, reason=str(exc)[:200]))
db.commit()
return ImportResult(inserted=inserted, updated=updated, failed=failed)