From 312f6f2f96e0560cf37c7f7016ceb8a4d4a4e066 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 13 Oct 2026 00:26:58 +0800 Subject: [PATCH] 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 --- .gitignore | 1 + netx_api/managed_ne_router.py | 3 + netx_api/ne_schemas.py | 10 +- netx_api/ne_service_common.py | 62 +- netx_api/ne_service_crud.py | 82 +- netx_api/ne_service_import.py | 42 +- tests/test_managed_ne_page.py | 205 +++ web/src/constants/queryKeys.ts | 4 +- web/src/features/managed-ne/NePage.tsx | 791 ++++++----- .../components/ManagedNeFormDialog.tsx | 83 +- web/src/features/managed-ne/lib/errors.ts | 19 + web/src/features/managed-ne/lib/formState.ts | 20 + web/src/features/workbench/WorkbenchPage.tsx | 315 ++--- .../workbench/components/ConsoleIcon.tsx | 14 + .../workbench/components/WorkbenchModules.tsx | 70 + web/src/i18n/locales/en/managedNe.ts | 39 + web/src/i18n/locales/en/workbench.ts | 55 +- web/src/i18n/locales/zh/managedNe.ts | 39 + web/src/i18n/locales/zh/workbench.ts | 61 +- web/src/layout/AppLayout.tsx | 17 +- web/src/services/api/managedNe.ts | 4 + web/src/styles/layout/app-shell.css | 37 + web/src/styles/pages/ne-ume-system.css | 41 + web/src/styles/pages/workbench.css | 1235 +++-------------- web/src/styles/theme/theme.css | 251 +--- 25 files changed, 1484 insertions(+), 2016 deletions(-) create mode 100644 tests/test_managed_ne_page.py create mode 100644 web/src/features/managed-ne/lib/errors.ts create mode 100644 web/src/features/workbench/components/ConsoleIcon.tsx create mode 100644 web/src/features/workbench/components/WorkbenchModules.tsx 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({