mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 21:10:46 +08:00
Improve managed NE filters/validation and simplify the workbench UI.
Add column filters and clearer API error mapping for NE management, tighten port validation, and slim the workbench into reusable modules with cleaner styles. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3c9e0efd2b
commit
312f6f2f96
25 changed files with 1484 additions and 2016 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@ build/
|
||||||
dist/
|
dist/
|
||||||
scripts/.run/
|
scripts/.run/
|
||||||
# Local one-off probes / staging dumps (not for commit)
|
# Local one-off probes / staging dumps (not for commit)
|
||||||
|
artifacts/
|
||||||
scripts/_*
|
scripts/_*
|
||||||
scripts/archive/
|
scripts/archive/
|
||||||
packages/netx-topology-mcp/tests/_*
|
packages/netx-topology-mcp/tests/_*
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ from .ne_service import (
|
||||||
update_managed_ne,
|
update_managed_ne,
|
||||||
)
|
)
|
||||||
from .models import ManagedNE
|
from .models import ManagedNE
|
||||||
|
from .table_filters import TableFilterParams, table_filter_params
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/managed-ne", tags=["managed-ne"])
|
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),
|
connect_status: str | None = Query(default=None),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = Query(default=50, ge=1, le=500),
|
page_size: int = Query(default=50, ge=1, le=500),
|
||||||
|
table_filters: TableFilterParams = Depends(table_filter_params),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
return list_managed_ne(
|
return list_managed_ne(
|
||||||
|
|
@ -72,6 +74,7 @@ def api_list_managed_ne(
|
||||||
connect_status=connect_status,
|
connect_status=connect_status,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
|
table_filters=table_filters,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class ManagedNeCreate(BaseModel):
|
||||||
vendor: str
|
vendor: str
|
||||||
device_type: str
|
device_type: str
|
||||||
ip_address: str
|
ip_address: str
|
||||||
port: int = 22
|
port: int = Field(default=22, ge=1, le=65535)
|
||||||
protocol: str = "ssh"
|
protocol: str = "ssh"
|
||||||
username: str
|
username: str
|
||||||
password: str = ""
|
password: str = ""
|
||||||
|
|
@ -27,7 +27,7 @@ class ManagedNeCreate(BaseModel):
|
||||||
hop_enabled: bool = False
|
hop_enabled: bool = False
|
||||||
hop_vendor: str = "zte"
|
hop_vendor: str = "zte"
|
||||||
hop_host: str = ""
|
hop_host: str = ""
|
||||||
hop_port: int = 22
|
hop_port: int = Field(default=22, ge=1, le=65535)
|
||||||
hop_protocol: str = "ssh"
|
hop_protocol: str = "ssh"
|
||||||
hop_username: str = ""
|
hop_username: str = ""
|
||||||
hop_password: str = ""
|
hop_password: str = ""
|
||||||
|
|
@ -63,7 +63,7 @@ class ManagedNeUpdate(BaseModel):
|
||||||
vendor: str | None = None
|
vendor: str | None = None
|
||||||
device_type: str | None = None
|
device_type: str | None = None
|
||||||
ip_address: 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
|
protocol: str | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
|
|
@ -73,7 +73,7 @@ class ManagedNeUpdate(BaseModel):
|
||||||
hop_enabled: bool | None = None
|
hop_enabled: bool | None = None
|
||||||
hop_vendor: str | None = None
|
hop_vendor: str | None = None
|
||||||
hop_host: 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_protocol: str | None = None
|
||||||
hop_username: str | None = None
|
hop_username: str | None = None
|
||||||
hop_password: str | None = None
|
hop_password: str | None = None
|
||||||
|
|
@ -194,7 +194,7 @@ class HopProxyConfig(BaseModel):
|
||||||
|
|
||||||
hop_vendor: str = "zte"
|
hop_vendor: str = "zte"
|
||||||
hop_host: str
|
hop_host: str
|
||||||
hop_port: int = 22
|
hop_port: int = Field(default=22, ge=1, le=65535)
|
||||||
hop_protocol: str = "ssh"
|
hop_protocol: str = "ssh"
|
||||||
hop_username: str
|
hop_username: str
|
||||||
hop_password: str = ""
|
hop_password: str = ""
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,63 @@ def _normalize_ip(ip: str) -> str:
|
||||||
return str(ip or "").strip()
|
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:
|
def _normalize_protocol(protocol: str) -> str:
|
||||||
p = str(protocol or "ssh").strip().lower()
|
p = str(protocol or "ssh").strip().lower()
|
||||||
return p if p in ("ssh", "telnet") else "ssh"
|
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")
|
raise HTTPException(status_code=400, detail="hop_host_required")
|
||||||
if not str(row.hop_username or "").strip():
|
if not str(row.hop_username or "").strip():
|
||||||
raise HTTPException(status_code=400, detail="hop_username_required")
|
raise HTTPException(status_code=400, detail="hop_username_required")
|
||||||
if (
|
if not str(row.hop_password_enc or "").strip() and not hop_password_optional(
|
||||||
not str(row.hop_password_enc or "").strip()
|
str(row.hop_vendor or ""), str(row.hop_target_auth_mode or "")
|
||||||
and _normalize_hop_target_auth_mode(row.hop_target_auth_mode) != "bastion_managed"
|
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=400, detail="hop_password_required")
|
raise HTTPException(status_code=400, detail="hop_password_required")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import false, func, literal, or_
|
||||||
from sqlalchemy.orm import Session
|
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 .models import ManagedNE
|
||||||
from .ne_crypto import encrypt_secret
|
from .ne_crypto import encrypt_secret
|
||||||
from .ne_hop_templates import default_bastion_username_template, default_hop_command_template
|
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,
|
_apply_hop_update,
|
||||||
_normalize_hop_target_auth_mode,
|
_normalize_hop_target_auth_mode,
|
||||||
_normalize_hop_vendor,
|
_normalize_hop_vendor,
|
||||||
_normalize_ip,
|
|
||||||
_normalize_protocol,
|
_normalize_protocol,
|
||||||
|
_require_mgmt_address,
|
||||||
|
connect_snapshot,
|
||||||
|
hop_password_optional,
|
||||||
|
reset_connect_status_if_changed,
|
||||||
_normalize_saved_hop_endpoint,
|
_normalize_saved_hop_endpoint,
|
||||||
_normalize_vendor,
|
_normalize_vendor,
|
||||||
_now,
|
_now,
|
||||||
|
|
@ -33,16 +36,43 @@ from .ne_service_common import (
|
||||||
_validate_hop_on_create,
|
_validate_hop_on_create,
|
||||||
row_to_out,
|
row_to_out,
|
||||||
)
|
)
|
||||||
|
from .table_filters import TableFilterParams, filter_query
|
||||||
from .ne_exec_guard import (
|
from .ne_exec_guard import (
|
||||||
EXEC_POLICY_READONLY,
|
EXEC_POLICY_READONLY,
|
||||||
allows_open_exec_policy,
|
allows_open_exec_policy,
|
||||||
require_exec_policy_writable,
|
require_exec_policy_writable,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inventory create stays strict; updates must also accept WebCRT/LLDP placeholder types
|
# Create and update share the form's device-type list, which includes linux/generic
|
||||||
# (generic/linux) so operators can open the form and promote them to zte_zxros etc.
|
# (Linux hosts and WebCRT/LLDP placeholders promoted to inventory).
|
||||||
_ALLOWED_UPDATE_DEVICE_TYPES = frozenset(WEBCRT_DEVICE_TYPES)
|
_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(
|
def list_managed_ne(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -51,6 +81,7 @@ def list_managed_ne(
|
||||||
connect_status: str | None = None,
|
connect_status: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
|
table_filters: TableFilterParams | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
stmt = db.query(ManagedNE)
|
stmt = db.query(ManagedNE)
|
||||||
kw = str(keyword or "").strip()
|
kw = str(keyword or "").strip()
|
||||||
|
|
@ -72,6 +103,22 @@ def list_managed_ne(
|
||||||
stmt = stmt.filter(ManagedNE.vendor == v)
|
stmt = stmt.filter(ManagedNE.vendor == v)
|
||||||
if cs:
|
if cs:
|
||||||
stmt = stmt.filter(ManagedNE.connect_status == 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())
|
total = int(stmt.count())
|
||||||
rows = (
|
rows = (
|
||||||
stmt.order_by(ManagedNE.updated_at.desc())
|
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:
|
def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut:
|
||||||
_require_crypto()
|
_require_crypto()
|
||||||
_validate_hop_on_create(body)
|
_validate_hop_on_create(body)
|
||||||
ip = _normalize_ip(body.ip_address)
|
ip = _require_mgmt_address(body.ip_address)
|
||||||
if not ip:
|
if body.device_type not in _ALLOWED_UPDATE_DEVICE_TYPES:
|
||||||
raise HTTPException(status_code=400, detail="ip_address_required")
|
|
||||||
if body.device_type not in SUPPORTED_DEVICE_TYPES:
|
|
||||||
raise HTTPException(status_code=400, detail="unsupported_device_type")
|
raise HTTPException(status_code=400, detail="unsupported_device_type")
|
||||||
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
|
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
|
||||||
if existing:
|
if existing:
|
||||||
|
|
@ -140,10 +185,9 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="managed_ne_not_found")
|
raise HTTPException(status_code=404, detail="managed_ne_not_found")
|
||||||
data = body.model_dump(exclude_unset=True)
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
before = connect_snapshot(row)
|
||||||
if "ip_address" in data:
|
if "ip_address" in data:
|
||||||
ip = _normalize_ip(data["ip_address"])
|
ip = _require_mgmt_address(data["ip_address"])
|
||||||
if not ip:
|
|
||||||
raise HTTPException(status_code=400, detail="ip_address_required")
|
|
||||||
other = db.query(ManagedNE).filter(ManagedNE.ip_address == ip, ManagedNE.id != ne_id).first()
|
other = db.query(ManagedNE).filter(ManagedNE.ip_address == ip, ManagedNE.id != ne_id).first()
|
||||||
if other:
|
if other:
|
||||||
raise HTTPException(status_code=400, detail="ip_address_exists")
|
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()
|
v = str(data["vendor"] or "").strip()
|
||||||
row.vendor = v if v in SUPPORTED_VENDORS else "Other"
|
row.vendor = v if v in SUPPORTED_VENDORS else "Other"
|
||||||
if "name" in data:
|
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:
|
if "port" in data and data["port"] is not None:
|
||||||
row.port = int(data["port"])
|
row.port = int(data["port"])
|
||||||
if "protocol" in data and data["protocol"] is not None:
|
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 "ip_address" in data and str(row.ip_address or "").strip():
|
||||||
if is_placeholder_ne_source(row.source):
|
if is_placeholder_ne_source(row.source):
|
||||||
row.source = ""
|
row.source = ""
|
||||||
|
reset_connect_status_if_changed(row, before)
|
||||||
row.updated_at = _now()
|
row.updated_at = _now()
|
||||||
# Keep linked fabric node identity in sync (name / IP / vendor).
|
# Keep linked fabric node identity in sync (name / IP / vendor).
|
||||||
if any(k in data for k in ("ip_address", "name", "vendor", "device_type")):
|
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:
|
if not hop_user:
|
||||||
raise HTTPException(status_code=400, detail="hop_username_required")
|
raise HTTPException(status_code=400, detail="hop_username_required")
|
||||||
hop_auth_mode = _normalize_hop_target_auth_mode(hop.hop_target_auth_mode)
|
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()
|
template = str(hop.hop_command_template or "").strip()
|
||||||
if hop_vendor == "bastion" and not template:
|
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]
|
missing = [x for x in ne_ids if x not in found_ids]
|
||||||
if missing:
|
if missing:
|
||||||
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
|
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()
|
now = _now()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
before = connect_snapshot(row)
|
||||||
row.hop_enabled = True
|
row.hop_enabled = True
|
||||||
row.hop_vendor = hop_vendor
|
row.hop_vendor = hop_vendor
|
||||||
row.hop_host = hop_host
|
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_vrf = str(hop.hop_vrf or "").strip()
|
||||||
row.hop_target_auth_mode = hop_auth_mode
|
row.hop_target_auth_mode = hop_auth_mode
|
||||||
row.hop_enter_system_view = bool(getattr(hop, "hop_enter_system_view", False))
|
row.hop_enter_system_view = bool(getattr(hop, "hop_enter_system_view", False))
|
||||||
|
reset_connect_status_if_changed(row, before)
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "updated": len(rows)}
|
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])}")
|
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
|
||||||
now = _now()
|
now = _now()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
before = connect_snapshot(row)
|
||||||
if user:
|
if user:
|
||||||
row.username = user
|
row.username = user
|
||||||
if pwd:
|
if pwd:
|
||||||
row.password_enc = pwd_enc
|
row.password_enc = pwd_enc
|
||||||
|
reset_connect_status_if_changed(row, before)
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "updated": len(rows)}
|
return {"ok": True, "updated": len(rows)}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import ManagedNE, UmeInventoryNE
|
from .models import ManagedNE, UmeInventoryNE
|
||||||
from .ne_crypto import CredentialCryptoError, encrypt_secret
|
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 (
|
from .ne_schemas import (
|
||||||
ImportFailure,
|
ImportFailure,
|
||||||
ImportResult,
|
ImportResult,
|
||||||
|
|
@ -23,6 +23,9 @@ from .ne_service_common import (
|
||||||
UME_SYNC_TAG,
|
UME_SYNC_TAG,
|
||||||
_import_cell_str,
|
_import_cell_str,
|
||||||
_infer_managed_ne_type_vendor,
|
_infer_managed_ne_type_vendor,
|
||||||
|
_is_valid_mgmt_address,
|
||||||
|
connect_snapshot,
|
||||||
|
reset_connect_status_if_changed,
|
||||||
_merge_tags,
|
_merge_tags,
|
||||||
_normalize_ip,
|
_normalize_ip,
|
||||||
_normalize_protocol,
|
_normalize_protocol,
|
||||||
|
|
@ -188,25 +191,38 @@ def import_managed_ne(db: Session, content: bytes, filename: str) -> ImportResul
|
||||||
failed: list[ImportFailure] = []
|
failed: list[ImportFailure] = []
|
||||||
for idx, row in df.iterrows():
|
for idx, row in df.iterrows():
|
||||||
row_no = int(idx) + 2
|
row_no = int(idx) + 2
|
||||||
|
# Savepoint per row: a failing row must not leave a half-written NE behind.
|
||||||
|
savepoint = db.begin_nested()
|
||||||
try:
|
try:
|
||||||
ip = _normalize_ip(_import_cell_str(row.get("ip", "")))
|
ip = _normalize_ip(_import_cell_str(row.get("ip", "")))
|
||||||
if not ip:
|
if not ip:
|
||||||
failed.append(ImportFailure(row=row_no, reason="ip_required"))
|
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
|
continue
|
||||||
device_type = _import_cell_str(row.get("device_type", ""))
|
device_type = _import_cell_str(row.get("device_type", ""))
|
||||||
if device_type not in SUPPORTED_DEVICE_TYPES:
|
if device_type not in SUPPORTED_DEVICE_TYPES:
|
||||||
failed.append(ImportFailure(row=row_no, reason="unsupported_device_type"))
|
failed.append(ImportFailure(row=row_no, reason="unsupported_device_type"))
|
||||||
|
savepoint.rollback()
|
||||||
continue
|
continue
|
||||||
username = _import_cell_str(row.get("username", ""))
|
username = _import_cell_str(row.get("username", ""))
|
||||||
password = _import_cell_str(row.get("password", ""))
|
password = _import_cell_str(row.get("password", ""))
|
||||||
if not username:
|
if not username:
|
||||||
failed.append(ImportFailure(row=row_no, reason="username_required"))
|
failed.append(ImportFailure(row=row_no, reason="username_required"))
|
||||||
|
savepoint.rollback()
|
||||||
continue
|
continue
|
||||||
port_raw = row.get("port", 22)
|
port_raw = row.get("port", 22)
|
||||||
try:
|
try:
|
||||||
port = int(port_raw)
|
port = int(port_raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
port = 22
|
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")))
|
protocol = _normalize_protocol(str(row.get("protocol", "ssh")))
|
||||||
display_name = _import_cell_str(row.get("name", "")) or ip
|
display_name = _import_cell_str(row.get("name", "")) or ip
|
||||||
vendor_raw = _import_cell_str(row.get("vendor", "")) or "Other"
|
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
|
break
|
||||||
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
|
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
|
||||||
now = _now()
|
now = _now()
|
||||||
|
is_new = existing is None
|
||||||
if 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)
|
db.add(existing)
|
||||||
inserted += 1
|
before = connect_snapshot(existing)
|
||||||
else:
|
|
||||||
updated += 1
|
|
||||||
existing.name = display_name
|
existing.name = display_name
|
||||||
existing.vendor = vendor
|
existing.vendor = vendor
|
||||||
existing.device_type = device_type
|
existing.device_type = device_type
|
||||||
existing.port = port
|
existing.port = port
|
||||||
existing.protocol = protocol
|
existing.protocol = protocol
|
||||||
existing.username = username
|
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", ""))
|
tags_val = _import_cell_str(row.get("tags", ""))
|
||||||
remark_val = _import_cell_str(row.get("remark", ""))
|
remark_val = _import_cell_str(row.get("remark", ""))
|
||||||
if tags_val:
|
if tags_val:
|
||||||
existing.tags = tags_val
|
existing.tags = tags_val
|
||||||
if remark_val:
|
if remark_val:
|
||||||
existing.remark = 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
|
existing.updated_at = now
|
||||||
|
savepoint.commit()
|
||||||
|
if is_new:
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
except CredentialCryptoError as exc:
|
except CredentialCryptoError as exc:
|
||||||
|
savepoint.rollback()
|
||||||
failed.append(ImportFailure(row=row_no, reason=str(exc)))
|
failed.append(ImportFailure(row=row_no, reason=str(exc)))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
savepoint.rollback()
|
||||||
failed.append(ImportFailure(row=row_no, reason=str(exc)[:200]))
|
failed.append(ImportFailure(row=row_no, reason=str(exc)[:200]))
|
||||||
db.commit()
|
db.commit()
|
||||||
return ImportResult(inserted=inserted, updated=updated, failed=failed)
|
return ImportResult(inserted=inserted, updated=updated, failed=failed)
|
||||||
|
|
|
||||||
205
tests/test_managed_ne_page.py
Normal file
205
tests/test_managed_ne_page.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -22,8 +22,8 @@ export const queryKeys = {
|
||||||
managedNeMeta: ["managedNeMeta"] as const,
|
managedNeMeta: ["managedNeMeta"] as const,
|
||||||
managedNeStats: ["managedNeStats"] as const,
|
managedNeStats: ["managedNeStats"] as const,
|
||||||
managedNeAll: ["managedNe"] as const,
|
managedNeAll: ["managedNe"] as const,
|
||||||
managedNe: (keyword: string, vendor: string, connectStatus: string, page: number, pageSize: number) =>
|
managedNe: (columnFilters: string, page: number, pageSize: number) =>
|
||||||
["managedNe", keyword, vendor, connectStatus, page, pageSize] as const,
|
["managedNe", columnFilters, page, pageSize] as const,
|
||||||
collectionEligibleNeAll: ["collectionEligibleNe"] as const,
|
collectionEligibleNeAll: ["collectionEligibleNe"] as const,
|
||||||
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
|
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
|
||||||
neCollectionsAll: ["neCollections"] as const,
|
neCollectionsAll: ["neCollections"] as const,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 {
|
import {
|
||||||
batchApplyAccountManagedNe,
|
batchApplyAccountManagedNe,
|
||||||
batchApplyHopManagedNe,
|
batchApplyHopManagedNe,
|
||||||
|
|
@ -20,20 +20,23 @@ import {
|
||||||
type ManagedNeStats,
|
type ManagedNeStats,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
import { HelpHint } from "../../components/HelpHint";
|
import { HelpHint } from "../../components/HelpHint";
|
||||||
|
import { ListPager } from "../../components/ListPager";
|
||||||
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./components/HopProxyFields";
|
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./components/HopProxyFields";
|
||||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||||
import { queryKeys } from "../../constants/queryKeys";
|
import { queryKeys } from "../../constants/queryKeys";
|
||||||
|
import { useTableColumnFilters } from "../../hooks/useTableColumnFilters";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
import { useToast } from "../../hooks/useToast";
|
import { useToast } from "../../hooks/useToast";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import type { ManagedNeItem } from "../../types";
|
import type { ManagedNeImportResult, ManagedNeItem } from "../../types";
|
||||||
import { pageCount } from "../../utils/display";
|
import { pageCount } from "../../utils/display";
|
||||||
import { formatSystemTime } from "../../utils/time";
|
import { formatSystemTime } from "../../utils/time";
|
||||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||||
import { ManagedNeFormDialog } from "./components/ManagedNeFormDialog";
|
import { ManagedNeFormDialog } from "./components/ManagedNeFormDialog";
|
||||||
import { ManagedNeConnectDetailDialog } from "./components/ManagedNeConnectDetailDialog";
|
import { ManagedNeConnectDetailDialog } from "./components/ManagedNeConnectDetailDialog";
|
||||||
import { connectStatusClass } from "./lib/connectStatus";
|
import { connectStatusClass } from "./lib/connectStatus";
|
||||||
|
import { managedNeErrorText } from "./lib/errors";
|
||||||
import {
|
import {
|
||||||
deviceTypeForVendor,
|
deviceTypeForVendor,
|
||||||
emptyManagedNeForm,
|
emptyManagedNeForm,
|
||||||
|
|
@ -44,16 +47,36 @@ import {
|
||||||
/** Hide UME→managed sync/delete controls until needed again. APIs remain available. */
|
/** Hide UME→managed sync/delete controls until needed again. APIs remain available. */
|
||||||
const SHOW_UME_MANAGED_SYNC = false;
|
const SHOW_UME_MANAGED_SYNC = false;
|
||||||
|
|
||||||
|
const MANAGED_NE_ENDPOINT = "/v1/managed-ne";
|
||||||
|
const CONNECT_STATUSES = ["pass", "fail", "testing", "unknown"] as const;
|
||||||
|
|
||||||
type AccountState = {
|
type AccountState = {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BulkTagAction = "proxy" | "test" | "account";
|
||||||
|
|
||||||
const emptyAccount = (): AccountState => ({
|
const emptyAccount = (): AccountState => ({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
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() {
|
export function NePage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showOk, showError } = useToast();
|
const { showOk, showError } = useToast();
|
||||||
|
|
@ -63,10 +86,9 @@ export function NePage() {
|
||||||
const importRef = useRef<HTMLInputElement>(null);
|
const importRef = useRef<HTMLInputElement>(null);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const deepLinkHandled = useRef("");
|
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 [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
@ -78,12 +100,21 @@ export function NePage() {
|
||||||
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
|
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
|
||||||
const [batchAccount, setBatchAccount] = useState<AccountState>(emptyAccount);
|
const [batchAccount, setBatchAccount] = useState<AccountState>(emptyAccount);
|
||||||
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
|
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
|
||||||
|
const [importResult, setImportResult] = useState<ManagedNeImportResult | null>(null);
|
||||||
|
|
||||||
// --- bulk-by-tag dialog ---
|
// --- bulk-by-tag dialog ---
|
||||||
const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false);
|
const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false);
|
||||||
const [bulkTagAction, setBulkTagAction] = useState<"proxy" | "test" | "account">("proxy");
|
const [bulkTagAction, setBulkTagAction] = useState<BulkTagAction>("proxy");
|
||||||
const [bulkTagSelected, setBulkTagSelected] = useState<string>(""); // "" = all, "__no_tag__" = no-tag NEs
|
const [bulkTagSelected, setBulkTagSelected] = useState<string>(""); // "" = all, "__no_tag__" = no-tag NEs
|
||||||
const [bulkAccount, setBulkAccount] = useState<AccountState>(emptyAccount);
|
const [bulkAccount, setBulkAccount] = useState<AccountState>(emptyAccount);
|
||||||
|
const [bulkHop, setBulkHop] = useState<HopProxyFieldsState>(() => 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({
|
const metaQuery = useQuery({
|
||||||
queryKey: queryKeys.managedNeMeta,
|
queryKey: queryKeys.managedNeMeta,
|
||||||
|
|
@ -98,14 +129,15 @@ export function NePage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: queryKeys.managedNe(keyword, vendorFilter, statusFilter, page, pageSize),
|
queryKey: queryKeys.managedNe(columns.key, page, pageSize),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchManagedNe({
|
fetchManagedNe({
|
||||||
keyword,
|
keyword: "",
|
||||||
vendor: vendorFilter,
|
vendor: "",
|
||||||
connectStatus: statusFilter,
|
connectStatus: "",
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
columnFilters: columns.filters,
|
||||||
}),
|
}),
|
||||||
refetchInterval: (q) => {
|
refetchInterval: (q) => {
|
||||||
const items = q.state.data?.items || [];
|
const items = q.state.data?.items || [];
|
||||||
|
|
@ -129,17 +161,26 @@ export function NePage() {
|
||||||
|
|
||||||
const total = listQuery.data?.total ?? 0;
|
const total = listQuery.data?.total ?? 0;
|
||||||
const pages = pageCount(total, pageSize);
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: deleteManagedNe,
|
mutationFn: deleteManagedNe,
|
||||||
onSuccess: async () => {
|
onSuccess: async (_res, id) => {
|
||||||
|
setSelected((prev) => prev.filter((x) => x !== id));
|
||||||
showOk(t("managedNe.form.deleted"));
|
showOk(t("managedNe.form.deleted"));
|
||||||
await invalidateList();
|
await invalidateList();
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const connectMutation = useMutation({
|
const connectMutation = useMutation({
|
||||||
|
|
@ -148,7 +189,7 @@ export function NePage() {
|
||||||
showOk(t("managedNe.connect.submitted", { n: res.submitted }));
|
showOk(t("managedNe.connect.submitted", { n: res.submitted }));
|
||||||
await invalidateList();
|
await invalidateList();
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const batchDeleteMutation = useMutation({
|
const batchDeleteMutation = useMutation({
|
||||||
|
|
@ -158,30 +199,18 @@ export function NePage() {
|
||||||
showOk(t("managedNe.batchDeleteDone", { n: res.deleted }));
|
showOk(t("managedNe.batchDeleteDone", { n: res.deleted }));
|
||||||
await invalidateList();
|
await invalidateList();
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const batchHopMutation = useMutation({
|
const batchHopMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => batchApplyHopManagedNe(selected, hopPayload(batchHop)),
|
||||||
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,
|
|
||||||
}),
|
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
setBatchHopOpen(false);
|
setBatchHopOpen(false);
|
||||||
setBatchHop(emptyHopProxyFields());
|
setBatchHop(emptyHopProxyFields());
|
||||||
showOk(t("managedNe.hop.batchDone", { n: res.updated }));
|
showOk(t("managedNe.hop.batchDone", { n: res.updated }));
|
||||||
await invalidateList();
|
await invalidateList();
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const batchAccountMutation = useMutation({
|
const batchAccountMutation = useMutation({
|
||||||
|
|
@ -196,7 +225,7 @@ export function NePage() {
|
||||||
showOk(t("managedNe.account.batchDone", { n: res.updated }));
|
showOk(t("managedNe.account.batchDone", { n: res.updated }));
|
||||||
await invalidateList();
|
await invalidateList();
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const umeSyncMutation = useMutation({
|
const umeSyncMutation = useMutation({
|
||||||
|
|
@ -210,24 +239,18 @@ export function NePage() {
|
||||||
total: res.total_inventory,
|
total: res.total_inventory,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Promise.all([
|
await invalidateList();
|
||||||
invalidateList(),
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }),
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const umeDeleteMutation = useMutation({
|
const umeDeleteMutation = useMutation({
|
||||||
mutationFn: deleteUmeManagedNe,
|
mutationFn: deleteUmeManagedNe,
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
showOk(t("managedNe.umeSync.deletedDone", { n: res.deleted }));
|
showOk(t("managedNe.umeSync.deletedDone", { n: res.deleted }));
|
||||||
await Promise.all([
|
await invalidateList();
|
||||||
invalidateList(),
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }),
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const importMutation = useMutation({
|
const importMutation = useMutation({
|
||||||
|
|
@ -240,65 +263,45 @@ export function NePage() {
|
||||||
failed: res.failed.length,
|
failed: res.failed.length,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (res.failed.length) setImportResult(res);
|
||||||
await invalidateList();
|
await invalidateList();
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bulk-by-tag: fetch ids then run proxy/test
|
|
||||||
const [bulkHop, setBulkHop] = useState<HopProxyFieldsState>(() => emptyHopProxyFields());
|
|
||||||
const bulkByTagMutation = useMutation({
|
const bulkByTagMutation = useMutation({
|
||||||
mutationFn: async (params: { action: "proxy" | "test" | "account"; tag: string }) => {
|
mutationFn: async (params: { action: BulkTagAction; ids: string[] }) => {
|
||||||
const apiTag = params.tag === "" ? null : params.tag;
|
const { action, ids } = params;
|
||||||
const { ids } = await fetchIdsByTag(apiTag);
|
if (action === "test") {
|
||||||
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") {
|
|
||||||
const res = await connectTestManagedNe(ids);
|
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, {
|
const res = await batchApplyAccountManagedNe(ids, {
|
||||||
username: bulkAccount.username.trim(),
|
username: bulkAccount.username.trim(),
|
||||||
password: bulkAccount.password,
|
password: bulkAccount.password,
|
||||||
});
|
});
|
||||||
return { type: "account" as const, n: res.updated };
|
return { type: action, n: res.updated };
|
||||||
}
|
}
|
||||||
const res = await batchApplyHopManagedNe(ids, {
|
const res = await batchApplyHopManagedNe(ids, hopPayload(bulkHop));
|
||||||
hop_vendor: bulkHop.hop_vendor,
|
return { type: action, n: res.updated };
|
||||||
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 };
|
|
||||||
},
|
},
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
if (!res) return;
|
|
||||||
setBulkTagModalOpen(false);
|
setBulkTagModalOpen(false);
|
||||||
if (res.type === "test") showOk(t("managedNe.stats.testDone", { n: res.n }));
|
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 if (res.type === "account") showOk(t("managedNe.account.batchDone", { n: res.n }));
|
||||||
else showOk(t("managedNe.stats.proxyDone", { n: res.n }));
|
else showOk(t("managedNe.stats.proxyDone", { n: res.n }));
|
||||||
await Promise.all([
|
await invalidateList();
|
||||||
invalidateList(),
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }),
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const vendors = metaQuery.data?.vendors ?? [];
|
|
||||||
const credsOk = metaQuery.data?.credentials_configured ?? false;
|
const credsOk = metaQuery.data?.credentials_configured ?? false;
|
||||||
|
const listItems = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
|
||||||
const allSelected = useMemo(() => {
|
const pageIds = listItems.map((x) => x.id);
|
||||||
const items = listQuery.data?.items ?? [];
|
const selectedOnPage = pageIds.filter((id) => selected.includes(id)).length;
|
||||||
return items.length > 0 && items.every((x) => selected.includes(x.id));
|
const allSelected = pageIds.length > 0 && selectedOnPage === pageIds.length;
|
||||||
}, [listQuery.data?.items, selected]);
|
const someSelected = selectedOnPage > 0 && !allSelected;
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
|
@ -344,7 +347,7 @@ export function NePage() {
|
||||||
const row = await fetchManagedNeById(neId);
|
const row = await fetchManagedNeById(neId);
|
||||||
openEdit(row);
|
openEdit(row);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(err));
|
showError(managedNeErrorText(err, t));
|
||||||
} finally {
|
} finally {
|
||||||
clearDeepLink();
|
clearDeepLink();
|
||||||
}
|
}
|
||||||
|
|
@ -366,20 +369,18 @@ export function NePage() {
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
clearDeepLink();
|
clearDeepLink();
|
||||||
}, [searchParams, setSearchParams, showError]);
|
}, [searchParams, setSearchParams, showError, t]);
|
||||||
|
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string) => {
|
||||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
const toggleSelectAll = () => {
|
||||||
const items = listQuery.data?.items ?? [];
|
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
const pageIds = new Set(items.map((x) => x.id));
|
const ids = new Set(pageIds);
|
||||||
setSelected((prev) => prev.filter((id) => !pageIds.has(id)));
|
setSelected((prev) => prev.filter((id) => !ids.has(id)));
|
||||||
} else {
|
} else {
|
||||||
const ids = items.map((x) => x.id);
|
setSelected((prev) => [...new Set([...prev, ...pageIds])]);
|
||||||
setSelected((prev) => [...new Set([...prev, ...ids])]);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -411,12 +412,69 @@ export function NePage() {
|
||||||
return items;
|
return items;
|
||||||
}, [stats, perTag, t]);
|
}, [stats, perTag, t]);
|
||||||
|
|
||||||
const listItems = listQuery.data?.items || [];
|
// Tag cards double as a quick filter on the tags column ("" = untagged).
|
||||||
const hasFilters = Boolean(keyword || vendorFilter || statusFilter);
|
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 (
|
return (
|
||||||
<div className="page-stack ne-page">
|
<div className="page-stack ne-page">
|
||||||
{!credsOk ? (
|
{!credsOk && metaQuery.isSuccess ? (
|
||||||
<section className="panel panel--warn">
|
<section className="panel panel--warn">
|
||||||
<p>{t("managedNe.credsNotConfigured")}</p>
|
<p>{t("managedNe.credsNotConfigured")}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -430,7 +488,7 @@ export function NePage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
isDisabled={bulkByTagMutation.isPending}
|
isDisabled={bulkBusy || !canWriteNe}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setBulkTagAction("proxy");
|
setBulkTagAction("proxy");
|
||||||
setBulkHop(emptyHopProxyFields());
|
setBulkHop(emptyHopProxyFields());
|
||||||
|
|
@ -442,7 +500,7 @@ export function NePage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
isDisabled={bulkByTagMutation.isPending}
|
isDisabled={bulkBusy || !canWriteNe}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setBulkTagAction("account");
|
setBulkTagAction("account");
|
||||||
setBulkAccount(emptyAccount());
|
setBulkAccount(emptyAccount());
|
||||||
|
|
@ -454,7 +512,7 @@ export function NePage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
isDisabled={bulkByTagMutation.isPending}
|
isDisabled={bulkBusy || !canWriteNe}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setBulkTagAction("test");
|
setBulkTagAction("test");
|
||||||
setBulkTagModalOpen(true);
|
setBulkTagModalOpen(true);
|
||||||
|
|
@ -466,21 +524,32 @@ export function NePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{stats ? (
|
{stats ? (
|
||||||
|
<>
|
||||||
|
<p className="muted text-sm">{t("managedNe.stats.cardFilterHint")}</p>
|
||||||
<div className="ne-stats-card__tag-cards">
|
<div className="ne-stats-card__tag-cards">
|
||||||
{tagCardItems.map((x) => (
|
{tagCardItems.map((x) => (
|
||||||
<div key={x.key} className="ne-tag-card">
|
<button
|
||||||
<div className="ne-tag-card__title">{x.title}</div>
|
type="button"
|
||||||
<div className="ne-tag-card__total">{t("managedNe.stats.total", { n: x.total })}</div>
|
key={x.key}
|
||||||
<div className="ne-tag-card__pills">
|
className={`ne-tag-card${activeTagCard === x.key ? " is-active" : ""}`}
|
||||||
{(["pass", "fail", "testing", "unknown"] as const).map((s) => (
|
aria-pressed={activeTagCard === x.key}
|
||||||
|
onClick={() => applyTagCard(x.key)}
|
||||||
|
>
|
||||||
|
<span className="ne-tag-card__title">{x.title}</span>
|
||||||
|
<span className="ne-tag-card__total">{t("managedNe.stats.total", { n: x.total })}</span>
|
||||||
|
<span className="ne-tag-card__pills">
|
||||||
|
{CONNECT_STATUSES.map((s) => (
|
||||||
<span key={s} className={`pt-list-status ${connectStatusClass(s)}`}>
|
<span key={s} className={`pt-list-status ${connectStatusClass(s)}`}>
|
||||||
{t(`managedNe.stats.${s}`)} {x.by_status[s] ?? 0}
|
{t(`managedNe.stats.${s}`)} {x.by_status[s] ?? 0}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : statsQuery.isError ? (
|
||||||
|
<p className="error-text">{errText(statsQuery.error)}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="ne-stats-card__loading">{t("managedNe.stats.loadingIds")}</p>
|
<p className="ne-stats-card__loading">{t("managedNe.stats.loadingIds")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -525,7 +594,7 @@ export function NePage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
void downloadManagedNeImportTemplate("xlsx").catch((err) => showError(String(err)));
|
void downloadManagedNeImportTemplate("xlsx").catch(fail);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("managedNe.downloadTemplate")}
|
{t("managedNe.downloadTemplate")}
|
||||||
|
|
@ -552,7 +621,7 @@ export function NePage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
isDisabled={selected.length === 0 || connectMutation.isPending}
|
isDisabled={selected.length === 0 || connectMutation.isPending || !canWriteNe}
|
||||||
onPress={() => connectMutation.mutate(selected)}
|
onPress={() => connectMutation.mutate(selected)}
|
||||||
>
|
>
|
||||||
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
|
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
|
||||||
|
|
@ -562,10 +631,6 @@ export function NePage() {
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
isDisabled={selected.length === 0 || batchHopMutation.isPending || !canWriteNe}
|
isDisabled={selected.length === 0 || batchHopMutation.isPending || !canWriteNe}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (selected.length === 0) {
|
|
||||||
showError(t("managedNe.hop.selectRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBatchHop(emptyHopProxyFields());
|
setBatchHop(emptyHopProxyFields());
|
||||||
setBatchHopOpen(true);
|
setBatchHopOpen(true);
|
||||||
}}
|
}}
|
||||||
|
|
@ -577,10 +642,6 @@ export function NePage() {
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
isDisabled={selected.length === 0 || batchAccountMutation.isPending || !canWriteNe}
|
isDisabled={selected.length === 0 || batchAccountMutation.isPending || !canWriteNe}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (selected.length === 0) {
|
|
||||||
showError(t("managedNe.account.selectRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBatchAccount(emptyAccount());
|
setBatchAccount(emptyAccount());
|
||||||
setBatchAccountOpen(true);
|
setBatchAccountOpen(true);
|
||||||
}}
|
}}
|
||||||
|
|
@ -592,17 +653,16 @@ export function NePage() {
|
||||||
variant="danger"
|
variant="danger"
|
||||||
isDisabled={selected.length === 0 || batchDeleteMutation.isPending || !canWriteNe}
|
isDisabled={selected.length === 0 || batchDeleteMutation.isPending || !canWriteNe}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (selected.length === 0) {
|
|
||||||
showError(t("managedNe.batchDeleteSelectRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.confirm(t("managedNe.batchDeleteConfirm", { n: selected.length }))) return;
|
if (!window.confirm(t("managedNe.batchDeleteConfirm", { n: selected.length }))) return;
|
||||||
batchDeleteMutation.mutate(selected);
|
batchDeleteMutation.mutate(selected);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{batchDeleteMutation.isPending ? t("managedNe.batchDeleting") : t("managedNe.batchDelete")}
|
{batchDeleteMutation.isPending ? t("managedNe.batchDeleting") : t("managedNe.batchDelete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="tertiary" onPress={() => invalidateList()}>
|
<Button size="sm" variant="tertiary" isDisabled={!columns.active} onPress={columns.reset}>
|
||||||
|
{t("common.clearFilters")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="tertiary" onPress={() => void invalidateList()}>
|
||||||
{t("common.refresh")}
|
{t("common.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -611,102 +671,52 @@ export function NePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-list">
|
<div className="pt-list">
|
||||||
<div className="filter-inline">
|
{selected.length ? (
|
||||||
<Input
|
<div className="ne-selection-meta">
|
||||||
value={keyword}
|
<span>{t("managedNe.selectedCount", { n: selected.length })}</span>
|
||||||
placeholder={t("managedNe.keywordPh")}
|
<Button size="sm" variant="ghost" onPress={() => setSelected([])}>
|
||||||
onChange={(e) => {
|
{t("managedNe.clearSelection")}
|
||||||
setKeyword(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FieldSelect
|
|
||||||
value={vendorFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setVendorFilter(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">{t("managedNe.allVendors")}</option>
|
|
||||||
{vendors.map((v) => (
|
|
||||||
<option key={v} value={v}>
|
|
||||||
{v}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</FieldSelect>
|
|
||||||
<FieldSelect
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStatusFilter(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">{t("managedNe.allConnectStatus")}</option>
|
|
||||||
<option value="unknown">unknown</option>
|
|
||||||
<option value="testing">testing</option>
|
|
||||||
<option value="pass">pass</option>
|
|
||||||
<option value="fail">fail</option>
|
|
||||||
</FieldSelect>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="tertiary"
|
|
||||||
isDisabled={!hasFilters}
|
|
||||||
onPress={() => {
|
|
||||||
setKeyword("");
|
|
||||||
setVendorFilter("");
|
|
||||||
setStatusFilter("");
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("common.clearFilters")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{listQuery.isFetching ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||||
|
{listQuery.isError ? (
|
||||||
|
<p className="error-text">{t("managedNe.loadFailed", { error: errText(listQuery.error) })}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
|
||||||
|
|
||||||
{!listItems.length && !listQuery.isLoading ? (
|
|
||||||
<div className="pt-list-empty">
|
|
||||||
<p>{t("common.empty")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="pt-list-table-wrap">
|
<div className="pt-list-table-wrap">
|
||||||
<table className="data-table pt-list-table">
|
<table className="data-table pt-list-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
<Checkbox
|
<input
|
||||||
isSelected={allSelected}
|
type="checkbox"
|
||||||
onChange={toggleSelectAll}
|
|
||||||
aria-label="select all"
|
aria-label="select all"
|
||||||
>
|
ref={(input) => { if (input) input.indeterminate = someSelected; }}
|
||||||
<Checkbox.Control>
|
checked={allSelected}
|
||||||
<Checkbox.Indicator />
|
disabled={!pageIds.length}
|
||||||
</Checkbox.Control>
|
onChange={toggleSelectAll}
|
||||||
</Checkbox>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th>{t("managedNe.col.name")}</th>
|
<th>{columns.column("name", t("managedNe.col.name"))}</th>
|
||||||
<th>{t("managedNe.col.vendor")}</th>
|
<th>{columns.column("vendor", t("managedNe.col.vendor"))}</th>
|
||||||
<th>{t("managedNe.col.deviceType")}</th>
|
<th>{columns.column("deviceType", t("managedNe.col.deviceType"))}</th>
|
||||||
<th>{t("managedNe.col.source")}</th>
|
<th>{columns.column("source", t("managedNe.col.source"), sourceLabel)}</th>
|
||||||
<th>{t("managedNe.col.tags")}</th>
|
<th>{columns.column("tags", t("managedNe.col.tags"))}</th>
|
||||||
<th>{t("managedNe.col.ip")}</th>
|
<th>{columns.column("ip", t("managedNe.col.ip"))}</th>
|
||||||
<th>{t("managedNe.col.user")}</th>
|
<th>{columns.column("username", t("managedNe.col.user"))}</th>
|
||||||
<th>{t("managedNe.col.connect")}</th>
|
<th>{columns.column("connectStatus", t("managedNe.col.connect"), statusLabel)}</th>
|
||||||
<th>{t("managedNe.col.testedAt")}</th>
|
<th>{t("managedNe.col.testedAt")}</th>
|
||||||
<th>{t("managedNe.col.actions")}</th>
|
<th>{t("managedNe.col.actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{listItems.map((row) => {
|
{listItems.map((row) => (
|
||||||
const srcKey = managedSourceKey(row.source);
|
|
||||||
const srcLabel = srcKey
|
|
||||||
? t(`managedNe.source.${srcKey}`)
|
|
||||||
: String(row.source || "").trim() || t("managedNe.source.manual");
|
|
||||||
return (
|
|
||||||
<tr key={row.id}>
|
<tr key={row.id}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
aria-label={row.name || row.ip_address}
|
||||||
checked={selected.includes(row.id)}
|
checked={selected.includes(row.id)}
|
||||||
onChange={() => toggleSelect(row.id)}
|
onChange={() => toggleSelect(row.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -715,16 +725,16 @@ export function NePage() {
|
||||||
<td>{row.vendor}</td>
|
<td>{row.vendor}</td>
|
||||||
<td>{row.device_type}</td>
|
<td>{row.device_type}</td>
|
||||||
<td title={row.source_ref || undefined}>
|
<td title={row.source_ref || undefined}>
|
||||||
<span className="table-tag">{srcLabel}</span>
|
<span className="table-tag">{sourceLabel(row.source || "")}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{row.tags
|
{row.tags
|
||||||
? row.tags.split(/\s+/).map((tag) => (
|
? row.tags.split(/\s+/).filter(Boolean).map((tag) => (
|
||||||
<span key={tag} className="table-tag">
|
<span key={tag} className="table-tag">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
: t("common.empty")}
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="pt-list-num">
|
<td className="pt-list-num">
|
||||||
{row.ip_address}:{row.port}/{row.protocol}
|
{row.ip_address}:{row.port}/{row.protocol}
|
||||||
|
|
@ -739,19 +749,19 @@ export function NePage() {
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>{row.username}</td>
|
<td>{row.username || "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
className={`pt-list-status ${connectStatusClass(row.connect_status)}`}
|
className={`pt-list-status ${connectStatusClass(row.connect_status)}`}
|
||||||
title={row.connect_message || undefined}
|
title={row.connect_message || undefined}
|
||||||
>
|
>
|
||||||
{row.connect_status}
|
{statusLabel(row.connect_status)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="pt-list-time">
|
<td className="pt-list-time">
|
||||||
{row.connect_tested_at
|
{row.connect_tested_at
|
||||||
? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })
|
? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })
|
||||||
: t("common.empty")}
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="btn-row pt-list-actions table-actions">
|
<div className="btn-row pt-list-actions table-actions">
|
||||||
|
|
@ -786,7 +796,7 @@ export function NePage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
isDisabled={!canWriteNe}
|
isDisabled={!canWriteNe || deleteMutation.isPending}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
|
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
|
||||||
}}
|
}}
|
||||||
|
|
@ -796,52 +806,33 @@ export function NePage() {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
{!listItems.length && !listQuery.isFetching && !listQuery.isError ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11}>
|
||||||
|
<div className="pt-list-empty">
|
||||||
|
<p>{columns.active ? t("managedNe.emptyFiltered") : t("common.empty")}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="pager pt-list-pager">
|
<ListPager
|
||||||
<span className="muted">
|
page={page}
|
||||||
{t("common.pagerMeta", {
|
pages={pages}
|
||||||
total: String(total),
|
total={total}
|
||||||
page: String(page),
|
pageSize={pageSize}
|
||||||
pages: String(pages),
|
pageSizeOptions={[10, 20, 50, 100, 200]}
|
||||||
})}
|
onPageChange={setPage}
|
||||||
</span>
|
onPageSizeChange={(size) => {
|
||||||
<div className="btn-row">
|
setPageSize(size);
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
isDisabled={page <= 1}
|
|
||||||
onPress={() => setPage(Math.max(1, page - 1))}
|
|
||||||
>
|
|
||||||
{t("common.prevPage")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
isDisabled={page >= pages}
|
|
||||||
onPress={() => setPage(page + 1)}
|
|
||||||
>
|
|
||||||
{t("common.nextPage")}
|
|
||||||
</Button>
|
|
||||||
<FieldSelect
|
|
||||||
value={String(pageSize)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPageSize(Number(e.target.value) || 10);
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
disabled={listQuery.isLoading}
|
||||||
<option value="10">{perPage(10)}</option>
|
/>
|
||||||
<option value="20">{perPage(20)}</option>
|
|
||||||
<option value="50">{perPage(50)}</option>
|
|
||||||
<option value="100">{perPage(100)}</option>
|
|
||||||
</FieldSelect>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -876,7 +867,13 @@ export function NePage() {
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body className="flex flex-col gap-3">
|
<Modal.Body className="flex flex-col gap-3">
|
||||||
<p className="text-sm text-muted">{t("managedNe.hop.batchHint", { n: selected.length })}</p>
|
<p className="text-sm text-muted">{t("managedNe.hop.batchHint", { n: selected.length })}</p>
|
||||||
<HopProxyFields value={batchHop} onChange={(patch) => setBatchHop((prev) => ({ ...prev, ...patch }))} />
|
<p className="text-sm text-muted">{t("managedNe.hop.batchPasswordHint")}</p>
|
||||||
|
<HopProxyFields
|
||||||
|
value={batchHop}
|
||||||
|
onChange={(patch) => setBatchHop((prev) => ({ ...prev, ...patch }))}
|
||||||
|
hopPasswordRequired={false}
|
||||||
|
hopPasswordOptional
|
||||||
|
/>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="tertiary" onPress={() => setBatchHopOpen(false)}>
|
<Button variant="tertiary" onPress={() => setBatchHopOpen(false)}>
|
||||||
|
|
@ -894,10 +891,6 @@ export function NePage() {
|
||||||
showError(t("managedNe.hop.userRequired"));
|
showError(t("managedNe.hop.userRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!batchHop.hop_password && batchHop.hop_target_auth_mode !== "bastion_managed") {
|
|
||||||
showError(t("managedNe.hop.passwordRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
batchHopMutation.mutate();
|
batchHopMutation.mutate();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -964,7 +957,7 @@ export function NePage() {
|
||||||
<AppModalShell
|
<AppModalShell
|
||||||
open={bulkTagModalOpen}
|
open={bulkTagModalOpen}
|
||||||
onClose={() => setBulkTagModalOpen(false)}
|
onClose={() => setBulkTagModalOpen(false)}
|
||||||
dismissible={!bulkByTagMutation.isPending}
|
dismissible={!bulkBusy}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
|
|
@ -978,7 +971,7 @@ export function NePage() {
|
||||||
{bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""}
|
{bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""}
|
||||||
{bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}
|
{bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}
|
||||||
</Modal.Heading>
|
</Modal.Heading>
|
||||||
<Modal.CloseTrigger isDisabled={bulkByTagMutation.isPending} />
|
<Modal.CloseTrigger isDisabled={bulkBusy} />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body className="flex flex-col gap-3">
|
<Modal.Body className="flex flex-col gap-3">
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
|
|
@ -995,10 +988,18 @@ export function NePage() {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
|
{bulkScopeCount !== undefined ? (
|
||||||
|
<p className="text-sm text-muted">{t("managedNe.stats.scopeCount", { n: bulkScopeCount })}</p>
|
||||||
|
) : null}
|
||||||
{bulkTagAction === "proxy" ? (
|
{bulkTagAction === "proxy" ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted">{t("managedNe.hop.batchHint", { n: "?" })}</p>
|
<p className="text-sm text-muted">{t("managedNe.hop.batchPasswordHint")}</p>
|
||||||
<HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} />
|
<HopProxyFields
|
||||||
|
value={bulkHop}
|
||||||
|
onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))}
|
||||||
|
hopPasswordRequired={false}
|
||||||
|
hopPasswordOptional
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : bulkTagAction === "account" ? (
|
) : bulkTagAction === "account" ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1026,42 +1027,18 @@ export function NePage() {
|
||||||
</TextField>
|
</TextField>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<p className="text-sm text-muted">{t("managedNe.stats.confirm", { n: "?" })}</p>
|
|
||||||
)}
|
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="tertiary" onPress={() => setBulkTagModalOpen(false)}>
|
<Button variant="tertiary" isDisabled={bulkBusy} onPress={() => setBulkTagModalOpen(false)}>
|
||||||
{t("managedNe.form.cancel")}
|
{t("managedNe.form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
isDisabled={bulkByTagMutation.isPending}
|
isDisabled={bulkBusy || bulkScopeCount === 0}
|
||||||
onPress={() => {
|
onPress={() => void runBulkByTag()}
|
||||||
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 (!bulkHop.hop_password && bulkHop.hop_target_auth_mode !== "bastion_managed") {
|
|
||||||
showError(t("managedNe.hop.passwordRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bulkTagAction === "account") {
|
|
||||||
if (!bulkAccount.username.trim() && !bulkAccount.password) {
|
|
||||||
showError(t("managedNe.account.usernameOrPasswordRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{bulkByTagMutation.isPending
|
{bulkBusy
|
||||||
? t("managedNe.stats.loadingIds")
|
? t("managedNe.stats.loadingIds")
|
||||||
: bulkTagAction === "proxy"
|
: bulkTagAction === "proxy"
|
||||||
? t("managedNe.hop.apply")
|
? t("managedNe.hop.apply")
|
||||||
|
|
@ -1072,6 +1049,50 @@ export function NePage() {
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</AppModalShell>
|
</AppModalShell>
|
||||||
|
|
||||||
|
<AppModalShell open={importResult !== null} onClose={() => setImportResult(null)} size="md">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Heading>{t("managedNe.importResult.title")}</Modal.Heading>
|
||||||
|
<Modal.CloseTrigger />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body className="flex flex-col gap-3">
|
||||||
|
{importResult ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">
|
||||||
|
{t("managedNe.importResult.done", {
|
||||||
|
inserted: importResult.inserted,
|
||||||
|
updated: importResult.updated,
|
||||||
|
failed: importResult.failed.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<h3 className="text-sm">{t("managedNe.importResult.failedTitle", { n: importResult.failed.length })}</h3>
|
||||||
|
<div className="ne-import-failures">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("managedNe.importResult.row")}</th>
|
||||||
|
<th>{t("managedNe.importResult.reason")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{importResult.failed.map((item) => (
|
||||||
|
<tr key={`${item.row}:${item.reason}`}>
|
||||||
|
<td className="pt-list-num">{item.row}</td>
|
||||||
|
<td>{errText(new Error(item.reason))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="primary" onPress={() => setImportResult(null)}>
|
||||||
|
{t("managedNe.importResult.close")}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</AppModalShell>
|
||||||
|
|
||||||
<ManagedNeConnectDetailDialog
|
<ManagedNeConnectDetailDialog
|
||||||
row={connectDetailRow}
|
row={connectDetailRow}
|
||||||
onClose={() => setConnectDetailRow(null)}
|
onClose={() => setConnectDetailRow(null)}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,13 @@ import { FieldSelect } from "../../../components/ui/FieldSelect";
|
||||||
import { queryKeys } from "../../../constants/queryKeys";
|
import { queryKeys } from "../../../constants/queryKeys";
|
||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import { patchHopVendorChange } from "../lib/hopProxy";
|
import { patchHopVendorChange } from "../lib/hopProxy";
|
||||||
|
import { managedNeErrorText } from "../lib/errors";
|
||||||
import type { ManagedNeItem } from "../../../types";
|
import type { ManagedNeItem } from "../../../types";
|
||||||
import {
|
import {
|
||||||
applyHopTemplate,
|
applyHopTemplate,
|
||||||
buildManagedNeSaveBody,
|
buildManagedNeSaveBody,
|
||||||
|
deviceTypeForVendor,
|
||||||
|
isValidMgmtAddress,
|
||||||
emptyManagedNeForm,
|
emptyManagedNeForm,
|
||||||
formFromManagedNe,
|
formFromManagedNe,
|
||||||
allowsOpenExecPolicy,
|
allowsOpenExecPolicy,
|
||||||
|
|
@ -37,6 +40,9 @@ export function ManagedNeFormDialog({
|
||||||
}: ManagedNeFormDialogProps) {
|
}: ManagedNeFormDialogProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [form, setForm] = useState<ManagedNeFormState>(emptyManagedNeForm);
|
const [form, setForm] = useState<ManagedNeFormState>(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({
|
const metaQuery = useQuery({
|
||||||
queryKey: queryKeys.managedNeMeta,
|
queryKey: queryKeys.managedNeMeta,
|
||||||
|
|
@ -45,16 +51,6 @@ export function ManagedNeFormDialog({
|
||||||
enabled: open,
|
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 vendors = metaQuery.data?.vendors ?? [];
|
||||||
const execPolicyEnabled = Boolean(metaQuery.data?.exec_policy_enabled);
|
const execPolicyEnabled = Boolean(metaQuery.data?.exec_policy_enabled);
|
||||||
|
|
@ -65,6 +61,15 @@ export function ManagedNeFormDialog({
|
||||||
return base;
|
return base;
|
||||||
}, [metaQuery.data?.device_types, form.device_type]);
|
}, [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({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const body = buildManagedNeSaveBody(form, {
|
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 (
|
return (
|
||||||
<AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">
|
<AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
|
|
@ -113,12 +129,13 @@ export function ManagedNeFormDialog({
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
const next = { ...prev, vendor };
|
const next = { ...prev, vendor };
|
||||||
const dt = String(prev.device_type || "").trim().toLowerCase();
|
const dt = String(prev.device_type || "").trim().toLowerCase();
|
||||||
if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
|
const suggested = deviceTypeForVendor(vendor);
|
||||||
if (vendor === "ZTE") next.device_type = "zte_zxros";
|
// Follow the vendor while the type is a placeholder or still the old vendor's default.
|
||||||
else if (vendor === "Huawei") next.device_type = "huawei";
|
const followVendor = !dt || dt === deviceTypeForVendor(prev.vendor)
|
||||||
else if (vendor === "Cisco") next.device_type = "cisco_ios";
|
|| ((dt === "generic" || dt === "other" || dt === "linux") && suggested !== "generic");
|
||||||
else if (vendor === "Juniper") next.device_type = "juniper_junos";
|
if (followVendor) {
|
||||||
else if (vendor === "Nokia") next.device_type = "nokia_sros";
|
next.device_type = suggested;
|
||||||
|
if (!allowsOpenExecPolicy(suggested)) next.exec_policy = "readonly";
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
@ -175,25 +192,39 @@ export function ManagedNeFormDialog({
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
isRequired
|
isRequired
|
||||||
|
isInvalid={submitted && Boolean(fieldErrors.ip)}
|
||||||
value={form.ip_address}
|
value={form.ip_address}
|
||||||
onChange={(ip_address) => setForm({ ...form, ip_address })}
|
onChange={(ip_address) => setForm({ ...form, ip_address })}
|
||||||
>
|
>
|
||||||
<Label>{t("managedNe.col.ip")}</Label>
|
<Label>{t("managedNe.col.ip")}</Label>
|
||||||
<Input />
|
<Input placeholder="192.0.2.10 / pe-01.example.net" />
|
||||||
|
{submitted && fieldErrors.ip ? <span className="form-field-hint error-text">{fieldErrors.ip}</span> : null}
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
type="number"
|
isRequired
|
||||||
value={String(form.port)}
|
inputMode="numeric"
|
||||||
onChange={(port) => setForm({ ...form, port: Number(port) || 22 })}
|
isInvalid={submitted && Boolean(fieldErrors.port)}
|
||||||
|
value={portDraft}
|
||||||
|
onChange={(value) => {
|
||||||
|
setPortDraft(value);
|
||||||
|
if (/^\d+$/.test(value.trim())) setForm((prev) => ({ ...prev, port: Number(value.trim()) }));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Label>{t("managedNe.col.port")}</Label>
|
<Label>{t("managedNe.col.port")}</Label>
|
||||||
<Input />
|
<Input />
|
||||||
|
{submitted && fieldErrors.port ? <span className="form-field-hint error-text">{fieldErrors.port}</span> : null}
|
||||||
</TextField>
|
</TextField>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
label={t("managedNe.col.protocol")}
|
label={t("managedNe.col.protocol")}
|
||||||
value={form.protocol}
|
value={form.protocol}
|
||||||
onChange={(e) => 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));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="ssh">ssh</option>
|
<option value="ssh">ssh</option>
|
||||||
<option value="telnet">telnet</option>
|
<option value="telnet">telnet</option>
|
||||||
|
|
@ -201,11 +232,13 @@ export function ManagedNeFormDialog({
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
isRequired
|
isRequired
|
||||||
|
isInvalid={submitted && Boolean(fieldErrors.username)}
|
||||||
value={form.username}
|
value={form.username}
|
||||||
onChange={(username) => setForm({ ...form, username })}
|
onChange={(username) => setForm({ ...form, username })}
|
||||||
>
|
>
|
||||||
<Label>{t("managedNe.col.user")}</Label>
|
<Label>{t("managedNe.col.user")}</Label>
|
||||||
<Input />
|
<Input />
|
||||||
|
{submitted && fieldErrors.username ? <span className="form-field-hint error-text">{fieldErrors.username}</span> : null}
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -279,7 +312,7 @@ export function ManagedNeFormDialog({
|
||||||
{saveMutation.isError ? (
|
{saveMutation.isError ? (
|
||||||
<Alert status="danger">
|
<Alert status="danger">
|
||||||
<Alert.Content>
|
<Alert.Content>
|
||||||
<Alert.Description>{String(saveMutation.error)}</Alert.Description>
|
<Alert.Description>{managedNeErrorText(saveMutation.error, t)}</Alert.Description>
|
||||||
</Alert.Content>
|
</Alert.Content>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -291,7 +324,11 @@ export function ManagedNeFormDialog({
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
isDisabled={saveMutation.isPending}
|
isDisabled={saveMutation.isPending}
|
||||||
onPress={() => saveMutation.mutate()}
|
onPress={() => {
|
||||||
|
setSubmitted(true);
|
||||||
|
if (hasFieldErrors) return;
|
||||||
|
saveMutation.mutate();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
|
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
19
web/src/features/managed-ne/lib/errors.ts
Normal file
19
web/src/features/managed-ne/lib/errors.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { formatErr } from "../../../services/api";
|
||||||
|
|
||||||
|
type Translate = (key: string, vars?: Record<string, string | number>) => 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, "");
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,26 @@ export type ManagedNeFormState = {
|
||||||
exec_policy: "readonly" | "linux_shell" | "unrestricted";
|
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 {
|
export function deviceTypeForVendor(vendor: string): string {
|
||||||
if (vendor === "ZTE") return "zte_zxros";
|
if (vendor === "ZTE") return "zte_zxros";
|
||||||
if (vendor === "Huawei") return "huawei";
|
if (vendor === "Huawei") return "huawei";
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,18 @@ import { useMemo, type ReactNode } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@heroui/react";
|
import { Button } from "@heroui/react";
|
||||||
import { useI18n } from "../../i18n";
|
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 { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules";
|
||||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { fetchFabricSummary, fetchManagedNeStats, fetchOpsTasks, fetchRuntimeMetrics, fetchTopologyTree, type OpsTaskItem } from "../../services/api";
|
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 { isUmeWorldContainer, isWorldDrillFolder } from "../topology/lib/treeUtils";
|
||||||
import type { TopologyTreeFolderItem } from "../../types";
|
import type { TopologyTreeFolderItem } from "../../types";
|
||||||
|
|
||||||
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"];
|
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"];
|
||||||
|
|
||||||
type DisplaySection = {
|
|
||||||
key: "operations" | "system";
|
|
||||||
mods: ModuleDefinition[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatCount(n: number | string): string {
|
function formatCount(n: number | string): string {
|
||||||
if (typeof n === "string") return n;
|
if (typeof n === "string") return n;
|
||||||
if (!Number.isFinite(n)) return "—";
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
|
@ -32,20 +28,13 @@ function formatAgeSec(sec: number | null | undefined): string {
|
||||||
const n = Math.max(0, sec);
|
const n = Math.max(0, sec);
|
||||||
if (n < 60) return `${Math.round(n)}s`;
|
if (n < 60) return `${Math.round(n)}s`;
|
||||||
if (n < 3600) return `${Math.max(1, Math.round(n / 60))}m`;
|
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 {
|
function formatCompactTime(value: string | null | undefined): string {
|
||||||
if (!value) return "—";
|
const date = parseApiTime(value);
|
||||||
const full = formatSystemTime(value);
|
if (!date) return "—";
|
||||||
if (!full) return "—";
|
return `${date.toLocaleDateString(undefined, { month: "numeric", day: "numeric" })} ${date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })}`;
|
||||||
// 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 FLEET_TONE_COLOR: Record<"ok" | "mid" | "warn" | "bad" | "muted", string> = {
|
const FLEET_TONE_COLOR: Record<"ok" | "mid" | "warn" | "bad" | "muted", string> = {
|
||||||
|
|
@ -121,7 +110,8 @@ function FleetDonut({
|
||||||
<div
|
<div
|
||||||
className="wb-fleet__donut"
|
className="wb-fleet__donut"
|
||||||
style={{ background: buildDonutGradient(parts) }}
|
style={{ background: buildDonutGradient(parts) }}
|
||||||
aria-hidden="true"
|
role="img"
|
||||||
|
aria-label={`${center} ${unit ?? ""}`.trim()}
|
||||||
>
|
>
|
||||||
<div className="wb-fleet__donut-hole">
|
<div className="wb-fleet__donut-hole">
|
||||||
<div className="wb-fleet__donut-value">{center}</div>
|
<div className="wb-fleet__donut-value">{center}</div>
|
||||||
|
|
@ -131,115 +121,15 @@ function FleetDonut({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCHED_DISC_COLORS = ["#38bdf8", "#22d3ee", "#818cf8", "#34d399"];
|
|
||||||
|
|
||||||
function polarPoint(cx: number, cy: number, r: number, deg: number): [number, number] {
|
|
||||||
const rad = ((deg - 90) * Math.PI) / 180;
|
|
||||||
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function donutSlicePath(
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
rOuter: number,
|
|
||||||
rInner: number,
|
|
||||||
startDeg: number,
|
|
||||||
endDeg: number,
|
|
||||||
): string {
|
|
||||||
const sweep = endDeg - startDeg;
|
|
||||||
const large = sweep > 180 ? 1 : 0;
|
|
||||||
const [x1, y1] = polarPoint(cx, cy, rOuter, startDeg);
|
|
||||||
const [x2, y2] = polarPoint(cx, cy, rOuter, endDeg);
|
|
||||||
const [x3, y3] = polarPoint(cx, cy, rInner, endDeg);
|
|
||||||
const [x4, y4] = polarPoint(cx, cy, rInner, startDeg);
|
|
||||||
return [
|
|
||||||
`M ${x1.toFixed(2)} ${y1.toFixed(2)}`,
|
|
||||||
`A ${rOuter} ${rOuter} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`,
|
|
||||||
`L ${x3.toFixed(2)} ${y3.toFixed(2)}`,
|
|
||||||
`A ${rInner} ${rInner} 0 ${large} 0 ${x4.toFixed(2)} ${y4.toFixed(2)}`,
|
|
||||||
"Z",
|
|
||||||
].join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
type SchedDiscItem = {
|
type SchedDiscItem = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
on: boolean;
|
on: boolean | null;
|
||||||
age: string;
|
age: string;
|
||||||
moduleId: string;
|
moduleId: string;
|
||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SchedDartDisc({
|
|
||||||
items,
|
|
||||||
center,
|
|
||||||
unit,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
items: SchedDiscItem[];
|
|
||||||
center: ReactNode;
|
|
||||||
unit?: ReactNode;
|
|
||||||
onSelect: (item: SchedDiscItem) => void;
|
|
||||||
}) {
|
|
||||||
// Match FleetDonut footprint (84px) so the three FLEET cards share one visual rhythm.
|
|
||||||
const size = 84;
|
|
||||||
const cx = size / 2;
|
|
||||||
const cy = size / 2;
|
|
||||||
const rOuter = 39;
|
|
||||||
const rInner = 25;
|
|
||||||
const gap = items.length > 1 ? 2.5 : 0;
|
|
||||||
const slice = items.length > 0 ? 360 / items.length : 360;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="wb-fleet__donut wb-fleet__donut--dart">
|
|
||||||
<svg
|
|
||||||
className="wb-fleet__dart"
|
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
role="group"
|
|
||||||
aria-label={typeof unit === "string" ? unit : "schedulers"}
|
|
||||||
>
|
|
||||||
{items.map((item, i) => {
|
|
||||||
const start = i * slice + gap / 2;
|
|
||||||
const end = (i + 1) * slice - gap / 2;
|
|
||||||
const color = item.on
|
|
||||||
? SCHED_DISC_COLORS[i % SCHED_DISC_COLORS.length]
|
|
||||||
: "#475569";
|
|
||||||
return (
|
|
||||||
<path
|
|
||||||
key={item.key}
|
|
||||||
className={`wb-fleet__dart-slice${item.on ? " is-on" : ""}`}
|
|
||||||
d={donutSlicePath(cx, cy, rOuter, rInner, start, end)}
|
|
||||||
fill={color}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={item.label}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelect(item);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelect(item);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<title>{`${item.label} · ${item.on ? "ON" : "OFF"} · ${item.age}`}</title>
|
|
||||||
</path>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
<div className="wb-fleet__donut-hole wb-fleet__donut-hole--dart">
|
|
||||||
<div className="wb-fleet__donut-value">{center}</div>
|
|
||||||
{unit ? <div className="wb-fleet__donut-unit">{unit}</div> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function topLevelRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
|
function topLevelRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
|
||||||
if (!root) return [];
|
if (!root) return [];
|
||||||
return (root.children || []).filter((c) => {
|
return (root.children || []).filter((c) => {
|
||||||
|
|
@ -558,17 +448,20 @@ export function WorkbenchPage() {
|
||||||
return slices;
|
return slices;
|
||||||
}, [topoTreeQuery.data, fabricNodes]);
|
}, [topoTreeQuery.data, fabricNodes]);
|
||||||
|
|
||||||
const memValue =
|
const metricsPlaceholder = metricsQuery.isLoading ? "…" : "—";
|
||||||
memTotal > 0 ? `${memPct}% · ${formatBytes(memUsed)}` : `${memPct}%`;
|
const hostOk = Boolean(host && !host.error);
|
||||||
const storageValue = storageOk ? formatBytesCompact(storageUsed) : "—";
|
const memValue = hostOk
|
||||||
|
? memTotal > 0 ? `${memPct}% · ${formatBytes(memUsed)}` : `${memPct}%`
|
||||||
|
: metricsPlaceholder;
|
||||||
|
const storageValue = storageOk ? formatBytesCompact(storageUsed) : metricsPlaceholder;
|
||||||
|
|
||||||
const dash = (v: number | string) => (canReadFleet ? v : "—");
|
const dash = (v: number | string) => (canReadFleet && neStatsQuery.data ? v : neStatsQuery.isLoading ? "…" : "—");
|
||||||
const tickAge = (sec?: number | null) => formatAgeSec(sec ?? null);
|
const tickAge = (sec?: number | null) => formatAgeSec(sec ?? null);
|
||||||
|
|
||||||
const schedRows: SchedDiscItem[] = [
|
const schedRows: SchedDiscItem[] = [
|
||||||
{
|
{
|
||||||
key: "config",
|
key: "config",
|
||||||
on: schedConfigOn,
|
on: schedulers && !metricsQuery.isError ? schedConfigOn : null,
|
||||||
label: t("workbench.fleetSchedConfig"),
|
label: t("workbench.fleetSchedConfig"),
|
||||||
age: tickAge(schedulers?.config_sync?.last_tick_age_sec),
|
age: tickAge(schedulers?.config_sync?.last_tick_age_sec),
|
||||||
moduleId: "network",
|
moduleId: "network",
|
||||||
|
|
@ -576,7 +469,7 @@ export function WorkbenchPage() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "lldp",
|
key: "lldp",
|
||||||
on: schedLldpOn,
|
on: schedulers && !metricsQuery.isError ? schedLldpOn : null,
|
||||||
label: t("workbench.fleetSchedLldp"),
|
label: t("workbench.fleetSchedLldp"),
|
||||||
age: tickAge(schedulers?.lldp_collect?.last_tick_age_sec),
|
age: tickAge(schedulers?.lldp_collect?.last_tick_age_sec),
|
||||||
moduleId: "network",
|
moduleId: "network",
|
||||||
|
|
@ -584,7 +477,7 @@ export function WorkbenchPage() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "port",
|
key: "port",
|
||||||
on: schedPortOn,
|
on: schedulers && !metricsQuery.isError ? schedPortOn : null,
|
||||||
label: t("workbench.fleetSchedPort"),
|
label: t("workbench.fleetSchedPort"),
|
||||||
age: tickAge(schedulers?.port_traffic?.last_tick_age_sec),
|
age: tickAge(schedulers?.port_traffic?.last_tick_age_sec),
|
||||||
moduleId: "network",
|
moduleId: "network",
|
||||||
|
|
@ -592,7 +485,7 @@ export function WorkbenchPage() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "ne",
|
key: "ne",
|
||||||
on: schedNeOn,
|
on: schedulers && !metricsQuery.isError ? schedNeOn : null,
|
||||||
label: t("workbench.fleetSchedNeCollect"),
|
label: t("workbench.fleetSchedNeCollect"),
|
||||||
age: tickAge(schedulers?.ne_collect?.last_tick_age_sec),
|
age: tickAge(schedulers?.ne_collect?.last_tick_age_sec),
|
||||||
moduleId: "network",
|
moduleId: "network",
|
||||||
|
|
@ -600,80 +493,45 @@ export function WorkbenchPage() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const schedOnCount = schedRows.filter((r) => r.on).length;
|
const schedOnCount = schedRows.filter((r) => r.on).length;
|
||||||
|
const refreshing = [metricsQuery, opsQuery, neStatsQuery, fabricQuery, topoTreeQuery].some((query) => query.isFetching);
|
||||||
|
const refreshConsole = () => {
|
||||||
|
void metricsQuery.refetch();
|
||||||
|
void opsQuery.refetch();
|
||||||
|
if (canReadFleet) {
|
||||||
|
void neStatsQuery.refetch();
|
||||||
|
void fabricQuery.refetch();
|
||||||
|
void topoTreeQuery.refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const healthKnown = Boolean(metrics && hostOk && !metricsQuery.isError);
|
||||||
|
const healthTone = !healthKnown ? "is-unknown" : healthBusy ? "is-busy" : "is-ok";
|
||||||
|
const healthLabel = !healthKnown ? t("workbench.kpi.healthUnknown") : healthBusy ? t("workbench.kpi.healthWarn") : t("workbench.kpi.healthOk");
|
||||||
|
const taskValue = opsQuery.isLoading ? "…" : opsQuery.isError ? "—" : formatCount(activeTasks);
|
||||||
|
const schedValue = metricsQuery.isLoading ? "…" : !schedulers || metricsQuery.isError ? "—" : `${schedOnCount}/${schedRows.length}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workbench workbench--console">
|
<div className="workbench workbench--console">
|
||||||
<header className="wb-head">
|
<header className="wb-head">
|
||||||
|
<div>
|
||||||
<div className="wb-head__title-row">
|
<div className="wb-head__title-row">
|
||||||
<h1 className="wb-head__title">{t("workbench.title")}</h1>
|
<h1 className="wb-head__title">{t("workbench.consoleTitle")}</h1>
|
||||||
<span className={`wb-head__health${healthBusy ? " is-busy" : " is-ok"}`}>
|
<span className={`wb-head__health ${healthTone}`}><span className="wb-head__health-dot" aria-hidden="true" />{healthLabel}</span>
|
||||||
<span className="wb-head__health-dot" aria-hidden="true" />
|
</div>
|
||||||
{healthBusy
|
</div>
|
||||||
? t("workbench.kpi.healthWarn")
|
<div className="wb-head__actions">
|
||||||
: metrics
|
<span className="wb-head__updated">{metricsQuery.dataUpdatedAt ? t("workbench.refreshedAt", { time: new Date(metricsQuery.dataUpdatedAt).toLocaleTimeString(undefined, { hour12: false }) }) : t("workbench.awaitingData")}</span>
|
||||||
? t("workbench.kpi.healthOk")
|
<Button size="sm" variant="outline" className="wb-refresh" isDisabled={refreshing} onPress={refreshConsole}><span className={refreshing ? "wb-refresh__icon is-spinning" : "wb-refresh__icon"}><ConsoleIcon kind="refresh" /></span>{t("workbench.refresh")}</Button>
|
||||||
: t("workbench.kpi.healthUnknown")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="wb-strip wb-panel" aria-label={t("workbench.statusTitle")}>
|
|
||||||
<div className="wb-strip__row">
|
|
||||||
<span className="wb-strip__tag">{t("workbench.hostHealth")}</span>
|
|
||||||
<StatLine label={t("workbench.gauge.cpu")} value={`${cpuPct}%`} pct={cpuPct} />
|
|
||||||
<StatLine label={t("workbench.gauge.mem")} value={memValue} pct={memPct} />
|
|
||||||
<StatLine
|
|
||||||
label={t("workbench.gauge.storage")}
|
|
||||||
value={storageValue}
|
|
||||||
muted={!storageOk}
|
|
||||||
/>
|
|
||||||
<span className="wb-strip__divider" aria-hidden="true" />
|
|
||||||
<span className="wb-strip__tag wb-strip__tag--soft">{t("workbench.channels")}</span>
|
|
||||||
<StatLine
|
|
||||||
label={t("workbench.gauge.cli")}
|
|
||||||
value={`${cliUsed}/${cliLimit}`}
|
|
||||||
pct={cliPct}
|
|
||||||
/>
|
|
||||||
<StatLine
|
|
||||||
label={t("workbench.gauge.db")}
|
|
||||||
value={`${dbOut}/${dbSize}`}
|
|
||||||
pct={dbPct}
|
|
||||||
/>
|
|
||||||
<StatLine
|
|
||||||
label={t("workbench.gauge.webcrt")}
|
|
||||||
value={`${webUsed}/${webMax}`}
|
|
||||||
pct={webPct}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="wb-body">
|
<div className="wb-body">
|
||||||
<div className="wb-main">
|
<div className="wb-main">
|
||||||
<section className="wb-modules wb-panel" aria-label={t("workbench.modules")}>
|
<WorkbenchModules sections={displaySections} />
|
||||||
{displaySections.map((section) => (
|
|
||||||
<div key={section.key} className={`wb-section wb-section--${section.key}`}>
|
|
||||||
<h2 className="wb-section__title">{t(`workbench.${section.key}`)}</h2>
|
|
||||||
<div className="wb-tiles">
|
|
||||||
{section.mods.map((mod) => (
|
|
||||||
<Button
|
|
||||||
key={mod.moduleId}
|
|
||||||
variant="ghost"
|
|
||||||
className={`wb-tile wb-tile--${mod.iconTone}`}
|
|
||||||
aria-label={t("workbench.openModule")}
|
|
||||||
onPress={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
|
|
||||||
>
|
|
||||||
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
|
||||||
<span className="wb-tile__label">{t(mod.labelKey)}</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="wb-fleet wb-panel" aria-label={t("workbench.fleet")}>
|
<section className="wb-fleet wb-panel" aria-label={t("workbench.fleet")}>
|
||||||
<div className="wb-fleet__head">
|
<div className="wb-fleet__head">
|
||||||
<h2 className="wb-fleet__title">{t("workbench.fleet")}</h2>
|
<h2 className="wb-fleet__title">{t("workbench.fleet")}</h2>
|
||||||
|
<span className="wb-fleet__live"><span aria-hidden="true" />{t("workbench.autoRefresh")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="wb-fleet__grid">
|
<div className="wb-fleet__grid">
|
||||||
<button
|
<button
|
||||||
|
|
@ -685,7 +543,7 @@ export function WorkbenchPage() {
|
||||||
<div className="wb-fleet__card-top">
|
<div className="wb-fleet__card-top">
|
||||||
<div className="wb-fleet__card-label">{t("workbench.fleetNe")}</div>
|
<div className="wb-fleet__card-label">{t("workbench.fleetNe")}</div>
|
||||||
<span className="wb-fleet__card-badge">
|
<span className="wb-fleet__card-badge">
|
||||||
{canReadFleet ? `${t("workbench.fleetNeKnown")} ${neKnownPct}%` : "—"}
|
{neStatsQuery.isError ? t("workbench.loadFailed") : canReadFleet && neStatsQuery.data ? `${t("workbench.fleetNeKnown")} ${neKnownPct}%` : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="wb-fleet__viz">
|
<div className="wb-fleet__viz">
|
||||||
|
|
@ -746,7 +604,7 @@ export function WorkbenchPage() {
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<em>
|
<em>
|
||||||
{t("workbench.fleetNeNoTag")} {canReadFleet ? neNoTag : "—"}
|
{t("workbench.fleetNeNoTag")} {dash(neNoTag)}
|
||||||
</em>
|
</em>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -762,7 +620,7 @@ export function WorkbenchPage() {
|
||||||
<div className="wb-fleet__card-top">
|
<div className="wb-fleet__card-top">
|
||||||
<div className="wb-fleet__card-label">{t("workbench.fleetTopo")}</div>
|
<div className="wb-fleet__card-label">{t("workbench.fleetTopo")}</div>
|
||||||
<span className="wb-fleet__card-badge">
|
<span className="wb-fleet__card-badge">
|
||||||
{canReadFleet
|
{fabricQuery.isError || topoTreeQuery.isError ? t("workbench.loadFailed") : canReadFleet
|
||||||
? `${formatCount(fabricEdges)} ${t("workbench.fleetTopoEdges")}`
|
? `${formatCount(fabricEdges)} ${t("workbench.fleetTopoEdges")}`
|
||||||
: "—"}
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -775,15 +633,15 @@ export function WorkbenchPage() {
|
||||||
? "—"
|
? "—"
|
||||||
: fabricQuery.isLoading || topoTreeQuery.isLoading
|
: fabricQuery.isLoading || topoTreeQuery.isLoading
|
||||||
? "…"
|
? "…"
|
||||||
: formatCount(fabricNodes)
|
: fabricQuery.isError ? "—" : formatCount(fabricNodes)
|
||||||
}
|
}
|
||||||
unit={t("workbench.fleetTopoNodes")}
|
unit={t("workbench.fleetTopoNodes")}
|
||||||
/>
|
/>
|
||||||
<div className="wb-fleet__rows">
|
<div className="wb-fleet__rows">
|
||||||
{(canReadFleet ? regionSlices : []).length === 0 ? (
|
{(canReadFleet ? regionSlices : []).length === 0 ? (
|
||||||
<div className="wb-fleet__row">
|
<div className="wb-fleet__row">
|
||||||
<span>{t("workbench.fleetTopoNoRegion")}</span>
|
<span>{fabricQuery.isError || topoTreeQuery.isError ? t("workbench.loadFailed") : t("workbench.fleetTopoNoRegion")}</span>
|
||||||
<strong>{dash(formatCount(fabricNodes))}</strong>
|
<strong>{canReadFleet && fabricQuery.data ? formatCount(fabricNodes) : "—"}</strong>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(canReadFleet ? regionSlices : []).map((s) => (
|
(canReadFleet ? regionSlices : []).map((s) => (
|
||||||
|
|
@ -822,41 +680,16 @@ export function WorkbenchPage() {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="wb-fleet__card wb-fleet__card--sched"
|
className="wb-fleet__card wb-fleet__card--sched"
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() =>
|
|
||||||
openOrFocusModule({
|
|
||||||
moduleId: "network",
|
|
||||||
path: "/network/tasks/port-traffic/wall",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
openOrFocusModule({
|
|
||||||
moduleId: "network",
|
|
||||||
path: "/network/tasks/port-traffic/wall",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="wb-fleet__card-top">
|
<div className="wb-fleet__card-top">
|
||||||
<div className="wb-fleet__card-label">{t("workbench.fleetSched")}</div>
|
<div className="wb-fleet__card-label">{t("workbench.fleetSched")}</div>
|
||||||
<span className={`wb-fleet__card-badge${schedStale ? " is-warn" : " is-ok"}`}>
|
<span className="wb-fleet__card-badge">
|
||||||
{schedStale ? t("workbench.fleetSchedStale") : t("workbench.fleetSchedFresh")}
|
{schedValue} · {t("workbench.enabledSchedulers")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="wb-fleet__viz">
|
<div className="wb-fleet__viz">
|
||||||
<SchedDartDisc
|
|
||||||
items={schedRows}
|
|
||||||
center={`${schedOnCount}/${schedRows.length}`}
|
|
||||||
unit={t("workbench.fleetSchedOn")}
|
|
||||||
onSelect={(row) =>
|
|
||||||
openOrFocusModule({ moduleId: row.moduleId, path: row.path })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="wb-fleet__rows">
|
<div className="wb-fleet__rows">
|
||||||
{schedRows.map((row, i) => (
|
{schedRows.map((row) => (
|
||||||
<button
|
<button
|
||||||
key={row.key}
|
key={row.key}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -868,19 +701,13 @@ export function WorkbenchPage() {
|
||||||
>
|
>
|
||||||
<span className="wb-fleet__legend">
|
<span className="wb-fleet__legend">
|
||||||
<i
|
<i
|
||||||
className="wb-fleet__swatch"
|
className={`wb-fleet__swatch${row.on ? " is-ok" : ""}`}
|
||||||
style={{
|
|
||||||
background: row.on
|
|
||||||
? SCHED_DISC_COLORS[i % SCHED_DISC_COLORS.length]
|
|
||||||
: "#475569",
|
|
||||||
boxShadow: "none",
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="wb-fleet__legend-text">{row.label}</span>
|
<span className="wb-fleet__legend-text">{row.label}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={`wb-fleet__sched-state${row.on ? " is-ok" : " is-muted"}`}>
|
<span className={`wb-fleet__sched-state${row.on ? " is-ok" : " is-muted"}`}>
|
||||||
{row.on ? "ON" : "OFF"}
|
{!schedulers || metricsQuery.isError ? "—" : row.on ? "ON" : "OFF"}
|
||||||
</span>
|
</span>
|
||||||
<span className="wb-fleet__sched-age">{row.age}</span>
|
<span className="wb-fleet__sched-age">{row.age}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -906,7 +733,7 @@ export function WorkbenchPage() {
|
||||||
<div className="wb-rail__head">
|
<div className="wb-rail__head">
|
||||||
<h2 className="wb-rail__title">
|
<h2 className="wb-rail__title">
|
||||||
{t("workbench.taskRail")}
|
{t("workbench.taskRail")}
|
||||||
<span className="wb-rail__count">{activeTasks}</span>
|
<span className="wb-rail__count">{taskValue}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -919,8 +746,10 @@ export function WorkbenchPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{railTasks.length === 0 ? (
|
{opsQuery.isLoading ? <p className="wb-rail__empty" role="status">{t("workbench.loadingTasks")}</p> : opsQuery.isError ? (
|
||||||
<p className="wb-rail__empty">{t("workbench.taskRailEmpty")}</p>
|
<div className="wb-rail__empty wb-rail__empty--error" role="status"><ConsoleIcon kind="activity" /><strong>{t("workbench.loadFailed")}</strong><Button size="sm" variant="ghost" onPress={() => void opsQuery.refetch()}>{t("workbench.retry")}</Button></div>
|
||||||
|
) : railTasks.length === 0 ? (
|
||||||
|
<div className="wb-rail__empty"><span className="wb-rail__empty-icon"><ConsoleIcon kind="check" /></span><strong>{t("workbench.taskRailEmpty")}</strong></div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="wb-rail__list">
|
<ul className="wb-rail__list">
|
||||||
{railTasks.map((row) => (
|
{railTasks.map((row) => (
|
||||||
|
|
@ -939,14 +768,32 @@ export function WorkbenchPage() {
|
||||||
<span className="wb-rail__progress"> · {row.progress}</span>
|
<span className="wb-rail__progress"> · {row.progress}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="wb-rail__time">{formatCompactTime(row.updated_at || row.started_at)}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="wb-runtime wb-panel" aria-label={t("workbench.statusTitle")}>
|
||||||
|
<div className="wb-runtime__head"><h2 className="wb-panel-title">{t("workbench.statusTitle")}</h2><ConsoleIcon kind="activity" /></div>
|
||||||
|
{metricsQuery.isError && <p className="wb-runtime__error" role="status">{t("workbench.loadFailed")} · {t("workbench.retryHint")}</p>}
|
||||||
|
<div className="wb-runtime__group wb-runtime__group--host"><h3>{t("workbench.hostHealth")}</h3>
|
||||||
|
<StatLine label={t("workbench.gauge.cpu")} value={hostOk ? `${cpuPct}%` : metricsPlaceholder} pct={hostOk ? cpuPct : undefined} />
|
||||||
|
<StatLine label={t("workbench.gauge.mem")} value={memValue} pct={hostOk ? memPct : undefined} />
|
||||||
|
<StatLine label={t("workbench.gauge.storage")} value={storageValue} muted={!storageOk} />
|
||||||
|
</div>
|
||||||
|
<div className="wb-runtime__group wb-runtime__group--capacity"><h3>{t("workbench.channels")}</h3>
|
||||||
|
<StatLine label={t("workbench.gauge.cli")} value={metrics?.cli_budget ? `${cliUsed}/${cliLimit}` : metricsPlaceholder} pct={metrics?.cli_budget ? cliPct : undefined} />
|
||||||
|
<StatLine label={t("workbench.gauge.db")} value={metrics?.db_pool ? `${dbOut}/${dbSize}` : metricsPlaceholder} pct={metrics?.db_pool ? dbPct : undefined} />
|
||||||
|
<StatLine label={t("workbench.gauge.webcrt")} value={metrics?.webcrt ? `${webUsed}/${webMax}` : metricsPlaceholder} pct={metrics?.webcrt ? webPct : undefined} />
|
||||||
|
</div>
|
||||||
|
<div className="wb-runtime__foot"><span>{t("workbench.kpi.uptime")}</span><strong>{metrics ? formatAgeSec(metrics.uptime_sec) : metricsPlaceholder}</strong></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
web/src/features/workbench/components/ConsoleIcon.tsx
Normal file
14
web/src/features/workbench/components/ConsoleIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" };
|
||||||
|
|
||||||
|
export function ConsoleIcon({ kind }: Props) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
{kind === "arrow" && <path d="M5 12h14m-6-6 6 6-6 6" />}
|
||||||
|
{kind === "search" && <><circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4 4" /></>}
|
||||||
|
{kind === "refresh" && <><path d="M20 7v5h-5M4 17v-5h5" /><path d="M6 7a7 7 0 0 1 11-2l3 3M4 16l3 3a7 7 0 0 0 11-2" /></>}
|
||||||
|
{kind === "activity" && <path d="M3 12h4l3-7 4 14 3-7h4" />}
|
||||||
|
{kind === "check" && <><circle cx="12" cy="12" r="9" /><path d="m8 12 3 3 5-6" /></>}
|
||||||
|
{kind === "close" && <path d="m6 6 12 12M6 18 18 6" />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
web/src/features/workbench/components/WorkbenchModules.tsx
Normal file
70
web/src/features/workbench/components/WorkbenchModules.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button } from "@heroui/react";
|
||||||
|
import type { ModuleDefinition } from "../../../config/modules";
|
||||||
|
import { useI18n } from "../../../i18n";
|
||||||
|
import { openOrFocusModule } from "../../../utils/moduleWindows";
|
||||||
|
import { ConsoleIcon } from "./ConsoleIcon";
|
||||||
|
import { WorkbenchCardIcon } from "./WorkbenchCardIcon";
|
||||||
|
|
||||||
|
export type DisplaySection = { key: "operations" | "system"; mods: ModuleDefinition[] };
|
||||||
|
|
||||||
|
function moduleDescription(mod: ModuleDefinition, t: (key: string) => string) {
|
||||||
|
const key = `workbench.descriptions.${mod.moduleId}`;
|
||||||
|
const description = t(key);
|
||||||
|
return description === key ? t(mod.titleKey) : description;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const term = search.trim().toLocaleLowerCase();
|
||||||
|
return sections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
mods: section.mods.filter((mod) =>
|
||||||
|
[t(mod.labelKey), moduleDescription(mod, t), mod.moduleId]
|
||||||
|
.some((value) => value.toLocaleLowerCase().includes(term)),
|
||||||
|
),
|
||||||
|
})).filter((section) => section.mods.length > 0);
|
||||||
|
}, [sections, search, t]);
|
||||||
|
const count = filtered.reduce((sum, section) => sum + section.mods.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="wb-modules wb-panel" aria-label={t("workbench.modules")}>
|
||||||
|
<div className="wb-modules__head">
|
||||||
|
<div>
|
||||||
|
<h2 className="wb-panel-title">{t("workbench.modules")}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="wb-search">
|
||||||
|
<ConsoleIcon kind="search" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
aria-label={t("workbench.searchModules")}
|
||||||
|
placeholder={t("workbench.searchModules")}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
onKeyDown={(event) => { if (event.key === "Escape") setSearch(""); }}
|
||||||
|
/>
|
||||||
|
{search && <Button isIconOnly size="sm" variant="ghost" className="wb-search__clear" aria-label={t("workbench.clearSearch")} onPress={() => setSearch("")}><ConsoleIcon kind="close" /></Button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="wb-sr-only" role="status">{t("workbench.moduleCount", { count })}</span>
|
||||||
|
{filtered.map((section) => (
|
||||||
|
<div key={section.key} className={`wb-section wb-section--${section.key}`}>
|
||||||
|
<div className={section.key === "operations" ? "wb-sr-only" : "wb-section__head"}>
|
||||||
|
<h3 className="wb-section__title">{t(`workbench.${section.key}`)}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="wb-tiles">
|
||||||
|
{section.mods.map((mod) => (
|
||||||
|
<Button key={mod.moduleId} variant="ghost" className={`wb-tile wb-tile--${mod.iconTone}`} aria-label={`${t(mod.labelKey)} · ${t("workbench.openModule")}`} onPress={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}>
|
||||||
|
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
||||||
|
<span className="wb-tile__label">{t(mod.labelKey)}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{count === 0 && <div className="wb-modules__empty"><ConsoleIcon kind="search" /><p>{t("workbench.noModules")}</p><Button size="sm" variant="ghost" onPress={() => setSearch("")}>{t("workbench.clearSearch")}</Button></div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,10 @@ const managedNe = {
|
||||||
batchDeleteDone: "Deleted {{n}} network element(s)",
|
batchDeleteDone: "Deleted {{n}} network element(s)",
|
||||||
batchDeleteSelectRequired: "Select network elements to delete",
|
batchDeleteSelectRequired: "Select network elements to delete",
|
||||||
keywordPh: "name / IP / user / tags",
|
keywordPh: "name / IP / user / tags",
|
||||||
|
emptyFiltered: "No NEs match the current filters. Adjust or clear them.",
|
||||||
|
selectedCount: "{{n}} selected",
|
||||||
|
clearSelection: "Clear selection",
|
||||||
|
loadFailed: "Failed to load NEs: {{error}}",
|
||||||
allVendors: "All vendors",
|
allVendors: "All vendors",
|
||||||
allConnectStatus: "All connect status",
|
allConnectStatus: "All connect status",
|
||||||
stats: {
|
stats: {
|
||||||
|
|
@ -38,6 +42,9 @@ const managedNe = {
|
||||||
proxyDone: "Proxy applied to {{n}} NE(s)",
|
proxyDone: "Proxy applied to {{n}} NE(s)",
|
||||||
testDone: "Connect-test submitted for {{n}} NE(s)",
|
testDone: "Connect-test submitted for {{n}} NE(s)",
|
||||||
loadingIds: "Loading…",
|
loadingIds: "Loading…",
|
||||||
|
noMatchingNe: "No NEs in the selected scope",
|
||||||
|
scopeCount: "Applies to {{n}} NEs",
|
||||||
|
cardFilterHint: "Click a card to filter the list below by tag",
|
||||||
},
|
},
|
||||||
col: {
|
col: {
|
||||||
name: "Name",
|
name: "Name",
|
||||||
|
|
@ -106,6 +113,7 @@ const managedNe = {
|
||||||
"[Bulk import]\n" +
|
"[Bulk import]\n" +
|
||||||
"· Required: device_type, ip, username, port, protocol, name, vendor (download template first)\n" +
|
"· Required: device_type, ip, username, port, protocol, name, vendor (download template first)\n" +
|
||||||
"· password may be empty for bastion-managed / later Batch add proxy; required for direct login\n" +
|
"· password may be empty for bastion-managed / later Batch add proxy; required for direct login\n" +
|
||||||
|
"· Existing IPs are updated; a blank password keeps the saved one\n" +
|
||||||
"· Flow: import → select rows → Batch add proxy\n\n" +
|
"· Flow: import → select rows → Batch add proxy\n\n" +
|
||||||
"[Jump / bastion]\n" +
|
"[Jump / bastion]\n" +
|
||||||
"· Username template: {hop_user}@{target_user}@{target_ip} (bastion host filled separately)\n" +
|
"· Username template: {hop_user}@{target_user}@{target_ip} (bastion host filled separately)\n" +
|
||||||
|
|
@ -115,6 +123,11 @@ const managedNe = {
|
||||||
"· Storing passwords needs NETX_CREDENTIAL_SECRET_KEY\n" +
|
"· Storing passwords needs NETX_CREDENTIAL_SECRET_KEY\n" +
|
||||||
"· Blank password on edit keeps current; use Details hop context after failed connect (no secrets)",
|
"· Blank password on edit keeps current; use Details hop context after failed connect (no secrets)",
|
||||||
importResult: {
|
importResult: {
|
||||||
|
title: "Import result",
|
||||||
|
failedTitle: "Failed rows ({{n}})",
|
||||||
|
row: "Row",
|
||||||
|
reason: "Reason",
|
||||||
|
close: "Close",
|
||||||
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
|
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
|
|
@ -130,6 +143,10 @@ const managedNe = {
|
||||||
deleted: "Network element deleted",
|
deleted: "Network element deleted",
|
||||||
passwordRequired: "Password is required",
|
passwordRequired: "Password is required",
|
||||||
passwordOptional: "leave blank to keep unchanged",
|
passwordOptional: "leave blank to keep unchanged",
|
||||||
|
ipRequired: "Management address is required",
|
||||||
|
ipInvalid: "Invalid management address (expect an IPv4 / IPv6 address or hostname)",
|
||||||
|
usernameRequired: "Username is required",
|
||||||
|
portInvalid: "Port must be an integer from 1 to 65535",
|
||||||
},
|
},
|
||||||
hop: {
|
hop: {
|
||||||
sectionTitle: "Jump host / proxy",
|
sectionTitle: "Jump host / proxy",
|
||||||
|
|
@ -196,6 +213,28 @@ const managedNe = {
|
||||||
selectRequired: "Select network elements first",
|
selectRequired: "Select network elements first",
|
||||||
applying: "Applying…",
|
applying: "Applying…",
|
||||||
apply: "Apply to selected",
|
apply: "Apply to selected",
|
||||||
|
batchPasswordHint: "Leave the hop password blank to keep each NE's saved hop password; NEs that never had one are rejected.",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
ip_address_exists: "This management address already exists",
|
||||||
|
ip_address_required: "Management address is required",
|
||||||
|
ip_required: "Management address is missing",
|
||||||
|
invalid_ip_address: "Invalid management address (expect an IPv4 / IPv6 address or hostname)",
|
||||||
|
invalid_port: "Port must be an integer from 1 to 65535",
|
||||||
|
unsupported_device_type: "Unsupported device type",
|
||||||
|
username_required: "Username is missing",
|
||||||
|
password_required: "Manual mode requires the target NE password",
|
||||||
|
hop_host_required: "Hop address is required",
|
||||||
|
hop_username_required: "Hop username is required",
|
||||||
|
hop_password_required: "Hop password is required (these NEs have none saved: {{detail}})",
|
||||||
|
username_or_password_required: "Enter a username or a password",
|
||||||
|
managed_ne_not_found: "NE not found or already deleted: {{detail}}",
|
||||||
|
ids_required: "Select NEs first",
|
||||||
|
credential_secret_key_not_configured: "Credential encryption key is not configured; passwords cannot be saved",
|
||||||
|
import_missing_columns: "Import file is missing columns: {{detail}}",
|
||||||
|
import_parse_failed: "Could not parse the import file: {{detail}}",
|
||||||
|
empty_file: "Import file is empty",
|
||||||
|
invalid_filter_field: "Unsupported filter column",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,39 @@
|
||||||
const workbench = {
|
const workbench = {
|
||||||
title: "Workbench",
|
title: "Workbench",
|
||||||
modules: "Modules",
|
modules: "Modules",
|
||||||
|
consoleTitle: "Operations console",
|
||||||
|
consoleWelcome: "Welcome back, {{name}}. Monitor your network and get to work.",
|
||||||
|
overview: "Operations overview",
|
||||||
|
managedDevices: "Managed devices",
|
||||||
|
connectedDevices: "{{count}} connected",
|
||||||
|
topologyLinks: "{{count}} topology links",
|
||||||
|
runningSchedulers: "Running schedulers",
|
||||||
|
enabledSchedulers: "enabled",
|
||||||
|
schedulerAuto: "Automatic collection and sync",
|
||||||
|
moduleHint: "Choose a module to open its workspace tab",
|
||||||
|
searchModules: "Search modules…",
|
||||||
|
clearSearch: "Clear search",
|
||||||
|
noModules: "No matching modules",
|
||||||
|
fleetHint: "Connectivity, regions, and collection schedules",
|
||||||
|
autoRefresh: "Auto refresh",
|
||||||
|
refresh: "Refresh",
|
||||||
|
awaitingData: "Waiting for an update",
|
||||||
|
loadFailed: "Unable to load data",
|
||||||
|
loadingTasks: "Loading tasks…",
|
||||||
|
retry: "Retry",
|
||||||
|
retryHint: "Try refreshing again shortly",
|
||||||
|
taskRailEmptyHint: "Collection and sync tasks will appear here.",
|
||||||
|
descriptions: {
|
||||||
|
ume: "Sync devices and alarms",
|
||||||
|
ne: "Inventory and connectivity tests",
|
||||||
|
network: "Monitoring, collection, and cutover",
|
||||||
|
topology: "Discover and edit network topology",
|
||||||
|
webcrt: "Remote terminals and diagnostics",
|
||||||
|
users: "Accounts and permissions",
|
||||||
|
audit: "Task progress and activity logs",
|
||||||
|
"api-keys": "Manage API access keys",
|
||||||
|
sessions: "Review active login sessions",
|
||||||
|
},
|
||||||
sections: "Sections",
|
sections: "Sections",
|
||||||
moduleCount: "{{count}} modules",
|
moduleCount: "{{count}} modules",
|
||||||
backToWorkbench: "Back to Workbench",
|
backToWorkbench: "Back to Workbench",
|
||||||
|
|
@ -17,15 +50,15 @@ const workbench = {
|
||||||
uptime: "API uptime",
|
uptime: "API uptime",
|
||||||
health: "System health",
|
health: "System health",
|
||||||
tasks: "Active tasks",
|
tasks: "Active tasks",
|
||||||
healthOk: "OK",
|
healthOk: "System healthy",
|
||||||
healthWarn: "CHECK",
|
healthWarn: "Needs attention",
|
||||||
healthUnknown: "N/A",
|
healthUnknown: "Status unknown",
|
||||||
},
|
},
|
||||||
statusTitle: "Runtime status",
|
statusTitle: "Runtime status",
|
||||||
hostHealth: "HOST",
|
hostHealth: "Host resources",
|
||||||
platformCapacity: "Platform capacity",
|
platformCapacity: "Platform capacity",
|
||||||
channels: "CHANNELS",
|
channels: "Platform capacity",
|
||||||
fleet: "FLEET",
|
fleet: "Network & schedulers",
|
||||||
fleetNe: "NE",
|
fleetNe: "NE",
|
||||||
fleetNeUnit: "units",
|
fleetNeUnit: "units",
|
||||||
fleetNeKnown: "Known",
|
fleetNeKnown: "Known",
|
||||||
|
|
@ -36,7 +69,7 @@ const workbench = {
|
||||||
fleetNeNoTag: "No tag",
|
fleetNeNoTag: "No tag",
|
||||||
fleetNeTags: "Top tags",
|
fleetNeTags: "Top tags",
|
||||||
fleetTopo: "Topology",
|
fleetTopo: "Topology",
|
||||||
fleetTopoNodes: "nodes",
|
fleetTopoNodes: "Nodes",
|
||||||
fleetTopoEdges: "edges",
|
fleetTopoEdges: "edges",
|
||||||
fleetTopoOther: "Other regions",
|
fleetTopoOther: "Other regions",
|
||||||
fleetTopoUnassigned: "Unassigned",
|
fleetTopoUnassigned: "Unassigned",
|
||||||
|
|
@ -59,10 +92,10 @@ const workbench = {
|
||||||
sidePanel: "Console side panel",
|
sidePanel: "Console side panel",
|
||||||
consoleId: "OPS CONSOLE",
|
consoleId: "OPS CONSOLE",
|
||||||
refreshedAt: "Updated {{time}}",
|
refreshedAt: "Updated {{time}}",
|
||||||
taskRail: "ACTIVE TASKS",
|
taskRail: "Task activity",
|
||||||
taskRailAll: "ALL",
|
taskRailAll: "View all",
|
||||||
taskRailEmpty: "No active tasks",
|
taskRailEmpty: "No tasks running",
|
||||||
taskTotal: "POOL {{count}}",
|
taskTotal: "{{count}} tasks in total",
|
||||||
announceTitle: "Announcements",
|
announceTitle: "Announcements",
|
||||||
recentTitle: "Recent operations",
|
recentTitle: "Recent operations",
|
||||||
recentMore: "View all",
|
recentMore: "View all",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ const managedNe = {
|
||||||
batchDeleteDone: "已删除 {{n}} 台网元",
|
batchDeleteDone: "已删除 {{n}} 台网元",
|
||||||
batchDeleteSelectRequired: "请先勾选要删除的网元",
|
batchDeleteSelectRequired: "请先勾选要删除的网元",
|
||||||
keywordPh: "名称 / IP / 用户名 / 标签",
|
keywordPh: "名称 / IP / 用户名 / 标签",
|
||||||
|
emptyFiltered: "没有符合当前筛选条件的网元,请调整或清除筛选条件。",
|
||||||
|
selectedCount: "已选 {{n}} 台",
|
||||||
|
clearSelection: "清除选择",
|
||||||
|
loadFailed: "网元列表加载失败:{{error}}",
|
||||||
allVendors: "全部厂商",
|
allVendors: "全部厂商",
|
||||||
allConnectStatus: "全部连通状态",
|
allConnectStatus: "全部连通状态",
|
||||||
stats: {
|
stats: {
|
||||||
|
|
@ -37,6 +41,9 @@ const managedNe = {
|
||||||
proxyDone: "已为 {{n}} 台网元提交代理配置",
|
proxyDone: "已为 {{n}} 台网元提交代理配置",
|
||||||
testDone: "已提交 {{n}} 台网元连通性测试",
|
testDone: "已提交 {{n}} 台网元连通性测试",
|
||||||
loadingIds: "加载中…",
|
loadingIds: "加载中…",
|
||||||
|
noMatchingNe: "所选范围内没有网元",
|
||||||
|
scopeCount: "将作用于 {{n}} 台网元",
|
||||||
|
cardFilterHint: "点击卡片可按标签筛选下方列表",
|
||||||
},
|
},
|
||||||
col: {
|
col: {
|
||||||
name: "名称",
|
name: "名称",
|
||||||
|
|
@ -104,6 +111,7 @@ const managedNe = {
|
||||||
"【批量导入】\n" +
|
"【批量导入】\n" +
|
||||||
"· 必填列:device_type、ip、username、port、protocol、name、vendor(可先下载模板)\n" +
|
"· 必填列:device_type、ip、username、port、protocol、name、vendor(可先下载模板)\n" +
|
||||||
"· password 可留空:直连需填;堡垒机托管或后续「批量添加代理」时可空\n" +
|
"· password 可留空:直连需填;堡垒机托管或后续「批量添加代理」时可空\n" +
|
||||||
|
"· 已存在的 IP 按更新处理;password 留空时保留已保存的密码\n" +
|
||||||
"· 推荐:导入网元 → 勾选 →「批量添加代理」统一配置跳板\n\n" +
|
"· 推荐:导入网元 → 勾选 →「批量添加代理」统一配置跳板\n\n" +
|
||||||
"【跳板 / 堡垒机】\n" +
|
"【跳板 / 堡垒机】\n" +
|
||||||
"· 用户名模板:{hop_user}@{target_user}@{target_ip}(堡垒机地址另填跳板地址)\n" +
|
"· 用户名模板:{hop_user}@{target_user}@{target_ip}(堡垒机地址另填跳板地址)\n" +
|
||||||
|
|
@ -113,6 +121,11 @@ const managedNe = {
|
||||||
"· 保存密码需配置 NETX_CREDENTIAL_SECRET_KEY\n" +
|
"· 保存密码需配置 NETX_CREDENTIAL_SECRET_KEY\n" +
|
||||||
"· 编辑时密码留空表示不改;失败请看「详情」中的 hop 上下文(不含密码)",
|
"· 编辑时密码留空表示不改;失败请看「详情」中的 hop 上下文(不含密码)",
|
||||||
importResult: {
|
importResult: {
|
||||||
|
title: "导入结果",
|
||||||
|
failedTitle: "失败明细(共 {{n}} 行)",
|
||||||
|
row: "行号",
|
||||||
|
reason: "原因",
|
||||||
|
close: "关闭",
|
||||||
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
|
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
|
|
@ -128,6 +141,10 @@ const managedNe = {
|
||||||
deleted: "网元已删除",
|
deleted: "网元已删除",
|
||||||
passwordRequired: "请填写密码",
|
passwordRequired: "请填写密码",
|
||||||
passwordOptional: "留空则不修改",
|
passwordOptional: "留空则不修改",
|
||||||
|
ipRequired: "请填写管理地址",
|
||||||
|
ipInvalid: "管理地址格式不正确(应为 IPv4 / IPv6 地址或主机名)",
|
||||||
|
usernameRequired: "请填写用户名",
|
||||||
|
portInvalid: "端口须为 1–65535 的整数",
|
||||||
},
|
},
|
||||||
hop: {
|
hop: {
|
||||||
sectionTitle: "跳板 / 代理",
|
sectionTitle: "跳板 / 代理",
|
||||||
|
|
@ -194,6 +211,28 @@ const managedNe = {
|
||||||
selectRequired: "请先勾选要配置的网元",
|
selectRequired: "请先勾选要配置的网元",
|
||||||
applying: "应用中…",
|
applying: "应用中…",
|
||||||
apply: "应用到选中网元",
|
apply: "应用到选中网元",
|
||||||
|
batchPasswordHint: "跳板密码留空时沿用各网元已保存的跳板密码;从未保存过跳板密码的网元会被拒绝。",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
ip_address_exists: "该管理地址已存在于网元管理中",
|
||||||
|
ip_address_required: "请填写管理地址",
|
||||||
|
ip_required: "缺少管理地址",
|
||||||
|
invalid_ip_address: "管理地址格式不正确(应为 IPv4 / IPv6 地址或主机名)",
|
||||||
|
invalid_port: "端口须为 1–65535 的整数",
|
||||||
|
unsupported_device_type: "不支持的设备类型",
|
||||||
|
username_required: "缺少用户名",
|
||||||
|
password_required: "手动模式需填写目标网元密码",
|
||||||
|
hop_host_required: "请填写跳板地址",
|
||||||
|
hop_username_required: "请填写跳板用户名",
|
||||||
|
hop_password_required: "跳板密码必填(以下网元未保存跳板密码:{{detail}})",
|
||||||
|
username_or_password_required: "用户名和密码至少填写一项",
|
||||||
|
managed_ne_not_found: "网元不存在或已被删除:{{detail}}",
|
||||||
|
ids_required: "请先选择网元",
|
||||||
|
credential_secret_key_not_configured: "未配置凭据加密密钥,无法保存密码",
|
||||||
|
import_missing_columns: "导入文件缺少列:{{detail}}",
|
||||||
|
import_parse_failed: "导入文件解析失败:{{detail}}",
|
||||||
|
empty_file: "导入文件为空",
|
||||||
|
invalid_filter_field: "不支持的筛选列",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,39 @@
|
||||||
const workbench = {
|
const workbench = {
|
||||||
title: "工作台",
|
title: "工作台",
|
||||||
modules: "可用模块",
|
modules: "功能入口",
|
||||||
|
consoleTitle: "运维控制台",
|
||||||
|
consoleWelcome: "欢迎回来,{{name}}。掌握网络状态,快速开始运维工作。",
|
||||||
|
overview: "运行概览",
|
||||||
|
managedDevices: "托管网元",
|
||||||
|
connectedDevices: "{{count}} 台已连通",
|
||||||
|
topologyLinks: "{{count}} 条拓扑连线",
|
||||||
|
runningSchedulers: "运行中调度",
|
||||||
|
enabledSchedulers: "启用",
|
||||||
|
schedulerAuto: "自动采集与同步",
|
||||||
|
moduleHint: "选择模块,打开独立工作页签",
|
||||||
|
searchModules: "搜索功能…",
|
||||||
|
clearSearch: "清除搜索",
|
||||||
|
noModules: "未找到匹配的功能",
|
||||||
|
fleetHint: "网元连通性、区域分布与采集调度",
|
||||||
|
autoRefresh: "自动更新",
|
||||||
|
refresh: "刷新",
|
||||||
|
awaitingData: "等待数据更新",
|
||||||
|
loadFailed: "数据加载失败",
|
||||||
|
loadingTasks: "正在加载任务…",
|
||||||
|
retry: "重试",
|
||||||
|
retryHint: "请稍后刷新重试",
|
||||||
|
taskRailEmptyHint: "采集、同步等任务将在这里显示。",
|
||||||
|
descriptions: {
|
||||||
|
ume: "同步网元与告警数据",
|
||||||
|
ne: "设备纳管与连通性测试",
|
||||||
|
network: "监控、采集与业务割接",
|
||||||
|
topology: "发现连接与编辑网络拓扑",
|
||||||
|
webcrt: "远程终端与设备调试",
|
||||||
|
users: "账户与访问权限",
|
||||||
|
audit: "任务进度与操作记录",
|
||||||
|
"api-keys": "管理 API 访问密钥",
|
||||||
|
sessions: "查看登录与会话状态",
|
||||||
|
},
|
||||||
sections: "功能分区",
|
sections: "功能分区",
|
||||||
moduleCount: "{{count}} 个模块",
|
moduleCount: "{{count}} 个模块",
|
||||||
backToWorkbench: "返回工作台",
|
backToWorkbench: "返回工作台",
|
||||||
|
|
@ -17,15 +50,15 @@ const workbench = {
|
||||||
uptime: "API 运行时长",
|
uptime: "API 运行时长",
|
||||||
health: "系统健康",
|
health: "系统健康",
|
||||||
tasks: "进行中任务",
|
tasks: "进行中任务",
|
||||||
healthOk: "OK",
|
healthOk: "运行正常",
|
||||||
healthWarn: "CHECK",
|
healthWarn: "需要关注",
|
||||||
healthUnknown: "N/A",
|
healthUnknown: "状态未知",
|
||||||
},
|
},
|
||||||
statusTitle: "运行状态",
|
statusTitle: "运行状态",
|
||||||
hostHealth: "HOST",
|
hostHealth: "主机资源",
|
||||||
platformCapacity: "平台容量",
|
platformCapacity: "平台容量",
|
||||||
channels: "CHANNELS",
|
channels: "平台容量",
|
||||||
fleet: "FLEET",
|
fleet: "网络与调度",
|
||||||
fleetNe: "网元",
|
fleetNe: "网元",
|
||||||
fleetNeUnit: "台",
|
fleetNeUnit: "台",
|
||||||
fleetNeKnown: "已知",
|
fleetNeKnown: "已知",
|
||||||
|
|
@ -36,8 +69,8 @@ const workbench = {
|
||||||
fleetNeNoTag: "无标签",
|
fleetNeNoTag: "无标签",
|
||||||
fleetNeTags: "热门标签",
|
fleetNeTags: "热门标签",
|
||||||
fleetTopo: "拓扑",
|
fleetTopo: "拓扑",
|
||||||
fleetTopoNodes: "节点",
|
fleetTopoNodes: "拓扑节点",
|
||||||
fleetTopoEdges: "边",
|
fleetTopoEdges: "连线",
|
||||||
fleetTopoOther: "其他区域",
|
fleetTopoOther: "其他区域",
|
||||||
fleetTopoUnassigned: "未分区",
|
fleetTopoUnassigned: "未分区",
|
||||||
fleetTopoNoRegion: "暂无区域",
|
fleetTopoNoRegion: "暂无区域",
|
||||||
|
|
@ -53,16 +86,16 @@ const workbench = {
|
||||||
fleetSchedOff: "OFF",
|
fleetSchedOff: "OFF",
|
||||||
fleetSchedMode: "模式",
|
fleetSchedMode: "模式",
|
||||||
fleetSchedHeartbeat: "心跳",
|
fleetSchedHeartbeat: "心跳",
|
||||||
fleetSchedFresh: "新鲜",
|
fleetSchedFresh: "心跳正常",
|
||||||
fleetSchedStale: "过期",
|
fleetSchedStale: "过期",
|
||||||
|
|
||||||
sidePanel: "控制台侧栏",
|
sidePanel: "控制台侧栏",
|
||||||
consoleId: "OPS CONSOLE",
|
consoleId: "OPS CONSOLE",
|
||||||
refreshedAt: "更新于 {{time}}",
|
refreshedAt: "更新于 {{time}}",
|
||||||
taskRail: "ACTIVE TASKS",
|
taskRail: "任务动态",
|
||||||
taskRailAll: "ALL",
|
taskRailAll: "查看全部",
|
||||||
taskRailEmpty: "No active tasks",
|
taskRailEmpty: "当前没有运行中的任务",
|
||||||
taskTotal: "POOL {{count}}",
|
taskTotal: "共 {{count}} 个任务",
|
||||||
announceTitle: "系统公告",
|
announceTitle: "系统公告",
|
||||||
recentTitle: "最近操作",
|
recentTitle: "最近操作",
|
||||||
recentMore: "查看全部",
|
recentMore: "查看全部",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export function AppLayout({ children }: Props) {
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
const activeTasks = opsTasksQuery.data?.active ?? 0;
|
const activeTasks = opsTasksQuery.data?.active ?? 0;
|
||||||
|
const activeTaskCount = opsTasksQuery.isLoading ? "…" : opsTasksQuery.isError ? "—" : activeTasks;
|
||||||
|
|
||||||
useAppWindowRegistration(pathname);
|
useAppWindowRegistration(pathname);
|
||||||
|
|
||||||
|
|
@ -66,15 +67,17 @@ export function AppLayout({ children }: Props) {
|
||||||
<div className="app-brand__actions">
|
<div className="app-brand__actions">
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="app-brand__actions-group">
|
<div className="app-brand__actions-group">
|
||||||
<Chip
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color={activeTasks > 0 ? "success" : "default"}
|
variant="ghost"
|
||||||
variant="soft"
|
className="conn-pill conn-pill--on-brand conn-pill--tasks"
|
||||||
className="conn-pill conn-pill--on-brand conn-pill--tasks cursor-pointer"
|
aria-label={t("layout.activeTasks", { count: activeTaskCount })}
|
||||||
onClick={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })}
|
onPress={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })}
|
||||||
>
|
>
|
||||||
<Chip.Label>{t("layout.activeTasks", { count: activeTasks })}</Chip.Label>
|
<svg viewBox="0 0 20 20" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="M3 5h14M3 10h10M3 15h7" /></svg>
|
||||||
</Chip>
|
<span className="app-brand__tasks-text">{t("layout.activeTasks", { count: activeTaskCount })}</span>
|
||||||
|
<span className="app-brand__tasks-count" aria-hidden="true">{activeTaskCount}</span>
|
||||||
|
</Button>
|
||||||
<Chip size="sm" variant="soft" className="conn-pill conn-pill--on-brand conn-pill--user" title={user.role}>
|
<Chip size="sm" variant="soft" className="conn-pill conn-pill--on-brand conn-pill--user" title={user.role}>
|
||||||
<Chip.Label>{user.username}</Chip.Label>
|
<Chip.Label>{user.username}</Chip.Label>
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import type {
|
||||||
ManagedNeListResponse,
|
ManagedNeListResponse,
|
||||||
ManagedNeMeta,
|
ManagedNeMeta,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
|
import type { TableColumnFilters } from "../../types/tableFilters";
|
||||||
|
import { appendColumnFilters } from "./tableFilters";
|
||||||
import { ApiRequestError, apiDelete, apiGet, apiPatch, apiPost, authHeaders, fetchCreds, handleUnauthorized, parseApiResponse } from "./http";
|
import { ApiRequestError, apiDelete, apiGet, apiPatch, apiPost, authHeaders, fetchCreds, handleUnauthorized, parseApiResponse } from "./http";
|
||||||
|
|
||||||
export const fetchManagedNeMeta = () =>
|
export const fetchManagedNeMeta = () =>
|
||||||
|
|
@ -46,11 +48,13 @@ export const fetchManagedNe = (params: {
|
||||||
connectStatus: string;
|
connectStatus: string;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
columnFilters?: TableColumnFilters;
|
||||||
}) => {
|
}) => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
if (params.keyword) p.set("keyword", params.keyword);
|
if (params.keyword) p.set("keyword", params.keyword);
|
||||||
if (params.vendor) p.set("vendor", params.vendor);
|
if (params.vendor) p.set("vendor", params.vendor);
|
||||||
if (params.connectStatus) p.set("connect_status", params.connectStatus);
|
if (params.connectStatus) p.set("connect_status", params.connectStatus);
|
||||||
|
appendColumnFilters(p, params.columnFilters);
|
||||||
p.set("page", String(Math.max(1, params.page)));
|
p.set("page", String(Math.max(1, params.page)));
|
||||||
p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize))));
|
p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize))));
|
||||||
return apiGet<ManagedNeListResponse>(`/v1/managed-ne?${p.toString()}`);
|
return apiGet<ManagedNeListResponse>(`/v1/managed-ne?${p.toString()}`);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-brand__apps {
|
.app-brand__apps {
|
||||||
|
|
@ -74,6 +75,9 @@
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-brand__actions {
|
.app-brand__actions {
|
||||||
|
|
@ -103,3 +107,36 @@
|
||||||
background: var(--nm-chrome, #e8eef6);
|
background: var(--nm-chrome, #e8eef6);
|
||||||
color: var(--nm-text, var(--nx-line));
|
color: var(--nm-text, var(--nx-line));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app--shell .app-brand .conn-pill.conn-pill--tasks::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app--shell .app-brand .conn-pill--tasks {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand__tasks-count { display: none; }
|
||||||
|
|
||||||
|
.app--shell .app-brand .conn-pill--user .chip__label {
|
||||||
|
max-width: 110px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app--shell .app-brand { padding-inline: 12px; gap: 8px; }
|
||||||
|
.app-brand__start { gap: 8px; }
|
||||||
|
.app-brand__actions-group { gap: 4px; }
|
||||||
|
.app--shell .app-brand .conn-pill--user .chip__label { max-width: 64px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.app-brand__tasks-text { display: none; }
|
||||||
|
.app-brand__tasks-count { display: inline; font-variant-numeric: tabular-nums; }
|
||||||
|
.app--shell .app-brand .conn-pill--tasks { padding-inline: 7px; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,47 @@
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.ne-tag-card {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ne-tag-card > .ne-tag-card__title,
|
||||||
|
button.ne-tag-card > .ne-tag-card__total {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ne-tag-card:hover {
|
||||||
|
border-color: rgb(var(--nx-accent-rgb) / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ne-tag-card:focus-visible {
|
||||||
|
outline: 2px solid var(--nx-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ne-tag-card.is-active {
|
||||||
|
border-color: var(--nx-accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--nx-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ne-selection-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--nx-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ne-import-failures {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ne-tag-card__title {
|
.ne-tag-card__title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -135,256 +135,7 @@ table,
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------- 3. Workbench */
|
/* Workbench styles are owned by pages/workbench.css. */
|
||||||
.app--shell .app-main:has(> .workbench) {
|
|
||||||
background:
|
|
||||||
radial-gradient(900px 380px at 12% -8%, rgb(var(--nx-accent-rgb) / 0.1), transparent 65%),
|
|
||||||
radial-gradient(700px 320px at 100% 0%, rgb(52 211 153 / 0.05), transparent 60%),
|
|
||||||
var(--nx-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .workbench {
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Soft gradient plate instead of the corner-bracket HUD frame. */
|
|
||||||
.app--shell .wb-panel {
|
|
||||||
border: 1px solid var(--nx-line);
|
|
||||||
border-radius: var(--nx-radius-lg);
|
|
||||||
background: linear-gradient(180deg, var(--nx-surface-2) 0%, var(--nx-surface-1) 100%);
|
|
||||||
box-shadow: var(--nx-shadow-panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-head__title {
|
|
||||||
font-family: var(--nx-font-sans);
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
color: var(--nx-text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-head__health {
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-family: var(--nx-font-sans);
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section captions: sans, readable, with a small gradient bar. */
|
|
||||||
.app--shell .wb-section__title,
|
|
||||||
.app--shell .wb-fleet__title,
|
|
||||||
.app--shell .wb-rail__title,
|
|
||||||
.app--shell .wb-fleet__card-label {
|
|
||||||
font-family: var(--nx-font-sans);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 650;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: none;
|
|
||||||
color: var(--nx-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-section__title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-section__title::before {
|
|
||||||
content: "";
|
|
||||||
width: 3px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: var(--nx-gradient-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-strip__tag,
|
|
||||||
.app--shell .wb-strip__tag--soft {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--nx-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-stat__label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--nx-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-stat__value {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-stat__track {
|
|
||||||
height: 3px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content-height layout: no stretched empty panel above/below the tiles. */
|
|
||||||
.app--shell .wb-body {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
grid-template-columns: minmax(0, 1fr) clamp(320px, 26vw, 400px);
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-main {
|
|
||||||
height: auto;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-modules {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 22px;
|
|
||||||
padding: 20px 22px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-tiles {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .app-main .wb-tile {
|
|
||||||
min-height: 72px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
gap: 14px;
|
|
||||||
border: 1px solid var(--nx-line);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--nx-surface-2);
|
|
||||||
transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .app-main .wb-tile:hover,
|
|
||||||
.app--shell .app-main .wb-tile:focus-visible {
|
|
||||||
border-color: rgb(var(--nx-accent-rgb) / 0.6);
|
|
||||||
background: linear-gradient(180deg, rgb(var(--nx-accent-rgb) / 0.16), var(--nx-surface-2));
|
|
||||||
box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-tile__label {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nx-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-card__icon {
|
|
||||||
--wb-icon: var(--nx-text-muted);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: var(--wb-icon);
|
|
||||||
background: color-mix(in oklab, var(--wb-icon) 14%, transparent);
|
|
||||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wb-icon) 28%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-card__icon svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-card__icon--blue {
|
|
||||||
--wb-icon: var(--nx-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-card__icon--green {
|
|
||||||
--wb-icon: #34d399;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-card__icon--amber {
|
|
||||||
--wb-icon: var(--nx-warn);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-card__icon--slate {
|
|
||||||
--wb-icon: var(--nx-text-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-fleet {
|
|
||||||
padding: 14px 16px 16px;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-fleet__head {
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom-color: var(--nx-line-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-fleet__grid {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-fleet__card {
|
|
||||||
padding: 12px 12px 10px;
|
|
||||||
border: 1px solid var(--nx-line);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgb(var(--nx-ink-deep-rgb) / 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell button.wb-fleet__card:hover,
|
|
||||||
.app--shell button.wb-fleet__card:focus-visible,
|
|
||||||
.app--shell .wb-fleet__card--sched:hover,
|
|
||||||
.app--shell .wb-fleet__card--sched:focus-visible {
|
|
||||||
border-color: rgb(var(--nx-accent-rgb) / 0.55);
|
|
||||||
background: rgb(var(--nx-accent-rgb) / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-fleet__card-badge,
|
|
||||||
.app--shell .wb-fleet__donut-unit,
|
|
||||||
.app--shell .wb-fleet__foot,
|
|
||||||
.app--shell .wb-fleet__row-age,
|
|
||||||
.app--shell .wb-fleet__sched-age,
|
|
||||||
.app--shell .wb-rail__meta,
|
|
||||||
.app--shell .wb-rail__progress,
|
|
||||||
.app--shell .wb-rail__empty {
|
|
||||||
color: var(--nx-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-fleet__card-badge,
|
|
||||||
.app--shell .wb-fleet__foot {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-rail {
|
|
||||||
padding: 16px 16px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-rail__count {
|
|
||||||
border-radius: 999px;
|
|
||||||
min-width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
background: var(--nx-accent-soft);
|
|
||||||
color: var(--nx-accent-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-rail__list {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .app-main button.wb-rail__item,
|
|
||||||
.app--shell .wb-rail__item {
|
|
||||||
padding: 11px 12px;
|
|
||||||
border: 1px solid var(--nx-line);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgb(var(--nx-ink-deep-rgb) / 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .app-main button.wb-rail__item:hover,
|
|
||||||
.app--shell .app-main button.wb-rail__item:focus-visible {
|
|
||||||
background: var(--nx-accent-soft);
|
|
||||||
border-color: rgb(var(--nx-accent-rgb) / 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-rail__item-title {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app--shell .wb-rail__item-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--nx-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------- 4. Nav + panels */
|
/* ---------------------------------------------------------- 4. Nav + panels */
|
||||||
.app--shell .network-nav {
|
.app--shell .network-nav {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue