mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 06:03:18 +08:00
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:
parent
3c9e0efd2b
commit
312f6f2f96
25 changed files with 1484 additions and 2016 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue