diff --git a/.gitignore b/.gitignore index 925aed9..c72cfd0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ dist/ scripts/.run/ # Local one-off probes / staging dumps (not for commit) +artifacts/ scripts/_* scripts/archive/ packages/netx-topology-mcp/tests/_* diff --git a/netx_api/managed_ne_router.py b/netx_api/managed_ne_router.py index a90bf62..6c656d2 100644 --- a/netx_api/managed_ne_router.py +++ b/netx_api/managed_ne_router.py @@ -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, ) diff --git a/netx_api/ne_schemas.py b/netx_api/ne_schemas.py index 0611390..5bc2813 100644 --- a/netx_api/ne_schemas.py +++ b/netx_api/ne_schemas.py @@ -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 = "" diff --git a/netx_api/ne_service_common.py b/netx_api/ne_service_common.py index 9e74f0b..ed9ed1f 100644 --- a/netx_api/ne_service_common.py +++ b/netx_api/ne_service_common.py @@ -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") diff --git a/netx_api/ne_service_crud.py b/netx_api/ne_service_crud.py index e8b253c..5b168e7 100644 --- a/netx_api/ne_service_crud.py +++ b/netx_api/ne_service_crud.py @@ -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)} diff --git a/netx_api/ne_service_import.py b/netx_api/ne_service_import.py index 22415c8..7bad1bc 100644 --- a/netx_api/ne_service_import.py +++ b/netx_api/ne_service_import.py @@ -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) diff --git a/tests/test_managed_ne_page.py b/tests/test_managed_ne_page.py new file mode 100644 index 0000000..3b82bc1 --- /dev/null +++ b/tests/test_managed_ne_page.py @@ -0,0 +1,205 @@ +"""NE management page: column filters, create/edit validation, batch and import fixes.""" +import json +from unittest.mock import patch + +import pytest +from cryptography.fernet import Fernet +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from netx_api.config import settings +from netx_api.db import Base, get_db +from netx_api.managed_ne_router import router +from netx_api.models import ManagedNE +from netx_api.ne_crypto import decrypt_secret, encrypt_secret +from netx_api.ne_service import import_managed_ne + + +@pytest.fixture +def engine(): + orig = settings.credential_secret_key + settings.credential_secret_key = Fernet.generate_key().decode() + eng = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(eng) + with Session(eng) as db: + db.add_all([ + ManagedNE(id="m1", name="PE-1", ip_address="10.0.0.1", vendor="ZTE", device_type="zte_zxros", + username="u1", tags="core edge", connect_status="pass", source=""), + ManagedNE(id="m2", name="", ip_address="10.0.0.2", vendor="Huawei", device_type="huawei", + username="u2", tags="edge", connect_status="fail", source="webcrt"), + ManagedNE(id="m3", name="SW-3", ip_address="10.0.0.3", vendor="Cisco", device_type="cisco_ios", + username="u3", tags="", connect_status="unknown", source="lldp"), + ManagedNE(id="m4", name="Lab_1", ip_address="10.0.0.4", vendor="Cisco", device_type="cisco_ios", + username="u4", tags="lab%x", connect_status="pass"), + ]) + db.commit() + yield eng + eng.dispose() + settings.credential_secret_key = orig + + +@pytest.fixture +def client(engine): + app = FastAPI() + app.include_router(router) + + def database(): + with Session(engine) as db: + yield db + + app.dependency_overrides[get_db] = database + with patch("netx_api.auth_deps.settings.auth_enabled", False), TestClient(app) as test_client: + yield test_client + + +def _list(client, filters=None, **params): + if filters: + params["column_filters"] = json.dumps(filters) + r = client.get("/v1/managed-ne", params=params) + assert r.status_code == 200, r.text + return r.json() + + +def _ids(body): + return sorted(x["id"] for x in body["items"]) + + +def test_column_filter_options_cover_full_dataset(client): + opts = _list(client, filter_field="vendor", page_size=1) + assert opts == {"field": "vendor", "options": ["Cisco", "Huawei", "ZTE"]} + # Blank names fall back to the IP shown in the table; blank source shows as manual. + assert "10.0.0.2" in _list(client, filter_field="name")["options"] + assert _list(client, filter_field="source")["options"] == ["lldp", "manual", "webcrt"] + + +def test_column_filters_apply_before_pagination(client): + body = _list(client, {"connectStatus": {"mode": "include", "values": ["pass"]}}, page_size=1) + assert body["total"] == 2 + body = _list(client, {"vendor": {"mode": "exclude", "values": ["Cisco"]}}) + assert _ids(body) == ["m1", "m2"] + body = _list(client, {"source": {"mode": "include", "values": ["manual"]}}) + assert _ids(body) == ["m1", "m4"] + + +def test_tags_filter_matches_individual_tags(client): + assert _list(client, filter_field="tags")["options"] == ["", "core", "edge", "lab%x"] + assert _ids(_list(client, {"tags": {"mode": "include", "values": ["edge"]}})) == ["m1", "m2"] + assert _ids(_list(client, {"tags": {"mode": "include", "values": [""]}})) == ["m3"] + assert _ids(_list(client, {"tags": {"mode": "exclude", "values": ["edge", ""]}})) == ["m4"] + # LIKE wildcards inside a tag are literal. + assert _ids(_list(client, {"tags": {"mode": "include", "values": ["lab%x"]}})) == ["m4"] + # Tag options respect the other columns' filters. + opts = _list(client, {"vendor": {"mode": "include", "values": ["Huawei"]}}, filter_field="tags") + assert opts["options"] == ["edge"] + + +def test_invalid_filter_field_rejected(client): + r = client.get("/v1/managed-ne", params={"filter_field": "password_enc"}) + assert r.status_code == 400 + + +@pytest.mark.parametrize("ip", ["10.0.0.300", "a b", "http://x", "10.0.0.9:22"]) +def test_create_rejects_bad_address(client, ip): + r = client.post("/v1/managed-ne", json={"vendor": "ZTE", "device_type": "zte_zxros", + "ip_address": ip, "username": "u"}) + assert r.status_code == 400 and r.json()["detail"] == "invalid_ip_address" + + +@pytest.mark.parametrize("ip", ["10.0.0.9", "2001:db8::1", "pe-01.example.net"]) +def test_create_accepts_ip_or_hostname(client, ip): + r = client.post("/v1/managed-ne", json={"vendor": "ZTE", "device_type": "zte_zxros", + "ip_address": ip, "username": "u"}) + assert r.status_code == 200, r.text + + +@pytest.mark.parametrize("device_type", ["linux", "generic"]) +def test_create_accepts_form_device_types(client, device_type): + r = client.post("/v1/managed-ne", json={"vendor": "Other", "device_type": device_type, + "ip_address": "10.0.0.9", "username": "u"}) + assert r.status_code == 200, r.text + + +@pytest.mark.parametrize("port", [0, 65536, -1]) +def test_create_rejects_bad_port(client, port): + r = client.post("/v1/managed-ne", json={"vendor": "ZTE", "device_type": "zte_zxros", + "ip_address": "10.0.0.9", "username": "u", "port": port}) + assert r.status_code == 422 + + +def test_update_resets_status_when_login_changes_and_keeps_name(client): + r = client.patch("/v1/managed-ne/m1", json={"remark": "note"}) + assert r.json()["connect_status"] == "pass" + r = client.patch("/v1/managed-ne/m1", json={"port": 2222}) + assert r.json()["connect_status"] == "unknown" + r = client.patch("/v1/managed-ne/m1", json={"name": " "}) + assert r.json()["name"] == "10.0.0.1" + + +def test_batch_hop_requires_password_for_ne_without_one(client, engine): + hop = {"hop_vendor": "zte", "hop_host": "1.1.1.1", "hop_username": "jump"} + r = client.post("/v1/managed-ne/batch-hop", json={"ids": ["m1"], "hop": hop}) + assert r.status_code == 400 and r.json()["detail"].startswith("hop_password_required") + with Session(engine) as db: + db.get(ManagedNE, "m1").hop_password_enc = encrypt_secret("old") + db.commit() + # Blank password keeps the stored one. + r = client.post("/v1/managed-ne/batch-hop", json={"ids": ["m1"], "hop": hop}) + assert r.status_code == 200, r.text + with Session(engine) as db: + row = db.get(ManagedNE, "m1") + assert decrypt_secret(row.hop_password_enc) == "old" + assert row.connect_status == "unknown" + # A bastion that manages target auth may run without a hop password. + bastion = {**hop, "hop_vendor": "bastion", "hop_target_auth_mode": "bastion_managed"} + r = client.post("/v1/managed-ne/batch-hop", json={"ids": ["m2"], "hop": bastion}) + assert r.status_code == 200, r.text + + +def test_batch_account_resets_status(client, engine): + r = client.post("/v1/managed-ne/batch-account", json={"ids": ["m1"], "account": {"username": "new"}}) + assert r.status_code == 200 + with Session(engine) as db: + assert db.get(ManagedNE, "m1").connect_status == "unknown" + + +def test_import_keeps_password_skips_bad_rows_and_promotes_placeholder(engine): + db = sessionmaker(bind=engine)() + try: + row = db.get(ManagedNE, "m1") + row.password_enc = encrypt_secret("secret") + db.commit() + csv = ( + "device_type,ip,username,password,port,protocol,name,vendor,tags\n" + "zte_zxros,10.0.0.1,u1,,22,ssh,PE-1,ZTE,core\n" # blank password keeps stored one + "zte_zxros,10.0.0.3,u3,,22,ssh,SW-3,ZTE,\n" # LLDP placeholder becomes manual + "zte_zxros,10.9.9.9,u,,70000,ssh,BAD,ZTE,\n" # bad port + "zte_zxros,999.1.1.1,u,,22,ssh,BAD,ZTE,\n" # bad ip + "zte_zxros,10.9.9.8,u,,22,ssh,NEW,ZTE,\n" + ).encode("utf-8") + result = import_managed_ne(db, csv, "devices.csv") + assert (result.inserted, result.updated) == (1, 2) + assert [(f.row, f.reason) for f in result.failed] == [(4, "invalid_port"), (5, "invalid_ip_address")] + db.expire_all() + assert decrypt_secret(db.get(ManagedNE, "m1").password_enc) == "secret" + assert db.get(ManagedNE, "m3").source == "" + assert db.query(ManagedNE).filter(ManagedNE.ip_address == "10.9.9.9").count() == 0 + finally: + db.close() + + +def test_import_failure_mid_row_leaves_no_partial_ne(engine): + db = sessionmaker(bind=engine)() + try: + csv = ( + "device_type,ip,username,password,port,protocol,name,vendor\n" + "zte_zxros,10.7.7.7,u,pw,22,ssh,X,ZTE\n" + ).encode("utf-8") + with patch("netx_api.ne_service_import.encrypt_secret", side_effect=RuntimeError("boom")): + result = import_managed_ne(db, csv, "devices.csv") + assert (result.inserted, result.updated, len(result.failed)) == (0, 0, 1) + assert db.query(ManagedNE).filter(ManagedNE.ip_address == "10.7.7.7").count() == 0 + finally: + db.close() diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index dc84792..e3c4205 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -22,8 +22,8 @@ export const queryKeys = { managedNeMeta: ["managedNeMeta"] as const, managedNeStats: ["managedNeStats"] as const, managedNeAll: ["managedNe"] as const, - managedNe: (keyword: string, vendor: string, connectStatus: string, page: number, pageSize: number) => - ["managedNe", keyword, vendor, connectStatus, page, pageSize] as const, + managedNe: (columnFilters: string, page: number, pageSize: number) => + ["managedNe", columnFilters, page, pageSize] as const, collectionEligibleNeAll: ["collectionEligibleNe"] as const, collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const, neCollectionsAll: ["neCollections"] as const, diff --git a/web/src/features/managed-ne/NePage.tsx b/web/src/features/managed-ne/NePage.tsx index 62a9b8e..ea6fd4e 100644 --- a/web/src/features/managed-ne/NePage.tsx +++ b/web/src/features/managed-ne/NePage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react"; +import { Button, Input, Label, Modal, TextField } from "@heroui/react"; import { batchApplyAccountManagedNe, batchApplyHopManagedNe, @@ -20,20 +20,23 @@ import { type ManagedNeStats, } from "../../services/api"; import { HelpHint } from "../../components/HelpHint"; +import { ListPager } from "../../components/ListPager"; import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./components/HopProxyFields"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; import { queryKeys } from "../../constants/queryKeys"; +import { useTableColumnFilters } from "../../hooks/useTableColumnFilters"; import { useI18n } from "../../i18n"; import { useToast } from "../../hooks/useToast"; import { useAuth } from "../auth/AuthContext"; -import type { ManagedNeItem } from "../../types"; +import type { ManagedNeImportResult, ManagedNeItem } from "../../types"; import { pageCount } from "../../utils/display"; import { formatSystemTime } from "../../utils/time"; import { openOrFocusModule } from "../../utils/moduleWindows"; import { ManagedNeFormDialog } from "./components/ManagedNeFormDialog"; import { ManagedNeConnectDetailDialog } from "./components/ManagedNeConnectDetailDialog"; import { connectStatusClass } from "./lib/connectStatus"; +import { managedNeErrorText } from "./lib/errors"; import { deviceTypeForVendor, emptyManagedNeForm, @@ -44,16 +47,36 @@ import { /** Hide UME→managed sync/delete controls until needed again. APIs remain available. */ const SHOW_UME_MANAGED_SYNC = false; +const MANAGED_NE_ENDPOINT = "/v1/managed-ne"; +const CONNECT_STATUSES = ["pass", "fail", "testing", "unknown"] as const; + type AccountState = { username: string; password: string; }; +type BulkTagAction = "proxy" | "test" | "account"; + const emptyAccount = (): AccountState => ({ username: "", password: "", }); +function hopPayload(hop: HopProxyFieldsState) { + return { + hop_vendor: hop.hop_vendor, + hop_host: hop.hop_host.trim(), + hop_port: hop.hop_port, + hop_protocol: hop.hop_protocol, + hop_username: hop.hop_username.trim(), + hop_password: hop.hop_password, + hop_command_template: hop.hop_command_template.trim(), + hop_vrf: hop.hop_vrf.trim(), + hop_target_auth_mode: hop.hop_target_auth_mode, + hop_enter_system_view: hop.hop_enter_system_view, + }; +} + export function NePage() { const { t } = useI18n(); const { showOk, showError } = useToast(); @@ -63,10 +86,9 @@ export function NePage() { const importRef = useRef(null); const [searchParams, setSearchParams] = useSearchParams(); const deepLinkHandled = useRef(""); + const errText = (err: unknown) => managedNeErrorText(err, t); + const fail = (err: unknown) => showError(errText(err)); - const [keyword, setKeyword] = useState(""); - const [vendorFilter, setVendorFilter] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [selected, setSelected] = useState([]); @@ -78,12 +100,21 @@ export function NePage() { const [batchHop, setBatchHop] = useState(emptyHopProxyFields); const [batchAccount, setBatchAccount] = useState(emptyAccount); const [connectDetailRow, setConnectDetailRow] = useState(null); + const [importResult, setImportResult] = useState(null); // --- bulk-by-tag dialog --- const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false); - const [bulkTagAction, setBulkTagAction] = useState<"proxy" | "test" | "account">("proxy"); + const [bulkTagAction, setBulkTagAction] = useState("proxy"); const [bulkTagSelected, setBulkTagSelected] = useState(""); // "" = all, "__no_tag__" = no-tag NEs const [bulkAccount, setBulkAccount] = useState(emptyAccount); + const [bulkHop, setBulkHop] = useState(() => emptyHopProxyFields()); + const [resolvingIds, setResolvingIds] = useState(false); + + // A filter change hides rows, so drop the selection rather than act on rows the user cannot see. + const columns = useTableColumnFilters(MANAGED_NE_ENDPOINT, () => { + setPage(1); + setSelected([]); + }); const metaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, @@ -98,14 +129,15 @@ export function NePage() { }); const listQuery = useQuery({ - queryKey: queryKeys.managedNe(keyword, vendorFilter, statusFilter, page, pageSize), + queryKey: queryKeys.managedNe(columns.key, page, pageSize), queryFn: () => fetchManagedNe({ - keyword, - vendor: vendorFilter, - connectStatus: statusFilter, + keyword: "", + vendor: "", + connectStatus: "", page, pageSize, + columnFilters: columns.filters, }), refetchInterval: (q) => { const items = q.state.data?.items || []; @@ -129,17 +161,26 @@ export function NePage() { const total = listQuery.data?.total ?? 0; const pages = pageCount(total, pageSize); - const perPage = (n: number) => t("common.perPage", { n }); + // Deleting the last rows of the last page must not strand the user on an empty page. + if (listQuery.data && page > pages) setPage(pages); - const invalidateList = () => queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll }); + /** List, stats, column filter options and the Network devices view all derive from managed_ne. */ + const invalidateList = () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll }), + queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }), + queryClient.invalidateQueries({ queryKey: ["tableFilterOptions", MANAGED_NE_ENDPOINT] }), + queryClient.invalidateQueries({ queryKey: queryKeys.cliTargetsAll }), + ]); const deleteMutation = useMutation({ mutationFn: deleteManagedNe, - onSuccess: async () => { + onSuccess: async (_res, id) => { + setSelected((prev) => prev.filter((x) => x !== id)); showOk(t("managedNe.form.deleted")); await invalidateList(); }, - onError: (err) => showError(String(err)), + onError: fail, }); const connectMutation = useMutation({ @@ -148,7 +189,7 @@ export function NePage() { showOk(t("managedNe.connect.submitted", { n: res.submitted })); await invalidateList(); }, - onError: (err) => showError(String(err)), + onError: fail, }); const batchDeleteMutation = useMutation({ @@ -158,30 +199,18 @@ export function NePage() { showOk(t("managedNe.batchDeleteDone", { n: res.deleted })); await invalidateList(); }, - onError: (err) => showError(String(err)), + onError: fail, }); const batchHopMutation = useMutation({ - mutationFn: () => - batchApplyHopManagedNe(selected, { - hop_vendor: batchHop.hop_vendor, - hop_host: batchHop.hop_host.trim(), - hop_port: batchHop.hop_port, - hop_protocol: batchHop.hop_protocol, - hop_username: batchHop.hop_username.trim(), - hop_password: batchHop.hop_password, - hop_command_template: batchHop.hop_command_template.trim(), - hop_vrf: batchHop.hop_vrf.trim(), - hop_target_auth_mode: batchHop.hop_target_auth_mode, - hop_enter_system_view: batchHop.hop_enter_system_view, - }), + mutationFn: () => batchApplyHopManagedNe(selected, hopPayload(batchHop)), onSuccess: async (res) => { setBatchHopOpen(false); setBatchHop(emptyHopProxyFields()); showOk(t("managedNe.hop.batchDone", { n: res.updated })); await invalidateList(); }, - onError: (err) => showError(String(err)), + onError: fail, }); const batchAccountMutation = useMutation({ @@ -196,7 +225,7 @@ export function NePage() { showOk(t("managedNe.account.batchDone", { n: res.updated })); await invalidateList(); }, - onError: (err) => showError(String(err)), + onError: fail, }); const umeSyncMutation = useMutation({ @@ -210,24 +239,18 @@ export function NePage() { total: res.total_inventory, }), ); - await Promise.all([ - invalidateList(), - queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }), - ]); + await invalidateList(); }, - onError: (err) => showError(String(err)), + onError: fail, }); const umeDeleteMutation = useMutation({ mutationFn: deleteUmeManagedNe, onSuccess: async (res) => { showOk(t("managedNe.umeSync.deletedDone", { n: res.deleted })); - await Promise.all([ - invalidateList(), - queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }), - ]); + await invalidateList(); }, - onError: (err) => showError(String(err)), + onError: fail, }); const importMutation = useMutation({ @@ -240,65 +263,45 @@ export function NePage() { failed: res.failed.length, }), ); + if (res.failed.length) setImportResult(res); await invalidateList(); }, - onError: (err) => showError(String(err)), + onError: fail, }); - // Bulk-by-tag: fetch ids then run proxy/test - const [bulkHop, setBulkHop] = useState(() => emptyHopProxyFields()); const bulkByTagMutation = useMutation({ - mutationFn: async (params: { action: "proxy" | "test" | "account"; tag: string }) => { - const apiTag = params.tag === "" ? null : params.tag; - const { ids } = await fetchIdsByTag(apiTag); - if (ids.length === 0) throw new Error(t("managedNe.stats.loadingIds")); - if (!window.confirm(t("managedNe.stats.confirm", { n: ids.length }))) return null; - if (params.action === "test") { + mutationFn: async (params: { action: BulkTagAction; ids: string[] }) => { + const { action, ids } = params; + if (action === "test") { const res = await connectTestManagedNe(ids); - return { type: "test" as const, n: res.submitted }; + return { type: action, n: res.submitted }; } - if (params.action === "account") { + if (action === "account") { const res = await batchApplyAccountManagedNe(ids, { username: bulkAccount.username.trim(), password: bulkAccount.password, }); - return { type: "account" as const, n: res.updated }; + return { type: action, n: res.updated }; } - const res = await batchApplyHopManagedNe(ids, { - hop_vendor: bulkHop.hop_vendor, - hop_host: bulkHop.hop_host.trim(), - hop_port: bulkHop.hop_port, - hop_protocol: bulkHop.hop_protocol, - hop_username: bulkHop.hop_username.trim(), - hop_password: bulkHop.hop_password, - hop_command_template: bulkHop.hop_command_template.trim(), - hop_vrf: bulkHop.hop_vrf.trim(), - hop_target_auth_mode: bulkHop.hop_target_auth_mode, - hop_enter_system_view: bulkHop.hop_enter_system_view, - }); - return { type: "proxy" as const, n: res.updated }; + const res = await batchApplyHopManagedNe(ids, hopPayload(bulkHop)); + return { type: action, n: res.updated }; }, onSuccess: async (res) => { - if (!res) return; setBulkTagModalOpen(false); if (res.type === "test") showOk(t("managedNe.stats.testDone", { n: res.n })); else if (res.type === "account") showOk(t("managedNe.account.batchDone", { n: res.n })); else showOk(t("managedNe.stats.proxyDone", { n: res.n })); - await Promise.all([ - invalidateList(), - queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }), - ]); + await invalidateList(); }, - onError: (err) => showError(String(err)), + onError: fail, }); - const vendors = metaQuery.data?.vendors ?? []; const credsOk = metaQuery.data?.credentials_configured ?? false; - - const allSelected = useMemo(() => { - const items = listQuery.data?.items ?? []; - return items.length > 0 && items.every((x) => selected.includes(x.id)); - }, [listQuery.data?.items, selected]); + const listItems = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]); + const pageIds = listItems.map((x) => x.id); + const selectedOnPage = pageIds.filter((id) => selected.includes(id)).length; + const allSelected = pageIds.length > 0 && selectedOnPage === pageIds.length; + const someSelected = selectedOnPage > 0 && !allSelected; const openCreate = () => { setEditing(null); @@ -344,7 +347,7 @@ export function NePage() { const row = await fetchManagedNeById(neId); openEdit(row); } catch (err) { - showError(String(err)); + showError(managedNeErrorText(err, t)); } finally { clearDeepLink(); } @@ -366,20 +369,18 @@ export function NePage() { }); setModalOpen(true); clearDeepLink(); - }, [searchParams, setSearchParams, showError]); + }, [searchParams, setSearchParams, showError, t]); const toggleSelect = (id: string) => { setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; const toggleSelectAll = () => { - const items = listQuery.data?.items ?? []; if (allSelected) { - const pageIds = new Set(items.map((x) => x.id)); - setSelected((prev) => prev.filter((id) => !pageIds.has(id))); + const ids = new Set(pageIds); + setSelected((prev) => prev.filter((id) => !ids.has(id))); } else { - const ids = items.map((x) => x.id); - setSelected((prev) => [...new Set([...prev, ...ids])]); + setSelected((prev) => [...new Set([...prev, ...pageIds])]); } }; @@ -411,12 +412,69 @@ export function NePage() { return items; }, [stats, perTag, t]); - const listItems = listQuery.data?.items || []; - const hasFilters = Boolean(keyword || vendorFilter || statusFilter); + // Tag cards double as a quick filter on the tags column ("" = untagged). + const tagsFilter = columns.filters.tags ?? null; + const activeTagCard = + tagsFilter === null + ? "__all__" + : tagsFilter.mode === "include" && tagsFilter.values.length === 1 + ? tagsFilter.values[0] || "__no_tag__" + : ""; + const applyTagCard = (key: string) => { + if (key === "__all__") columns.setFilter("tags", null); + else columns.setFilter("tags", { mode: "include", values: [key === "__no_tag__" ? "" : key] }); + }; + + const bulkScopeCount = + bulkTagSelected === "" + ? stats?.total + : perTag[bulkTagSelected]?.total ?? (bulkTagSelected === "__no_tag__" ? stats?.no_tag_count : 0); + + const sourceLabel = (source: string) => { + const key = managedSourceKey(source); + return key ? t(`managedNe.source.${key}`) : String(source || "").trim() || t("managedNe.source.manual"); + }; + const statusLabel = (status: string) => + (CONNECT_STATUSES as readonly string[]).includes(status) ? t(`managedNe.stats.${status}`) : status; + + const runBulkByTag = async () => { + if (bulkTagAction === "proxy") { + if (!bulkHop.hop_host.trim()) { + showError(t("managedNe.hop.hostRequired")); + return; + } + if (!bulkHop.hop_username.trim()) { + showError(t("managedNe.hop.userRequired")); + return; + } + } + if (bulkTagAction === "account" && !bulkAccount.username.trim() && !bulkAccount.password) { + showError(t("managedNe.account.usernameOrPasswordRequired")); + return; + } + setResolvingIds(true); + let ids: string[]; + try { + ids = (await fetchIdsByTag(bulkTagSelected === "" ? null : bulkTagSelected)).ids; + } catch (err) { + fail(err); + return; + } finally { + setResolvingIds(false); + } + if (ids.length === 0) { + showError(t("managedNe.stats.noMatchingNe")); + return; + } + if (!window.confirm(t("managedNe.stats.confirm", { n: ids.length }))) return; + bulkByTagMutation.mutate({ action: bulkTagAction, ids }); + }; + + const bulkBusy = resolvingIds || bulkByTagMutation.isPending; return (
- {!credsOk ? ( + {!credsOk && metaQuery.isSuccess ? (

{t("managedNe.credsNotConfigured")}

@@ -430,7 +488,7 @@ export function NePage() {
{stats ? ( -
- {tagCardItems.map((x) => ( -
-
{x.title}
-
{t("managedNe.stats.total", { n: x.total })}
-
- {(["pass", "fail", "testing", "unknown"] as const).map((s) => ( - - {t(`managedNe.stats.${s}`)} {x.by_status[s] ?? 0} - - ))} -
-
- ))} -
+ <> +

{t("managedNe.stats.cardFilterHint")}

+
+ {tagCardItems.map((x) => ( + + ))} +
+ + ) : statsQuery.isError ? ( +

{errText(statsQuery.error)}

) : (

{t("managedNe.stats.loadingIds")}

)} @@ -525,7 +594,7 @@ export function NePage() { size="sm" variant="secondary" onPress={() => { - void downloadManagedNeImportTemplate("xlsx").catch((err) => showError(String(err))); + void downloadManagedNeImportTemplate("xlsx").catch(fail); }} > {t("managedNe.downloadTemplate")} @@ -552,7 +621,7 @@ export function NePage() { - + @@ -611,237 +671,168 @@ export function NePage() {
-
- { - setKeyword(e.target.value); - setPage(1); - }} - /> - { - setVendorFilter(e.target.value); - setPage(1); - }} - > - - {vendors.map((v) => ( - - ))} - - { - setStatusFilter(e.target.value); - setPage(1); - }} - > - - - - - - - -
- - {listQuery.isLoading ?

{t("common.refreshing")}

: null} - - {!listItems.length && !listQuery.isLoading ? ( -
-

{t("common.empty")}

+ {selected.length ? ( +
+ {t("managedNe.selectedCount", { n: selected.length })} +
- ) : ( -
- - - - - - - - - - - - - - - - - - {listItems.map((row) => { - const srcKey = managedSourceKey(row.source); - const srcLabel = srcKey - ? t(`managedNe.source.${srcKey}`) - : String(row.source || "").trim() || t("managedNe.source.manual"); - return ( - - - - - - - - - - + + + + + + ))} + {!listItems.length && !listQuery.isFetching && !listQuery.isError ? ( + + + + ) : null} + +
- - - - - - {t("managedNe.col.name")}{t("managedNe.col.vendor")}{t("managedNe.col.deviceType")}{t("managedNe.col.source")}{t("managedNe.col.tags")}{t("managedNe.col.ip")}{t("managedNe.col.user")}{t("managedNe.col.connect")}{t("managedNe.col.testedAt")}{t("managedNe.col.actions")}
- toggleSelect(row.id)} - /> - {row.name || row.ip_address}{row.vendor}{row.device_type} - {srcLabel} - - {row.tags - ? row.tags.split(/\s+/).map((tag) => ( - - {tag} - - )) - : t("common.empty")} - - {row.ip_address}:{row.port}/{row.protocol} - {row.hop_enabled ? ( - - {t( - `managedNe.hop.badge.${["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor) ? row.hop_vendor : "zte"}`, - )} - - ) : null} - {row.username} + ) : null} + {listQuery.isFetching ?

{t("common.refreshing")}

: null} + {listQuery.isError ? ( +

{t("managedNe.loadFailed", { error: errText(listQuery.error) })}

+ ) : null} + +
+ + + + + + + + + + + + + + + + + + {listItems.map((row) => ( + + + + + + + + - - - - ); - })} - -
+ { if (input) input.indeterminate = someSelected; }} + checked={allSelected} + disabled={!pageIds.length} + onChange={toggleSelectAll} + /> + {columns.column("name", t("managedNe.col.name"))}{columns.column("vendor", t("managedNe.col.vendor"))}{columns.column("deviceType", t("managedNe.col.deviceType"))}{columns.column("source", t("managedNe.col.source"), sourceLabel)}{columns.column("tags", t("managedNe.col.tags"))}{columns.column("ip", t("managedNe.col.ip"))}{columns.column("username", t("managedNe.col.user"))}{columns.column("connectStatus", t("managedNe.col.connect"), statusLabel)}{t("managedNe.col.testedAt")}{t("managedNe.col.actions")}
+ toggleSelect(row.id)} + /> + {row.name || row.ip_address}{row.vendor}{row.device_type} + {sourceLabel(row.source || "")} + + {row.tags + ? row.tags.split(/\s+/).filter(Boolean).map((tag) => ( + + {tag} + + )) + : "—"} + + {row.ip_address}:{row.port}/{row.protocol} + {row.hop_enabled ? ( - {row.connect_status} + {t( + `managedNe.hop.badge.${["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor) ? row.hop_vendor : "zte"}`, + )} - - {row.connect_tested_at - ? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true }) - : t("common.empty")} - -
- - - - -
-
-
- )} - -
- - {t("common.pagerMeta", { - total: String(total), - page: String(page), - pages: String(pages), - })} - -
- - - { - setPageSize(Number(e.target.value) || 10); - setPage(1); - }} - > - - - - - -
+ ) : null} +
{row.username || "—"} + + {statusLabel(row.connect_status)} + + + {row.connect_tested_at + ? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true }) + : "—"} + +
+ + + + +
+
+
+

{columns.active ? t("managedNe.emptyFiltered") : t("common.empty")}

+
+
+ + { + setPageSize(size); + setPage(1); + }} + disabled={listQuery.isLoading} + />
@@ -876,7 +867,13 @@ export function NePage() {

{t("managedNe.hop.batchHint", { n: selected.length })}

- setBatchHop((prev) => ({ ...prev, ...patch }))} /> +

{t("managedNe.hop.batchPasswordHint")}

+ setBatchHop((prev) => ({ ...prev, ...patch }))} + hopPasswordRequired={false} + hopPasswordOptional + />
- ) : ( -

{t("managedNe.stats.confirm", { n: "?" })}

- )} + ) : null} - + + + setConnectDetailRow(null)} diff --git a/web/src/features/managed-ne/components/ManagedNeFormDialog.tsx b/web/src/features/managed-ne/components/ManagedNeFormDialog.tsx index da22db8..2969a19 100644 --- a/web/src/features/managed-ne/components/ManagedNeFormDialog.tsx +++ b/web/src/features/managed-ne/components/ManagedNeFormDialog.tsx @@ -8,10 +8,13 @@ import { FieldSelect } from "../../../components/ui/FieldSelect"; import { queryKeys } from "../../../constants/queryKeys"; import { useI18n } from "../../../i18n"; import { patchHopVendorChange } from "../lib/hopProxy"; +import { managedNeErrorText } from "../lib/errors"; import type { ManagedNeItem } from "../../../types"; import { applyHopTemplate, buildManagedNeSaveBody, + deviceTypeForVendor, + isValidMgmtAddress, emptyManagedNeForm, formFromManagedNe, allowsOpenExecPolicy, @@ -37,6 +40,9 @@ export function ManagedNeFormDialog({ }: ManagedNeFormDialogProps) { const { t } = useI18n(); const [form, setForm] = useState(emptyManagedNeForm); + // Port is edited as text so the field can be cleared while typing. + const [portDraft, setPortDraft] = useState("22"); + const [submitted, setSubmitted] = useState(false); const metaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, @@ -45,16 +51,6 @@ export function ManagedNeFormDialog({ enabled: open, }); - const editingId = editing?.id ?? ""; - useEffect(() => { - if (!open) return; - if (editing) { - setForm(formFromManagedNe(editing)); - return; - } - setForm({ ...emptyManagedNeForm(), ...(initialValues || {}) }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per open/create - }, [open, editingId]); const vendors = metaQuery.data?.vendors ?? []; const execPolicyEnabled = Boolean(metaQuery.data?.exec_policy_enabled); @@ -65,6 +61,15 @@ export function ManagedNeFormDialog({ return base; }, [metaQuery.data?.device_types, form.device_type]); + const ipText = form.ip_address.trim(); + const portNum = /^\d+$/.test(portDraft.trim()) ? Number(portDraft.trim()) : NaN; + const fieldErrors = { + ip: !ipText ? t("managedNe.form.ipRequired") : !isValidMgmtAddress(ipText) ? t("managedNe.form.ipInvalid") : "", + username: !form.username.trim() ? t("managedNe.form.usernameRequired") : "", + port: portNum >= 1 && portNum <= 65535 ? "" : t("managedNe.form.portInvalid"), + }; + const hasFieldErrors = Object.values(fieldErrors).some(Boolean); + const saveMutation = useMutation({ mutationFn: async () => { const body = buildManagedNeSaveBody(form, { @@ -84,6 +89,17 @@ export function ManagedNeFormDialog({ }, }); + const editingId = editing?.id ?? ""; + useEffect(() => { + if (!open) return; + const seed = editing ? formFromManagedNe(editing) : { ...emptyManagedNeForm(), ...(initialValues || {}) }; + setForm(seed); + setPortDraft(String(seed.port)); + setSubmitted(false); + saveMutation.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per open/create + }, [open, editingId]); + return ( @@ -113,12 +129,13 @@ export function ManagedNeFormDialog({ setForm((prev) => { const next = { ...prev, vendor }; const dt = String(prev.device_type || "").trim().toLowerCase(); - if (!dt || dt === "generic" || dt === "other" || dt === "linux") { - if (vendor === "ZTE") next.device_type = "zte_zxros"; - else if (vendor === "Huawei") next.device_type = "huawei"; - else if (vendor === "Cisco") next.device_type = "cisco_ios"; - else if (vendor === "Juniper") next.device_type = "juniper_junos"; - else if (vendor === "Nokia") next.device_type = "nokia_sros"; + const suggested = deviceTypeForVendor(vendor); + // Follow the vendor while the type is a placeholder or still the old vendor's default. + const followVendor = !dt || dt === deviceTypeForVendor(prev.vendor) + || ((dt === "generic" || dt === "other" || dt === "linux") && suggested !== "generic"); + if (followVendor) { + next.device_type = suggested; + if (!allowsOpenExecPolicy(suggested)) next.exec_policy = "readonly"; } return next; }); @@ -175,25 +192,39 @@ export function ManagedNeFormDialog({ setForm({ ...form, ip_address })} > - + + {submitted && fieldErrors.ip ? {fieldErrors.ip} : null} setForm({ ...form, port: Number(port) || 22 })} + isRequired + inputMode="numeric" + isInvalid={submitted && Boolean(fieldErrors.port)} + value={portDraft} + onChange={(value) => { + setPortDraft(value); + if (/^\d+$/.test(value.trim())) setForm((prev) => ({ ...prev, port: Number(value.trim()) })); + }} > + {submitted && fieldErrors.port ? {fieldErrors.port} : null} setForm({ ...form, protocol: e.target.value })} + onChange={(e) => { + const protocol = e.target.value; + // Keep the well-known port in step with the protocol unless it was customised. + const swapPort = protocol === "telnet" && portNum === 22 ? 23 : protocol === "ssh" && portNum === 23 ? 22 : 0; + setForm((prev) => ({ ...prev, protocol, ...(swapPort ? { port: swapPort } : {}) })); + if (swapPort) setPortDraft(String(swapPort)); + }} > @@ -201,11 +232,13 @@ export function ManagedNeFormDialog({ setForm({ ...form, username })} > + {submitted && fieldErrors.username ? {fieldErrors.username} : null} - {String(saveMutation.error)} + {managedNeErrorText(saveMutation.error, t)} ) : null} @@ -291,7 +324,11 @@ export function ManagedNeFormDialog({ diff --git a/web/src/features/managed-ne/lib/errors.ts b/web/src/features/managed-ne/lib/errors.ts new file mode 100644 index 0000000..06dce43 --- /dev/null +++ b/web/src/features/managed-ne/lib/errors.ts @@ -0,0 +1,19 @@ +import { formatErr } from "../../../services/api"; + +type Translate = (key: string, vars?: Record) => string; + +/** Map API detail codes such as `ip_address_exists` or `hop_password_required: a,b` to localized text. */ +export function managedNeErrorText(err: unknown, t: Translate): string { + const raw = formatErr(err); + const matched = /^([a-z_]+)(?::\s*([\s\S]*))?$/.exec(raw.trim()); + if (!matched) return raw; + const key = `managedNe.errors.${matched[1]}`; + const template = t(key); + if (template === key) return raw; + const detail = (matched[2] || "").trim(); + if (detail) return template.replace(/\{\{detail\}\}/g, detail); + // No detail from the API: drop the "(…{{detail}})" or ": {{detail}}" part of the message. + return template + .replace(/\s*[((][^(())]*\{\{detail\}\}[))]/g, "") + .replace(/\s*[::]\s*\{\{detail\}\}/g, ""); +} diff --git a/web/src/features/managed-ne/lib/formState.ts b/web/src/features/managed-ne/lib/formState.ts index a8bca61..1b3675e 100644 --- a/web/src/features/managed-ne/lib/formState.ts +++ b/web/src/features/managed-ne/lib/formState.ts @@ -30,6 +30,26 @@ export type ManagedNeFormState = { exec_policy: "readonly" | "linux_shell" | "unrestricted"; }; +const IPV4_RE = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; +const HOSTNAME_RE = /^(?=.{1,253}$)[A-Za-z0-9_][A-Za-z0-9_-]{0,62}(\.[A-Za-z0-9_][A-Za-z0-9_-]{0,62})*\.?$/; + +/** IPv4 / IPv6 literal or DNS hostname; mirrors the API's `invalid_ip_address` check. */ +export function isValidMgmtAddress(value: string): boolean { + const text = value.trim(); + if (!text) return false; + if (IPV4_RE.test(text)) return true; + if (/^[0-9.]+$/.test(text)) return false; + if (text.includes(":")) { + try { + new URL(`http://[${text}]/`); + return true; + } catch { + return false; + } + } + return HOSTNAME_RE.test(text); +} + export function deviceTypeForVendor(vendor: string): string { if (vendor === "ZTE") return "zte_zxros"; if (vendor === "Huawei") return "huawei"; diff --git a/web/src/features/workbench/WorkbenchPage.tsx b/web/src/features/workbench/WorkbenchPage.tsx index c9a99e8..ba9762d 100644 --- a/web/src/features/workbench/WorkbenchPage.tsx +++ b/web/src/features/workbench/WorkbenchPage.tsx @@ -2,22 +2,18 @@ import { useMemo, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@heroui/react"; import { useI18n } from "../../i18n"; -import { WorkbenchCardIcon } from "./components/WorkbenchCardIcon"; +import { WorkbenchModules, type DisplaySection } from "./components/WorkbenchModules"; +import { ConsoleIcon } from "./components/ConsoleIcon"; import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules"; import { openOrFocusModule } from "../../utils/moduleWindows"; import { useAuth } from "../auth/AuthContext"; import { fetchFabricSummary, fetchManagedNeStats, fetchOpsTasks, fetchRuntimeMetrics, fetchTopologyTree, type OpsTaskItem } from "../../services/api"; -import { formatSystemTime } from "../../utils/time"; +import { parseApiTime } from "../../utils/time"; import { isUmeWorldContainer, isWorldDrillFolder } from "../topology/lib/treeUtils"; import type { TopologyTreeFolderItem } from "../../types"; const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"]; -type DisplaySection = { - key: "operations" | "system"; - mods: ModuleDefinition[]; -}; - function formatCount(n: number | string): string { if (typeof n === "string") return n; if (!Number.isFinite(n)) return "—"; @@ -32,20 +28,13 @@ function formatAgeSec(sec: number | null | undefined): string { const n = Math.max(0, sec); if (n < 60) return `${Math.round(n)}s`; if (n < 3600) return `${Math.max(1, Math.round(n / 60))}m`; - return `${(n / 3600).toFixed(n >= 10 ? 0 : 1)}h`; + return `${(n / 3600).toFixed(n >= 10 * 3600 ? 0 : 1)}h`; } function formatCompactTime(value: string | null | undefined): string { - if (!value) return "—"; - const full = formatSystemTime(value); - if (!full) return "—"; - // Keep date+time but drop seconds when present: "2026/9/12 15:42:01" → "9/12 15:42" - const m = full.match(/(\d{1,4}[/-]\d{1,2}[/-]\d{1,2})\s+(\d{1,2}:\d{2})(?::\d{2})?/); - if (m) { - const date = m[1].replace(/^\d{4}[/-]/, ""); - return `${date} ${m[2]}`; - } - return full; + const date = parseApiTime(value); + if (!date) return "—"; + return `${date.toLocaleDateString(undefined, { month: "numeric", day: "numeric" })} ${date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })}`; } const FLEET_TONE_COLOR: Record<"ok" | "mid" | "warn" | "bad" | "muted", string> = { @@ -121,7 +110,8 @@ function FleetDonut({