From c4526e36d8fe6344be2b8f993bbea7724eb07977 Mon Sep 17 00:00:00 2001
From: oliver
Date: Sat, 12 Sep 2026 15:10:57 +0800
Subject: [PATCH] Polish HeroUI chrome, topology toolbar, and session client
IP.
Checkpoint before workbench facade redesign: list defaults, API key quota, toast portal, topology canvas editor toolbar with More menu, and trusted-proxy client IP for sessions.
Co-authored-by: Cursor
---
netx_api/auth_middleware.py | 4 +-
netx_api/auth_router.py | 7 +-
netx_api/auth_service.py | 22 +
netx_api/client_ip.py | 106 ++
netx_api/config.py | 5 +
netx_api/webcrt_router.py | 11 +-
tests/test_auth.py | 24 +
tests/test_client_ip.py | 46 +
web/index.html | 2 +-
web/package-lock.json | 1084 ++++++++++++++-
web/package.json | 5 +
web/src/components/HeaderMenu.tsx | 11 +-
web/src/components/HopProxyFields.tsx | 265 ++--
web/src/components/ListPager.tsx | 37 +-
web/src/components/UmeCliConnectPanel.tsx | 200 +--
web/src/components/WebTerminal.tsx | 68 +-
web/src/components/ui/AppModalShell.tsx | 51 +
web/src/components/ui/FieldSelect.tsx | 34 +
web/src/components/ui/TopoModalShell.tsx | 1 +
web/src/constants/queryKeys.ts | 13 +-
web/src/hooks/useToast.tsx | 23 +-
web/src/i18n/en.ts | 9 +-
web/src/i18n/zh.ts | 9 +-
web/src/index.css | 798 +++++++----
web/src/layout/AppLayout.tsx | 40 +-
web/src/pages/ApiTokensPage.tsx | 180 +--
web/src/pages/AuditPage.tsx | 63 +-
web/src/pages/CollectPage.tsx | 725 +++++-----
web/src/pages/ConfigSyncPage.tsx | 417 +++---
web/src/pages/ForceChangePasswordPage.tsx | 134 +-
web/src/pages/LoginPage.tsx | 152 +-
web/src/pages/NePage.tsx | 629 +++++----
web/src/pages/SessionsPage.tsx | 71 +-
web/src/pages/TopologyPage.tsx | 60 +-
web/src/pages/UmePage.tsx | 759 +++++-----
web/src/pages/UsersPage.tsx | 40 +-
web/src/pages/WebcrtPage.tsx | 1235 +++++++++--------
web/src/pages/WorkbenchPage.tsx | 11 +-
web/src/pages/audit/TaskOverviewPage.tsx | 10 +-
.../ManagedNeConnectDetailDialog.tsx | 101 +-
.../pages/managedNe/ManagedNeFormDialog.tsx | 317 +++--
web/src/pages/network/LldpLinksPage.tsx | 663 ++++-----
web/src/pages/network/NetworkAlarmsPage.tsx | 51 +-
web/src/pages/network/NetworkConfigsPage.tsx | 225 +--
web/src/pages/network/NetworkDevicesPage.tsx | 45 +-
.../network/PortTrafficBoardListPage.tsx | 156 ++-
.../network/PortTrafficBoardPanelCell.tsx | 21 +-
web/src/pages/network/PortTrafficPage.tsx | 599 ++++----
web/src/pages/network/PortTrafficWallPage.tsx | 168 ++-
.../pages/network/TopologyClassifyPage.tsx | 185 +--
web/src/pages/topology/TopologyCtxMenu.tsx | 11 +-
.../pages/topology/TopologyDiscoverPanel.tsx | 21 +-
web/src/pages/topology/TopologyHexBrowser.tsx | 29 +-
web/src/pages/topology/TopologySidebar.tsx | 41 +-
web/src/pages/topology/TopologyToolbar.tsx | 299 ++--
web/src/pages/topology/TopologyViewTools.tsx | 46 +-
.../topology/hooks/useTopologyCreateNe.ts | 2 +-
.../topology/modals/AddNePaletteDialog.tsx | 114 +-
.../topology/modals/CreateNeModeDialog.tsx | 54 +-
.../pages/topology/modals/NewRootDialog.tsx | 88 +-
.../topology/modals/OutsidePeersDialog.tsx | 105 +-
.../modals/PlaceholderCreateDialog.tsx | 124 +-
web/src/pages/topology/topoGeometry.ts | 6 +-
web/vite.config.ts | 5 +-
64 files changed, 6316 insertions(+), 4521 deletions(-)
create mode 100644 netx_api/client_ip.py
create mode 100644 tests/test_client_ip.py
create mode 100644 web/src/components/ui/AppModalShell.tsx
create mode 100644 web/src/components/ui/FieldSelect.tsx
create mode 100644 web/src/components/ui/TopoModalShell.tsx
diff --git a/netx_api/auth_middleware.py b/netx_api/auth_middleware.py
index 8127d9b..d6c9bd3 100644
--- a/netx_api/auth_middleware.py
+++ b/netx_api/auth_middleware.py
@@ -64,7 +64,9 @@ def _is_public(path: str) -> bool:
def _client_ip(request: Request) -> str:
- return str(request.client.host if request.client else "")
+ from .client_ip import resolve_client_ip
+
+ return resolve_client_ip(request)
def _action_for(method: str, path: str) -> str:
diff --git a/netx_api/auth_router.py b/netx_api/auth_router.py
index 8a70024..f6e0d0b 100644
--- a/netx_api/auth_router.py
+++ b/netx_api/auth_router.py
@@ -23,6 +23,7 @@ from .auth_service import (
change_password,
create_api_token,
create_user,
+ api_token_quota,
list_api_tokens,
list_audit_logs,
list_auth_sessions,
@@ -42,13 +43,14 @@ from .auth_rate_limit import (
login_lock_remaining,
register_login_failure,
)
+from .client_ip import resolve_client_ip
from .db import get_db
router = APIRouter(tags=["auth"])
def _client_meta(request: Request) -> tuple[str, str]:
- ip = str(request.client.host if request.client else "")
+ ip = resolve_client_ip(request)
ua = str(request.headers.get("user-agent") or "")[:512]
return ip, ua
@@ -401,7 +403,8 @@ def api_list_tokens(
db: Session = Depends(get_db),
) -> dict[str, Any]:
user_id = None if ctx.user.role == "admin" else ctx.user.id
- return {"items": list_api_tokens(db, user_id=user_id)}
+ quota = api_token_quota(db)
+ return {"items": list_api_tokens(db, user_id=user_id), **quota}
@router.post("/v1/api-tokens")
diff --git a/netx_api/auth_service.py b/netx_api/auth_service.py
index 9ab2b30..5aa9577 100644
--- a/netx_api/auth_service.py
+++ b/netx_api/auth_service.py
@@ -235,6 +235,7 @@ def ensure_default_mcp_token(db: Session, user: AppUser | None = None) -> str |
name="mcp-default",
expires_in_days=0,
scopes=list(MCP_DEFAULT_SCOPES),
+ enforce_limit=False,
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(plaintext + "\n", encoding="utf-8")
@@ -571,6 +572,17 @@ def change_password(
revoke_auth_sessions(db, user_id=str(row.id), except_jti=keep_jti)
+def count_active_api_tokens(db: Session) -> int:
+ """Non-revoked API keys (expired-but-not-revoked still occupy a slot)."""
+ return int(db.query(ApiToken).filter(ApiToken.revoked_at.is_(None)).count())
+
+
+def api_token_quota(db: Session) -> dict[str, int]:
+ max_count = max(0, int(getattr(settings, "auth_api_token_max_count", 20) or 0))
+ active = count_active_api_tokens(db)
+ return {"active_count": active, "max_count": max_count}
+
+
def create_api_token(
db: Session,
*,
@@ -578,10 +590,20 @@ def create_api_token(
name: str,
expires_in_days: int | None = None,
scopes: list[str] | None = None,
+ enforce_limit: bool = True,
) -> tuple[ApiToken, str]:
label = str(name or "").strip() or "default"
if len(label) > 128:
raise HTTPException(status_code=400, detail="token_name_too_long")
+ if enforce_limit:
+ max_count = max(0, int(getattr(settings, "auth_api_token_max_count", 20) or 0))
+ if max_count > 0:
+ active = count_active_api_tokens(db)
+ if active >= max_count:
+ raise HTTPException(
+ status_code=400,
+ detail=f"api_token_limit_reached:{active}/{max_count}",
+ )
expires_at: datetime | None = None
if expires_in_days is not None and int(expires_in_days) > 0:
expires_at = utcnow_naive() + timedelta(days=int(expires_in_days))
diff --git a/netx_api/client_ip.py b/netx_api/client_ip.py
new file mode 100644
index 0000000..50538a8
--- /dev/null
+++ b/netx_api/client_ip.py
@@ -0,0 +1,106 @@
+"""Resolve client IP behind reverse proxies (Vite / nginx / Caddy).
+
+Only honors X-Forwarded-For / X-Real-IP / CF-Connecting-IP when the direct peer
+is in NETX_TRUSTED_PROXY_IPS (default loopback). Otherwise the peer address is
+used — prevents header spoofing from the public internet.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+import re
+from typing import Mapping
+
+from starlette.requests import Request
+from starlette.websockets import WebSocket
+
+from .config import settings
+
+_IP_RE = re.compile(
+ r"^(?:"
+ r"(?:\d{1,3}\.){3}\d{1,3}" # IPv4
+ r"|"
+ r"\[[0-9a-fA-F:]+\]" # [IPv6]
+ r"|"
+ r"[0-9a-fA-F:]+" # IPv6 bare
+ r")$"
+)
+
+
+def _trusted_peers() -> set[str]:
+ raw = str(getattr(settings, "trusted_proxy_ips", "") or "")
+ out: set[str] = set()
+ for part in raw.split(","):
+ p = part.strip()
+ if p:
+ out.add(p)
+ # Always treat classic loopback as trusted for local Vite/nginx.
+ out.update({"127.0.0.1", "::1", "localhost"})
+ return out
+
+
+def _looks_like_ip(value: str) -> bool:
+ cand = str(value or "").strip()
+ if not cand or not _IP_RE.match(cand):
+ return False
+ if cand.startswith("[") and cand.endswith("]"):
+ cand = cand[1:-1]
+ try:
+ ipaddress.ip_address(cand)
+ return True
+ except ValueError:
+ return False
+
+
+def _normalize_ip(value: str) -> str:
+ cand = str(value or "").strip()
+ if cand.startswith("[") and cand.endswith("]"):
+ cand = cand[1:-1]
+ return cand[:128]
+
+
+def _header_map(headers: Mapping[str, str] | None) -> dict[str, str]:
+ if headers is None:
+ return {}
+ # Starlette Headers is case-insensitive; normalize keys for .get
+ return {str(k).lower(): str(v) for k, v in headers.items()}
+
+
+def resolve_client_ip_from(peer: str, headers: Mapping[str, str] | None = None) -> str:
+ """Pick the best client IP given the TCP peer and request headers."""
+ peer_ip = _normalize_ip(peer)
+ hdrs = _header_map(headers)
+ trusted = _trusted_peers()
+ peer_trusted = peer_ip in trusted or peer_ip.lower() in {x.lower() for x in trusted}
+
+ if not peer_trusted:
+ return peer_ip or ""
+
+ # Cloudflare (single value)
+ cf = (hdrs.get("cf-connecting-ip") or "").strip()
+ if _looks_like_ip(cf):
+ return _normalize_ip(cf)
+
+ # nginx often sets this to the original client
+ real = (hdrs.get("x-real-ip") or "").strip()
+ if _looks_like_ip(real):
+ return _normalize_ip(real)
+
+ # X-Forwarded-For: client, proxy1, proxy2 — take first public-looking hop
+ xff = hdrs.get("x-forwarded-for") or ""
+ for part in xff.split(","):
+ cand = part.strip()
+ if _looks_like_ip(cand):
+ return _normalize_ip(cand)
+
+ return peer_ip or ""
+
+
+def resolve_client_ip(request: Request) -> str:
+ peer = str(request.client.host if request.client else "") or ""
+ return resolve_client_ip_from(peer, request.headers)
+
+
+def resolve_websocket_client_ip(websocket: WebSocket) -> str:
+ peer = str(websocket.client.host if websocket.client else "") or ""
+ return resolve_client_ip_from(peer, websocket.headers)
diff --git a/netx_api/config.py b/netx_api/config.py
index 1968b5a..3ee9c87 100644
--- a/netx_api/config.py
+++ b/netx_api/config.py
@@ -163,6 +163,11 @@ class Settings(BaseSettings):
bootstrap_admin_password: str = "admin123"
# Written on first boot for MCP; path relative to cwd / absolute
auth_mcp_token_file: str = "data/auth/mcp_token"
+ # Cap non-revoked API keys (MCP / DSH / scripts). 0 = unlimited.
+ auth_api_token_max_count: int = 20
+ # Comma-separated peers allowed to supply X-Forwarded-For / X-Real-IP
+ # (Vite dev proxy, local nginx). Add your reverse-proxy LAN IP in production.
+ trusted_proxy_ips: str = "127.0.0.1,::1"
# Expose /docs /redoc /openapi.json without auth when true (lab only).
docs_enabled: bool = False
# Refuse start when bind host is non-loopback and insecure defaults remain.
diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py
index 9308d1f..74d02c8 100644
--- a/netx_api/webcrt_router.py
+++ b/netx_api/webcrt_router.py
@@ -126,12 +126,13 @@ class WebcrtSftpChmodBody(BaseModel):
def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str:
- host = ""
+ from .client_ip import resolve_client_ip, resolve_websocket_client_ip
+
if request is not None:
- host = request.client.host if request.client else ""
- elif websocket is not None:
- host = websocket.client.host if websocket.client else ""
- return str(host or "")
+ return resolve_client_ip(request)
+ if websocket is not None:
+ return resolve_websocket_client_ip(websocket)
+ return ""
@router.get("/sessions")
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 339b317..0b00176 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -440,6 +440,30 @@ class AuthApiTests(unittest.TestCase):
)
self.assertEqual(empty.status_code, 400)
+ def test_api_token_max_count(self) -> None:
+ token = self._login()
+ with patch("netx_api.auth_service.settings.auth_api_token_max_count", 2):
+ listed = self.client.get("/v1/api-tokens", headers={"Authorization": f"Bearer {token}"})
+ self.assertEqual(listed.status_code, 200, listed.text)
+ body = listed.json()
+ self.assertEqual(body.get("max_count"), 2)
+ active = int(body.get("active_count") or 0)
+ while active < 2:
+ created = self.client.post(
+ "/v1/api-tokens",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"name": f"cap-{active}", "expires_in_days": 7},
+ )
+ self.assertEqual(created.status_code, 200, created.text)
+ active += 1
+ blocked = self.client.post(
+ "/v1/api-tokens",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"name": "over-cap", "expires_in_days": 7},
+ )
+ self.assertEqual(blocked.status_code, 400, blocked.text)
+ self.assertIn("api_token_limit_reached", str(blocked.json().get("detail") or ""))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_client_ip.py b/tests/test_client_ip.py
new file mode 100644
index 0000000..f25495e
--- /dev/null
+++ b/tests/test_client_ip.py
@@ -0,0 +1,46 @@
+"""Unit tests for reverse-proxy client IP resolution."""
+
+from __future__ import annotations
+
+import unittest
+from unittest.mock import patch
+
+from netx_api.client_ip import resolve_client_ip_from
+
+
+class ClientIpTests(unittest.TestCase):
+ def test_direct_peer_when_not_trusted(self) -> None:
+ with patch("netx_api.client_ip.settings.trusted_proxy_ips", "127.0.0.1"):
+ ip = resolve_client_ip_from(
+ "203.0.113.9",
+ {"x-forwarded-for": "198.51.100.1", "x-real-ip": "198.51.100.1"},
+ )
+ self.assertEqual(ip, "203.0.113.9")
+
+ def test_xff_when_peer_is_loopback(self) -> None:
+ with patch("netx_api.client_ip.settings.trusted_proxy_ips", "127.0.0.1,::1"):
+ ip = resolve_client_ip_from(
+ "127.0.0.1",
+ {"x-forwarded-for": "198.51.100.44, 10.0.0.1"},
+ )
+ self.assertEqual(ip, "198.51.100.44")
+
+ def test_x_real_ip_preferred(self) -> None:
+ with patch("netx_api.client_ip.settings.trusted_proxy_ips", "127.0.0.1"):
+ ip = resolve_client_ip_from(
+ "127.0.0.1",
+ {"x-real-ip": "203.0.113.50", "x-forwarded-for": "198.51.100.1"},
+ )
+ self.assertEqual(ip, "203.0.113.50")
+
+ def test_cf_connecting_ip(self) -> None:
+ with patch("netx_api.client_ip.settings.trusted_proxy_ips", "127.0.0.1"):
+ ip = resolve_client_ip_from(
+ "127.0.0.1",
+ {"cf-connecting-ip": "203.0.113.77", "x-forwarded-for": "198.51.100.1"},
+ )
+ self.assertEqual(ip, "203.0.113.77")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/web/index.html b/web/index.html
index ea18e86..33804f1 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/web/package-lock.json b/web/package-lock.json
index 92cc7e4..8eb6317 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -8,6 +8,8 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
+ "@heroui/react": "^3.2.5",
+ "@heroui/styles": "^3.2.5",
"@tanstack/react-query": "^5.100.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
@@ -16,10 +18,12 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
+ "tailwind-variants": "^3.3.1",
"uplot": "^1.6.32"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@tailwindcss/vite": "^4.3.3",
"@types/dagre": "^0.7.54",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
@@ -29,6 +33,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
+ "tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
@@ -37,6 +42,50 @@
"node": ">=20.19.0"
}
},
+ "node_modules/@adobe/react-spectrum": {
+ "version": "3.47.5",
+ "resolved": "https://registry.npmjs.org/@adobe/react-spectrum/-/react-spectrum-3.47.5.tgz",
+ "integrity": "sha512-iTbAwwMijVnQjwjwbvkWn82AyRAbFNSJzt/VawWp1vXRIxVIO9COZ1pPG5Fj88xXgqlG8maVcv58p74vqqy5HA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@internationalized/date": "^3.12.4",
+ "@react-types/shared": "^3.36.1",
+ "@spectrum-icons/ui": "^3.7.2",
+ "@spectrum-icons/workflow": "^4.3.2",
+ "@swc/helpers": "^0.5.0",
+ "client-only": "^0.0.1",
+ "clsx": "^2.0.0",
+ "react-aria": "3.52.1",
+ "react-aria-components": "1.21.1",
+ "react-stately": "3.50.0",
+ "react-transition-group": "^4.4.5",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@adobe/react-spectrum-ui": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@adobe/react-spectrum-ui/-/react-spectrum-ui-1.2.1.tgz",
+ "integrity": "sha512-wcrbEE2O/9WnEn6avBnaVRRx88S5PLFsPLr4wffzlbMfXeQsy+RMQwaJd3cbzrn18/j04Isit7f7Emfn0dhrJA==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@adobe/react-spectrum-workflow": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@adobe/react-spectrum-workflow/-/react-spectrum-workflow-2.3.5.tgz",
+ "integrity": "sha512-b53VIPwPWKb/T5gzE3qs+QlGP5gVrw/LnWV3xMksDU+CRl3rzOKUwxIGiZO8ICyYh1WiyqY4myGlPU/nAynBUg==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -229,6 +278,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -439,6 +497,101 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
+ "node_modules/@formatjs/ecma402-abstract": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
+ "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@formatjs/fast-memoize": "2.2.7",
+ "@formatjs/intl-localematcher": "0.6.2",
+ "decimal.js": "^10.4.3",
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/fast-memoize": {
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
+ "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/icu-messageformat-parser": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
+ "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "2.3.6",
+ "@formatjs/icu-skeleton-parser": "1.8.16",
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/icu-skeleton-parser": {
+ "version": "1.8.16",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
+ "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "2.3.6",
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/intl-localematcher": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
+ "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@heroui/react": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/@heroui/react/-/react-3.2.5.tgz",
+ "integrity": "sha512-rIbgbyuNkwHqiDnlaM8ctN+jX5YpERtpz6rXy2Tg0+Fs7jx3OCXBkkeR07SrXcTf3CzjKWPVKb/J7MWtWPsn8w==",
+ "license": "MIT",
+ "dependencies": {
+ "@heroui/styles": "3.2.5",
+ "@radix-ui/react-avatar": "1.1.11",
+ "@react-stately/utils": "3.12.1",
+ "@react-types/color": "3.2.0",
+ "@react-types/shared": "3.36.1",
+ "input-otp": "1.4.2",
+ "tailwind-merge": "3.4.0",
+ "tailwind-variants": "3.3.1"
+ },
+ "peerDependencies": {
+ "@react-aria/i18n": "^3.13.1",
+ "@react-aria/ssr": "^3.10.1",
+ "@react-aria/utils": "^3.34.1",
+ "react": ">=19.0.0",
+ "react-aria": "^3.52.0",
+ "react-aria-components": "^1.21.0",
+ "react-dom": ">=19.0.0",
+ "tailwindcss": ">=4.0.0"
+ }
+ },
+ "node_modules/@heroui/styles": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/@heroui/styles/-/styles-3.2.5.tgz",
+ "integrity": "sha512-khSxtnNdzPcTNZfL6tcKvLUCRSClUd8V6vD1KnQAe3wEuxYtB59TYuJFCQKbxYoKT3Ss7zierBmDliW7/0qiQg==",
+ "license": "MIT",
+ "dependencies": {
+ "tailwind-variants": "3.3.1",
+ "tw-animate-css": "1.4.0"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=4.0.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -505,6 +658,44 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@internationalized/date": {
+ "version": "3.12.4",
+ "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.4.tgz",
+ "integrity": "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ }
+ },
+ "node_modules/@internationalized/message": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/@internationalized/message/-/message-3.1.10.tgz",
+ "integrity": "sha512-nc0Or6EdWHqZRcsXb6P9hBIpLsfSl/ILh0rk5h/OVBpzmhdExXtPy2cQtWsq8XKRBpRHwDNnAHt4OpolcB7dog==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@swc/helpers": "^0.5.0",
+ "intl-messageformat": "^10.1.0"
+ }
+ },
+ "node_modules/@internationalized/number": {
+ "version": "3.6.8",
+ "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.8.tgz",
+ "integrity": "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ }
+ },
+ "node_modules/@internationalized/string": {
+ "version": "3.2.10",
+ "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.10.tgz",
+ "integrity": "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -584,6 +775,301 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@radix-ui/react-avatar": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz",
+ "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.1.3",
+ "@radix-ui/react-primitive": "2.1.4",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-is-hydrated": "0.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz",
+ "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
+ "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-is-hydrated": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
+ "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.5.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-aria/color": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@react-aria/color/-/color-3.2.1.tgz",
+ "integrity": "sha512-v/pZh1yBNeayQ5Kio0xRIX0gBf6uu+KjCRW4QSydTyRpmhvk6sMzHT9i9/vIMdaDHaYHBXlXF+Tp/mvS9CG4Zw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0",
+ "react-aria": "^3.48.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/i18n": {
+ "version": "3.13.1",
+ "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.13.1.tgz",
+ "integrity": "sha512-z56ZYcbfpNmMyiGLhyEjytpmEfoTlBaksk84q4kds3HvNkf7QWKj+DJVfVDrJX+c1LyuBsszLSX7yxJRiHsYKQ==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@internationalized/date": "^3.12.1",
+ "@internationalized/message": "^3.1.9",
+ "@internationalized/string": "^3.2.8",
+ "@swc/helpers": "^0.5.0",
+ "react-aria": "^3.48.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/ssr": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.10.1.tgz",
+ "integrity": "sha512-jn038/ZYmu6DpfXJ6r2U9zFFppjbc9wnApPJSCxao2RZVEqep4YyoniHSy8qv6V21/xyS4IV7W9a+X2jOjSuag==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@swc/helpers": "^0.5.0",
+ "react-aria": "^3.48.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/utils": {
+ "version": "3.34.1",
+ "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.34.1.tgz",
+ "integrity": "sha512-H6+rGZL+0f58bBNaUMfctEnT+NogqwAk+nHiB8sR3K+YlQ37GTuCijy2U/pPvQtFMS5mURrjZeBH5JNNXsx14A==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@swc/helpers": "^0.5.0",
+ "react-aria": "^3.48.0",
+ "react-stately": "^3.46.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-spectrum/color": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@react-spectrum/color/-/color-3.2.1.tgz",
+ "integrity": "sha512-FbB+GaVufsN+obNJmUAe8XGZTeJjEWvEf/k/7nxDxoDCBdg/o6eEPONAZyID5Dj1f8BZKNym8VURxDZx7IIyuQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@adobe/react-spectrum": "^3.47.0",
+ "@swc/helpers": "^0.5.0",
+ "react-stately": "^3.46.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-spectrum/provider": {
+ "version": "3.11.1",
+ "resolved": "https://registry.npmjs.org/@react-spectrum/provider/-/provider-3.11.1.tgz",
+ "integrity": "sha512-TsoNdVdmlQ7L+75ILq5Yb3+wp/I1AtIeat0o+Y+ZBxP+TtWpwT1ZtCB5l3cplFVzHzOpZlzO0VaDrDP9ElGYDw==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@adobe/react-spectrum": "^3.47.0",
+ "@swc/helpers": "^0.5.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-stately/color": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@react-stately/color/-/color-3.10.1.tgz",
+ "integrity": "sha512-v4YlLa/oRQpPa/M9Yq1e0Bl8Z7xzGZdNDAIbfIMZuiJtacaOKriPW+AOgjjT/Ab0fNX2YDbwIYvkiMOJBnTi5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0",
+ "react-stately": "^3.46.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-stately/utils": {
+ "version": "3.12.1",
+ "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.12.1.tgz",
+ "integrity": "sha512-NqKfzrknpfwiewx7R2vk1P+CneClInPDsIhw15+jOcUYSEfej0nta4cJywuKQJ2gsPwqX/ojDNixedCve9FWGw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0",
+ "react-stately": "^3.46.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-types/color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@react-types/color/-/color-3.2.0.tgz",
+ "integrity": "sha512-beV3vz80nzZ1EuYUM7296Kyi3AHcMrbQw0qub/9yzHWVTKKc5sy/e4dCMKcWL/ArkeAyc7jDOiui190RQ4l0Fw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@react-aria/color": "^3.2.0",
+ "@react-spectrum/color": "^3.2.0",
+ "@react-stately/color": "^3.10.0"
+ },
+ "peerDependencies": {
+ "@react-spectrum/provider": "^3.0.0",
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-types/shared": {
+ "version": "3.36.1",
+ "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
+ "integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
@@ -848,6 +1334,318 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@spectrum-icons/ui": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.2.tgz",
+ "integrity": "sha512-7IuSHO+Ck8gJUIGIXqDcDIU4R8RHRUZhlfz8AwN1rSdCEvdgp6DWYN0LDTMrWh7TN/H5hrJ7oDO/gUpR3TnBjw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@adobe/react-spectrum-ui": "1.2.1",
+ "@babel/runtime": "^7.24.4",
+ "@swc/helpers": "^0.5.0"
+ },
+ "peerDependencies": {
+ "@adobe/react-spectrum": "^3.47.0",
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@spectrum-icons/workflow": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.2.tgz",
+ "integrity": "sha512-UkVg8Kp9rInjfqtG27in9p7RWfN9JFSvZr7ZOpN2n9jkrQ1y88hZpK8dKce8lgVb7jALXtsi2p0rjyaWzO0QsA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@adobe/react-spectrum-workflow": "2.3.5",
+ "@swc/helpers": "^0.5.0"
+ },
+ "peerDependencies": {
+ "@adobe/react-spectrum": "^3.47.0",
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.23",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
"node_modules/@tanstack/query-core": {
"version": "5.100.8",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.8.tgz",
@@ -1358,6 +2156,18 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -1455,6 +2265,21 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1494,7 +2319,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
@@ -1630,6 +2454,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1647,6 +2478,16 @@
"node": ">=8"
}
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
@@ -1654,6 +2495,20 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.5",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
+ "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -2000,6 +2855,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
@@ -2046,6 +2908,29 @@
"node": ">=0.8.19"
}
},
+ "node_modules/input-otp": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
+ "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/intl-messageformat": {
+ "version": "10.7.18",
+ "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
+ "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
+ "license": "BSD-3-Clause",
+ "peer": true,
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "2.3.6",
+ "@formatjs/fast-memoize": "2.2.7",
+ "@formatjs/icu-messageformat-parser": "2.11.4",
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2076,11 +2961,20 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@@ -2437,6 +3331,18 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2447,6 +3353,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -2503,6 +3419,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2632,6 +3557,17 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2651,6 +3587,46 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-aria": {
+ "version": "3.52.1",
+ "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.52.1.tgz",
+ "integrity": "sha512-fdZZruC9/x/joCg0mhKGs5aHpwrXLCSZ4GOJmhhYyiE0ffyEsk9MLFt9LCOCF9tn8ErTD+UDQP4oDUSlZkmpCg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@internationalized/date": "^3.12.4",
+ "@internationalized/number": "^3.6.8",
+ "@internationalized/string": "^3.2.10",
+ "@react-types/shared": "^3.36.1",
+ "@swc/helpers": "^0.5.0",
+ "aria-hidden": "^1.2.3",
+ "clsx": "^2.0.0",
+ "react-stately": "3.50.0",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/react-aria-components": {
+ "version": "1.21.1",
+ "resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.21.1.tgz",
+ "integrity": "sha512-J88WflYY0z+EhLX0ce+GjFlQ3ag3ubIgfUTjpKSrBQBu92Xtl4Ub9o118HItUddtbk1Ido0jzyPy94/klZy1hg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@internationalized/date": "^3.12.4",
+ "@internationalized/string": "^3.2.10",
+ "@react-types/shared": "^3.36.1",
+ "@swc/helpers": "^0.5.0",
+ "client-only": "^0.0.1",
+ "react-aria": "3.52.1",
+ "react-stately": "3.50.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
@@ -2663,6 +3639,12 @@
"react": "^19.2.5"
}
},
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
@@ -2701,6 +3683,39 @@
"react-dom": ">=18"
}
},
+ "node_modules/react-stately": {
+ "version": "3.50.0",
+ "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.50.0.tgz",
+ "integrity": "sha512-TnckvpDQGU0672wEaLZqdzvhuSeXWjgW6vijMWxiKOxc8CosQUfPGISdcZyfHqjX3XMjEtRxj4w9HumvX4h4mw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@internationalized/date": "^3.12.4",
+ "@internationalized/number": "^3.6.8",
+ "@internationalized/string": "^3.2.10",
+ "@react-types/shared": "^3.36.1",
+ "@swc/helpers": "^0.5.0",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
@@ -2797,6 +3812,58 @@
"node": ">=0.10.0"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
+ "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwind-variants": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz",
+ "integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.x",
+ "pnpm": ">=7.x"
+ },
+ "peerDependencies": {
+ "tailwind-merge": ">=3.0.0",
+ "tailwindcss": "*"
+ },
+ "peerDependenciesMeta": {
+ "tailwind-merge": {
+ "optional": true
+ },
+ "tailwindcss": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -2831,9 +3898,16 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
+ },
+ "node_modules/tw-animate-css": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
+ "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Wombosvideo"
+ }
},
"node_modules/type-check": {
"version": "0.4.0",
diff --git a/web/package.json b/web/package.json
index 1c8db04..3d529ca 100644
--- a/web/package.json
+++ b/web/package.json
@@ -13,6 +13,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "@heroui/react": "^3.2.5",
+ "@heroui/styles": "^3.2.5",
"@tanstack/react-query": "^5.100.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
@@ -21,10 +23,12 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
+ "tailwind-variants": "^3.3.1",
"uplot": "^1.6.32"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@tailwindcss/vite": "^4.3.3",
"@types/dagre": "^0.7.54",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
@@ -34,6 +38,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
+ "tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
diff --git a/web/src/components/HeaderMenu.tsx b/web/src/components/HeaderMenu.tsx
index 6f20d55..08a57c2 100644
--- a/web/src/components/HeaderMenu.tsx
+++ b/web/src/components/HeaderMenu.tsx
@@ -1,3 +1,4 @@
+import { Button } from "@heroui/react";
import { useI18n } from "../i18n";
/** Compact locale toggle: shows current language, click switches ZH ↔ EN. */
@@ -7,14 +8,14 @@ export function HeaderMenu() {
const label = locale === "zh" ? "ZH" : "EN";
return (
-
+
);
}
diff --git a/web/src/components/HopProxyFields.tsx b/web/src/components/HopProxyFields.tsx
index 5167267..257eba2 100644
--- a/web/src/components/HopProxyFields.tsx
+++ b/web/src/components/HopProxyFields.tsx
@@ -1,5 +1,6 @@
-import type { ReactNode } from "react";
+import { Input, Label, TextField } from "@heroui/react";
import { useI18n } from "../i18n";
+import { FieldSelect } from "./ui/FieldSelect";
import {
HOP_VENDORS,
defaultHopTemplate,
@@ -38,20 +39,6 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({
hop_enter_system_view: false,
});
-function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
- return (
-
- {children}
- {required ? (
-
- {" "}
- *
-
- ) : null}
-
- );
-}
-
function applyHopTemplate(
prev: HopProxyFieldsState,
protocol: string,
@@ -111,143 +98,151 @@ export function HopProxyFields({
return (
-
-
-
- {t("managedNe.hop.port")}
- set({ hop_port: Number(e.target.value) || 22 })}
- />
-
+
+ set({ hop_port: Number(hop_port) || 22 })}
+ >
+ {t("managedNe.hop.port")}
+
+
{bastion ? (
-
- {t("managedNe.hop.targetAuthMode")}
-
- {t("managedNe.hop.targetAuthHint")}
-
+ set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })}
+ >
+
+
+
) : null}
{cliHop ? (
-
- {t("managedNe.hop.protocol")}
-
-
+ {
+ const hop_protocol = e.target.value;
+ set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
+ }}
+ >
+
+
+
) : null}
-
- {t("managedNe.hop.username")}
- set({ hop_username: e.target.value })} />
-
-
-
+ set({ hop_username })}
+ >
+ {t("managedNe.hop.username")}
+
+
+ set({ hop_password })}
+ >
+
{t("managedNe.hop.password")}
{hopPasswordOptional ? (
({t("managedNe.form.passwordOptional")})
) : null}
-
- set({ hop_password: e.target.value })}
- />
-
+
+
+
{bastion ? (
-
- {t("managedNe.hop.usernameTemplate")}
- set({ hop_command_template: e.target.value })}
- placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)}
- />
+ set({ hop_command_template })}
+ >
+ {t("managedNe.hop.usernameTemplate")}
+
{t(templateHintKey(value.hop_vendor))}
-
+
) : null}
{cliHop ? (
<>
-
- {t(vrfLabelKey(value.hop_vendor))}
- {
- const hop_vrf = e.target.value;
- set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
- }}
- />
-
+ {
+ set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
+ }}
+ >
+ {t(vrfLabelKey(value.hop_vendor))}
+
+
{huawei ? (
-
- {t("managedNe.hop.enterSystemView")}
-
- {t("managedNe.hop.enterSystemViewHint")}
-
+ set({ hop_enter_system_view: e.target.value === "yes" })}
+ >
+
+
+
) : null}
-
- {t("managedNe.hop.commandTemplate")}
- set({ hop_command_template: e.target.value })}
- placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)}
- />
+ set({ hop_command_template })}
+ >
+ {t("managedNe.hop.commandTemplate")}
+
{t(templateHintKey(value.hop_vendor))}
-
+
>
) : null}
diff --git a/web/src/components/ListPager.tsx b/web/src/components/ListPager.tsx
index 2c22e4e..c68aed9 100644
--- a/web/src/components/ListPager.tsx
+++ b/web/src/components/ListPager.tsx
@@ -1,5 +1,7 @@
+import { Button, Input } from "@heroui/react";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
+import { FieldSelect } from "./ui/FieldSelect";
export type ListPagerProps = {
page: number;
@@ -19,7 +21,7 @@ export function ListPager({
pages,
total,
pageSize,
- pageSizeOptions = [20, 50, 100, 200],
+ pageSizeOptions = [10, 20, 50, 100, 200],
onPageChange,
onPageSizeChange,
disabled = false,
@@ -54,17 +56,18 @@ export function ListPager({
})}
-
+
{t("common.jumpPage")}
- setJumpDraft(e.target.value)}
onBlur={commitJump}
onKeyDown={(e) => {
@@ -84,20 +86,21 @@ export function ListPager({
/>
/ {safePages}
-
+
{onPageSizeChange ? (
-
+
) : null}
diff --git a/web/src/components/UmeCliConnectPanel.tsx b/web/src/components/UmeCliConnectPanel.tsx
index 846604c..2e173f9 100644
--- a/web/src/components/UmeCliConnectPanel.tsx
+++ b/web/src/components/UmeCliConnectPanel.tsx
@@ -1,3 +1,4 @@
+import { Button, Checkbox, Input, Label, TextField } from "@heroui/react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -12,6 +13,7 @@ import {
} from "../services/api";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields";
import { HelpHint } from "./HelpHint";
+import { FieldSelect } from "./ui/FieldSelect";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
@@ -214,77 +216,76 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
{cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")}
-
- {t("ume.cli.profileName")}
- setForm({ ...form, name: e.target.value })} />
-
-
- {t("ume.cli.targetUsername")}
- setForm({ ...form, username: e.target.value })}
- placeholder="ca-oper"
- />
-
-
- {t("ume.cli.targetPassword")}
- setForm({ ...form, password: e.target.value })}
- placeholder={form.id ? t("ume.cli.passwordKeep") : ""}
- />
-
-
- {t("managedNe.form.deviceType")}
-
-
-
- {t("managedNe.form.vendor")}
-
-
+ setForm({ ...form, name })}>
+ {t("ume.cli.profileName")}
+
+
+ setForm({ ...form, username })}
+ >
+ {t("ume.cli.targetUsername")}
+
+
+ setForm({ ...form, password })}
+ >
+ {t("ume.cli.targetPassword")}
+
+
+ setForm({ ...form, device_type_default: e.target.value })}
+ >
+ {deviceTypes.map((dt) => (
+
+ ))}
+
+ setForm({ ...form, vendor_default: e.target.value })}
+ >
+ {vendors.map((v) => (
+
+ ))}
+
{t("managedNe.hop.sectionTitle")}
-
- {
- const hop_enabled = e.target.checked;
- setForm((prev) => ({
- ...prev,
- hop_enabled,
- ...(hop_enabled
- ? {
- hop: {
- ...prev.hop,
- ...patchHopVendorChange(prev.hop.hop_vendor, prev.hop),
- ...applyHopTemplate(prev.hop, prev.hop.hop_protocol, prev.hop.hop_vrf, true),
- },
- }
- : {}),
- }));
- }}
- />
- {t("managedNe.hop.enable")}
-
+
{
+ setForm((prev) => ({
+ ...prev,
+ hop_enabled,
+ ...(hop_enabled
+ ? {
+ hop: {
+ ...prev.hop,
+ ...patchHopVendorChange(prev.hop.hop_vendor, prev.hop),
+ ...applyHopTemplate(prev.hop, prev.hop.hop_protocol, prev.hop.hop_vrf, true),
+ },
+ }
+ : {}),
+ }));
+ }}
+ >
+
+
+
+ {t("managedNe.hop.enable")}
+
{form.hop_enabled ? (
-
-
{form.id ? (
- {
- if (!window.confirm(t("ume.cli.deleteConfirm"))) return;
- try {
- await apiDelete(`/v1/cli/profiles/${form.id}`);
- setForm(emptyForm());
- await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles });
- showOk(t("ume.cli.deleted"));
- } catch (e) {
- showError(e instanceof Error ? e.message : String(e));
- }
+ {
+ void (async () => {
+ if (!window.confirm(t("ume.cli.deleteConfirm"))) return;
+ try {
+ await apiDelete(`/v1/cli/profiles/${form.id}`);
+ setForm(emptyForm());
+ await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles });
+ showOk(t("ume.cli.deleted"));
+ } catch (e) {
+ showError(e instanceof Error ? e.message : String(e));
+ }
+ })();
}}
>
{t("managedNe.delete")}
-
+
) : null}
{t("ume.cli.connectTestTitle")}
- setSampleUmeNeId(e.target.value)}
placeholder={t("ume.cli.sampleNePh")}
/>
- connectTestMutation.mutate()} disabled={connectTestMutation.isPending}>
+ connectTestMutation.mutate()}
+ >
{connectTestMutation.isPending || overrideTesting
? t("managedNe.connect.running")
: t("managedNe.connect.run")}
-
+
{testNeId ? (
@@ -385,10 +393,10 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
{t("ume.cli.existingProfiles")}
{(profilesQuery.data?.items || []).map((p) => (
- setForm(profileToForm(p))}>
+ setForm(profileToForm(p))}>
{p.name}
{p.is_default ? ` (${t("ume.cli.default")})` : ""}
-
+
))}
diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx
index f06597f..49376a7 100644
--- a/web/src/components/WebTerminal.tsx
+++ b/web/src/components/WebTerminal.tsx
@@ -1,3 +1,4 @@
+import { Button, Input } from "@heroui/react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
@@ -1031,9 +1032,10 @@ export const WebTerminal = forwardRef
(function WebTerm
}}
/>
- {
+ {
const text = pasteBridgeRef.current?.value || "";
setPasteBridgeOpen(false);
if (text) sendStdinThrottled(text);
@@ -1041,23 +1043,24 @@ export const WebTerminal = forwardRef(function WebTerm
}}
>
{t("webcrt.term.paste")}
-
- {
+
+ {
setPasteBridgeOpen(false);
focusTerminal();
}}
>
{t("webcrt.term.pasteBridgeCancel")}
-
+
) : null}
{findOpen ? (
- (function WebTerm
}
}}
/>
- {
+ onPress={() => {
const term = termRef.current;
if (!term || !findQuery) return;
const hit = findBufferLine(term, findQuery, Math.max(0, findLineRef.current) - 1, -1);
@@ -1103,12 +1106,12 @@ export const WebTerminal = forwardRef(function WebTerm
}}
>
↑
-
-
+ {
+ onPress={() => {
const term = termRef.current;
if (!term || !findQuery) return;
const hit = findBufferLine(term, findQuery, findLineRef.current + 1, 1);
@@ -1119,18 +1122,18 @@ export const WebTerminal = forwardRef(function WebTerm
}}
>
↓
-
-
+ {
+ onPress={() => {
setFindOpen(false);
focusTerminal();
}}
>
×
-
+
) : null}
(function WebTerm
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onMouseDown={(e) => e.stopPropagation()}
>
- void copySelection()}>
+ void copySelection()}>
{t("webcrt.term.copy")}
-
- void pasteFromClipboard()}>
+
+ void pasteFromClipboard()}>
{t("webcrt.term.paste")}
-
- {
+
+ {
termRef.current?.clear();
termRef.current?.write("\x1b[H\x1b[2J");
setCtxMenu(null);
}}
>
{t("webcrt.term.clear")}
-
+
) : null}
diff --git a/web/src/components/ui/AppModalShell.tsx b/web/src/components/ui/AppModalShell.tsx
new file mode 100644
index 0000000..1580141
--- /dev/null
+++ b/web/src/components/ui/AppModalShell.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from "react";
+import { Modal, useOverlayState } from "@heroui/react";
+
+export type AppModalShellProps = {
+ open: boolean;
+ onClose: () => void;
+ /** When false, backdrop click / Escape won't dismiss (e.g. while saving). */
+ dismissible?: boolean;
+ size?: "sm" | "md" | "lg" | "cover" | "full" | "xs";
+ className?: string;
+ children: ReactNode;
+};
+
+/**
+ * Controlled HeroUI Modal chrome for NetX dialogs.
+ * Keeps call sites on open/onClose while using v3 compound Modal.
+ */
+export function AppModalShell({
+ open,
+ onClose,
+ dismissible = true,
+ size = "md",
+ className = "",
+ children,
+}: AppModalShellProps) {
+ const state = useOverlayState({
+ isOpen: open,
+ onOpenChange: (next) => {
+ if (!next) onClose();
+ },
+ });
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+/** @deprecated Prefer AppModalShell — kept for topology imports. */
+export function TopoModalShell(props: AppModalShellProps) {
+ return ;
+}
diff --git a/web/src/components/ui/FieldSelect.tsx b/web/src/components/ui/FieldSelect.tsx
new file mode 100644
index 0000000..cc65495
--- /dev/null
+++ b/web/src/components/ui/FieldSelect.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode, SelectHTMLAttributes } from "react";
+import { Label } from "@heroui/react";
+
+/** Compact labeled native select — HeroUI Select is heavy for dense ops forms. */
+export function FieldSelect({
+ label,
+ hint,
+ required,
+ fullWidth,
+ className = "",
+ children,
+ ...props
+}: {
+ label?: ReactNode;
+ hint?: ReactNode;
+ required?: boolean;
+ fullWidth?: boolean;
+ className?: string;
+ children: ReactNode;
+} & SelectHTMLAttributes) {
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ {hint ? {hint} : null}
+
+ );
+}
diff --git a/web/src/components/ui/TopoModalShell.tsx b/web/src/components/ui/TopoModalShell.tsx
new file mode 100644
index 0000000..932d769
--- /dev/null
+++ b/web/src/components/ui/TopoModalShell.tsx
@@ -0,0 +1 @@
+export { AppModalShell, TopoModalShell, type AppModalShellProps } from "./AppModalShell";
diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts
index 3b0c5d1..dc84792 100644
--- a/web/src/constants/queryKeys.ts
+++ b/web/src/constants/queryKeys.ts
@@ -59,8 +59,19 @@ export const queryKeys = {
linkStatus: string,
page: number,
levelMajor?: string,
+ pageSize = 10,
) =>
- ["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page, levelMajor || ""] as const,
+ [
+ "fabricNodeInventory",
+ keyword,
+ role,
+ regionFolderId,
+ unmatched,
+ linkStatus,
+ page,
+ levelMajor || "",
+ pageSize,
+ ] as const,
fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const,
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
fabricSummary: ["fabricSummary"] as const,
diff --git a/web/src/hooks/useToast.tsx b/web/src/hooks/useToast.tsx
index b5d374c..f4d6c4e 100644
--- a/web/src/hooks/useToast.tsx
+++ b/web/src/hooks/useToast.tsx
@@ -1,4 +1,13 @@
-import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
export type ToastState = { type: "ok" | "error"; text: string } | null;
@@ -27,12 +36,22 @@ function useToastState(autoHideMs: number): ToastApi {
return useMemo(() => ({ toast, showOk, showError, clear }), [toast, showOk, showError, clear]);
}
+function ToastViewport({ toast }: { toast: NonNullable }) {
+ if (typeof document === "undefined") return null;
+ return createPortal(
+
+ {toast.text}
+
,
+ document.body,
+ );
+}
+
export function ToastProvider({ children, autoHideMs = 2600 }: { children: ReactNode; autoHideMs?: number }) {
const api = useToastState(autoHideMs);
return (
{children}
- {api.toast ? {api.toast.text}
: null}
+ {api.toast ? : null}
);
}
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index f6403ab..b5fa301 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -562,7 +562,8 @@ const en = {
loginFailed: "Login failed",
logout: "Sign out",
sessionsTitle: "Login sessions",
- sessionsHint: "Manage browser/device logins for this account. Revoked sessions must sign in again.",
+ sessionsHint:
+ "Manage browser/device logins for this account. Behind a reverse proxy, IP is taken from trusted forwarded headers; use the Client column to tell browsers apart on the same public IP. Revoked sessions must sign in again.",
sessionsEmpty: "No active sessions.",
revokeOtherSessions: "Revoke other sessions",
revokeSession: "Revoke",
@@ -571,6 +572,7 @@ const en = {
sessionsRevokedOthers: "Revoked {{count}} other session(s)",
revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?",
colSession: "Session",
+ colClient: "Client",
colLastSeen: "Last seen",
colCreated: "Created",
usersTitle: "User management",
@@ -603,7 +605,9 @@ const en = {
colIp: "IP",
apiKeysTitle: "API Key management",
apiKeysHelp:
- "The web UI uses login sessions, not API keys — keys are for MCP/scripts. Defaults on create: alarms:read + ne:read + ne:write + ne:exec; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved for future MCP tools.",
+ "The web UI uses login sessions, not API keys — keys are for MCP/scripts. Defaults on create: alarms:read + ne:read + ne:write + ne:exec; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved for future MCP tools. System-wide cap: {{max}} non-revoked keys (revoke to free a slot).",
+ tokenQuota: "Active {{active}} / {{max}}",
+ tokenLimitReached: "API key limit reached ({{max}}). Revoke unused keys first.",
tokenName: "Name",
expiresIn: "Expiry",
expire7d: "7 days",
@@ -1511,6 +1515,7 @@ const en = {
browserRegionsSub: "{{count}} item(s)",
browserEmptyMaps: "No root map under this root yet.",
addNe: "Add NE",
+ more: "More",
backUp: "Back",
newRegion: "New root",
newRegionName: "New root",
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index 795c03d..ce184a6 100644
--- a/web/src/i18n/zh.ts
+++ b/web/src/i18n/zh.ts
@@ -558,7 +558,8 @@ const zh = {
loginFailed: "登录失败",
logout: "退出",
sessionsTitle: "登录会话",
- sessionsHint: "管理当前账号在各浏览器/设备上的登录。踢掉会话后对方需重新登录。",
+ sessionsHint:
+ "管理当前账号在各浏览器/设备上的登录。IP 经反代时会取真实客户端地址;同公网出口可用「客户端」列区分浏览器/系统。踢掉会话后对方需重新登录。",
sessionsEmpty: "当前没有活跃会话。",
revokeOtherSessions: "踢掉其他会话",
revokeSession: "踢掉",
@@ -567,6 +568,7 @@ const zh = {
sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话",
revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?",
colSession: "会话",
+ colClient: "客户端",
colLastSeen: "最近活动",
colCreated: "创建时间",
usersTitle: "用户管理",
@@ -599,7 +601,9 @@ const zh = {
colIp: "IP",
apiKeysTitle: "API Key 管理",
apiKeysHelp:
- "网页登录不用 API Key,Key 给 MCP/脚本用。生成时默认 alarms:read + ne:read + ne:write + ne:exec,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接 MCP 的项为预留。",
+ "网页登录不用 API Key,Key 给 MCP/脚本用。生成时默认 alarms:read + ne:read + ne:write + ne:exec,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接 MCP 的项为预留。全站未吊销 Key 上限 {{max}} 把(吊销后可再建)。",
+ tokenQuota: "未吊销 {{active}} / {{max}}",
+ tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销不用的 Key",
tokenName: "名称",
expiresIn: "有效期",
expire7d: "7 天",
@@ -1500,6 +1504,7 @@ const zh = {
browserRegionsSub: "共 {{count}} 个",
browserEmptyMaps: "此根下还没有根图。",
addNe: "添加网元",
+ more: "更多",
backUp: "返回",
newRegion: "新建根",
newRegionName: "新根",
diff --git a/web/src/index.css b/web/src/index.css
index 1b36169..dbc8643 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -1,3 +1,22 @@
+/* HeroUI v3 + Tailwind CSS v4 — must come before app chrome styles */
+@import "tailwindcss";
+@import "@heroui/styles";
+
+/* Align HeroUI tokens with NetX dark ops shell */
+.dark,
+[data-theme="dark"] {
+ --background: oklch(0.14 0.02 255);
+ --foreground: oklch(0.92 0.01 250);
+ --surface: oklch(0.2 0.025 255);
+ --surface-secondary: oklch(0.24 0.025 255);
+ --overlay: oklch(0.2 0.025 255);
+ --accent: oklch(0.62 0.17 255);
+ --accent-foreground: oklch(0.99 0 0);
+ --muted: oklch(0.7 0.02 250);
+ --border: color-mix(in oklab, var(--foreground) 14%, transparent);
+ --field-background: oklch(0.18 0.02 255);
+}
+
* {
box-sizing: border-box;
}
@@ -1365,31 +1384,41 @@ pre {
outline: none;
}
-.toast {
+.toast,
+.app-toast {
position: fixed;
- right: 20px;
- bottom: 20px;
- padding: 12px 16px;
+ right: max(12px, env(safe-area-inset-right, 0px));
+ bottom: max(12px, env(safe-area-inset-bottom, 0px));
+ left: auto;
+ top: auto;
+ margin: 0;
+ padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: #0f172a;
color: #e2e8f0;
- font-size: 14px;
- line-height: 1.45;
- z-index: 60;
- max-width: 420px;
- box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4);
+ font-size: 12.5px;
+ font-weight: 500;
+ line-height: 1.35;
+ z-index: 10050;
+ width: max-content;
+ max-width: min(280px, calc(100vw - 24px));
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45);
+ pointer-events: none;
+ word-break: break-word;
}
-.toast--ok {
+.toast--ok,
+.app-toast--ok {
border-color: rgba(74, 222, 128, 0.35);
- background: rgba(20, 83, 45, 0.85);
+ background: rgba(20, 83, 45, 0.92);
color: #bbf7d0;
}
-.toast--error {
+.toast--error,
+.app-toast--error {
border-color: rgba(248, 113, 113, 0.4);
- background: rgba(127, 29, 29, 0.85);
+ background: rgba(127, 29, 29, 0.92);
color: #fecaca;
}
@@ -1736,6 +1765,17 @@ pre {
gap: 14px;
}
+/* Already inside network/audit shell — don't re-bleed or force full-viewport height
+ (that left a huge empty panel void under short lists like 任务概览). */
+.network-main .ne-page,
+.network-main .ume-page,
+.network-main .system-page {
+ margin: 0;
+ padding: 0;
+ min-height: 0;
+ background: transparent;
+}
+
.ne-page > .panel,
.ume-page > .panel,
.ume-page > .cards > .panel,
@@ -1815,11 +1855,11 @@ pre {
}
.ne-page .filter-inline input,
-.ne-page .filter-inline select,
+.ne-page .filter-inline > select,
.ume-page .filter-inline input,
-.ume-page .filter-inline select,
+.ume-page .filter-inline > select,
.system-page .filter-inline input,
-.system-page .filter-inline select {
+.system-page .filter-inline > select {
width: auto;
min-width: 140px;
flex: 1 1 160px;
@@ -1834,6 +1874,33 @@ pre {
font-size: 13px;
}
+.ne-page .filter-inline > .ui-field,
+.ume-page .filter-inline > .ui-field,
+.system-page .filter-inline > .ui-field {
+ flex: 1 1 160px;
+ max-width: 280px;
+ min-width: 140px;
+ align-self: center;
+ gap: 0;
+}
+
+.ne-page .filter-inline > .ui-field > .ui-field__select,
+.ume-page .filter-inline > .ui-field > .ui-field__select,
+.system-page .filter-inline > .ui-field > .ui-field__select {
+ width: 100%;
+ height: 32px;
+ min-height: 32px;
+ max-height: 32px;
+ flex: none;
+ margin: 0;
+ border: 1px solid rgba(148, 163, 184, 0.28);
+ border-radius: 6px;
+ background: #0b1220;
+ color: #e2e8f0;
+ padding: 0 10px;
+ font-size: 13px;
+}
+
.ne-page .filter-inline button,
.ume-page .filter-inline button,
.system-page .filter-inline button {
@@ -4493,11 +4560,14 @@ pre {
min-height: 0;
background: var(--nm-nav);
border: 1px solid var(--nm-nav-border);
- border-radius: 8px;
+ border-radius: 0;
+ border-left: 0;
+ border-top: 0;
+ border-bottom: 0;
padding: 10px;
overflow: hidden;
color: var(--nm-text);
- box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06);
+ box-shadow: none;
}
.topo-sidebar__resizer {
@@ -4629,8 +4699,8 @@ pre {
.app-main .topo-sidebar__icon-btn:hover:not(:disabled),
.topo-sidebar__icon-btn:hover:not(:disabled) {
- color: var(--nm-brand, #0f1b2d);
- background: rgba(15, 27, 45, 0.06) !important;
+ color: var(--nm-brand, #f1f5f9);
+ background: var(--nm-nav-hover, #152033) !important;
}
.app-main .topo-sidebar__icon-btn:disabled,
@@ -4739,7 +4809,7 @@ pre {
max-width: 100%;
padding: 2px 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
- border-radius: 999px;
+ border-radius: 6px;
background: rgba(30, 41, 59, 0.9);
font-size: 11px;
color: #93c5fd;
@@ -4765,8 +4835,8 @@ pre {
grid-template-columns: 1fr 1fr;
gap: 2px;
padding: 2px;
- background: #d7dee8;
- border: 1px solid var(--nm-nav-border);
+ background: rgba(15, 23, 42, 0.85);
+ border: 1px solid var(--nm-nav-border, rgba(148, 163, 184, 0.22));
border-radius: 6px;
}
@@ -4905,7 +4975,7 @@ pre {
color: #1d4ed8;
}
.topo-dir__icon--access {
- color: #6d28d9;
+ color: var(--nm-accent, #3b82f6);
}
.topo-dir__name {
flex: 1;
@@ -4925,37 +4995,7 @@ pre {
}
.topo-dir__meta {
font-size: 11px;
- color: #64748b;
-}
-.topo-dir__kind {
- flex: 0 0 auto;
- max-width: 4.5rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 0.02em;
- color: #64748b;
- background: #e8eef5;
- border-radius: 3px;
- padding: 1px 5px;
- line-height: 1.4;
-}
-.topo-dir__kind.is-physical {
- color: #9a3412;
- background: #ffedd5;
-}
-.topo-dir__badge {
- font-size: 10px;
- color: #64748b;
- min-width: 1.1rem;
- text-align: right;
- font-variant-numeric: tabular-nums;
- background: #e8eef5;
- border-radius: 8px;
- padding: 0 5px;
- line-height: 1.5;
+ color: var(--nm-muted, #94a3b8);
}
.topo-dir__action {
border: 0;
@@ -4976,8 +5016,8 @@ pre {
opacity: 1;
}
.topo-dir__action:hover {
- color: #334155;
- background: #e8eef5;
+ color: var(--nm-brand, #f1f5f9);
+ background: var(--nm-nav-hover, #152033);
}
.topo-dir__toolbar {
display: flex;
@@ -5147,27 +5187,27 @@ pre {
}
.app-main .topo-region-hex--tone-0,
.topo-region-hex--tone-0 {
- background: linear-gradient(160deg, #5b9cf0 0%, #1a56a8 52%, #0f766e 100%) !important;
+ background: linear-gradient(160deg, #3b82f6 0%, #1e3a5f 55%, #0f172a 100%) !important;
}
.app-main .topo-region-hex--tone-1,
.topo-region-hex--tone-1 {
- background: linear-gradient(160deg, #67e8f9 0%, #0284c7 48%, #1d4ed8 100%) !important;
+ background: linear-gradient(160deg, #38bdf8 0%, #1e40af 52%, #0f172a 100%) !important;
}
.app-main .topo-region-hex--tone-2,
.topo-region-hex--tone-2 {
- background: linear-gradient(160deg, #5eead4 0%, #0d9488 50%, #115e59 100%) !important;
+ background: linear-gradient(160deg, #2dd4bf 0%, #0f766e 50%, #0f172a 100%) !important;
}
.app-main .topo-region-hex--tone-3,
.topo-region-hex--tone-3 {
- background: linear-gradient(160deg, #fcd34d 0%, #f59e0b 46%, #b45309 100%) !important;
+ background: linear-gradient(160deg, #64748b 0%, #334155 48%, #0f172a 100%) !important;
}
.app-main .topo-region-hex--tone-4,
.topo-region-hex--tone-4 {
- background: linear-gradient(160deg, #cbd5e1 0%, #64748b 48%, #1e3a5f 100%) !important;
+ background: linear-gradient(160deg, #94a3b8 0%, #475569 48%, #1e293b 100%) !important;
}
.app-main .topo-region-hex--tone-physical,
.topo-region-hex--tone-physical {
- background: linear-gradient(160deg, #fcd34d 0%, #f59e0b 42%, #b45309 100%) !important;
+ background: linear-gradient(160deg, #f59e0b 0%, #92400e 46%, #0f172a 100%) !important;
}
.app-main .topo-region-hex--add,
.topo-region-hex--add {
@@ -5195,8 +5235,8 @@ pre {
flex: 0 0 auto;
}
.topo-region-hex--add .topo-region-hex__icon {
- background: rgba(26, 86, 168, 0.12);
- color: #1a56a8;
+ background: rgba(59, 130, 246, 0.2);
+ color: #93c5fd;
}
.topo-region-hex__name {
max-width: 100%;
@@ -5230,31 +5270,31 @@ pre {
white-space: nowrap;
}
.topo-region-hex--add .topo-region-hex__meta {
- color: #3d6f9c;
+ color: #93c5fd;
}
.topo-region-hex__plus {
display: grid;
place-items: center;
width: 32px;
height: 32px;
- border-radius: 50%;
+ border-radius: 8px;
font-size: 22px;
line-height: 1;
- background: rgba(26, 86, 168, 0.12);
- color: #1a56a8;
+ background: rgba(59, 130, 246, 0.2);
+ color: #93c5fd;
flex: 0 0 auto;
}
.topo-map-card {
display: flex;
flex-direction: column;
min-height: 168px;
- border: 1px solid rgba(148, 163, 184, 0.22);
- border-radius: 10px;
- background: rgba(15, 23, 42, 0.85);
+ border: 1px solid var(--nm-panel-border, rgba(148, 163, 184, 0.22));
+ border-radius: 8px;
+ background: var(--nm-panel-solid, #0f172a);
box-shadow: none;
overflow: hidden;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
- color: #e2e8f0;
+ color: var(--nm-text, #e2e8f0);
}
.topo-map-card:hover {
border-color: rgba(59, 130, 246, 0.45);
@@ -5300,7 +5340,7 @@ pre {
letter-spacing: 0.02em;
color: #93c5fd;
background: rgba(37, 99, 235, 0.18);
- border-radius: 999px;
+ border-radius: 6px;
padding: 2px 8px;
}
.topo-map-card__kind.is-physical {
@@ -5353,7 +5393,7 @@ pre {
.topo-map-card__add-plus {
width: 36px;
height: 36px;
- border-radius: 50%;
+ border-radius: 8px;
display: grid;
place-items: center;
font-size: 22px;
@@ -5423,7 +5463,7 @@ pre {
min-width: 180px;
max-width: 220px;
padding: 12px 14px;
- border-radius: 14px;
+ border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: linear-gradient(165deg, #1e293b 0%, #0f172a 100%);
box-shadow: none;
@@ -5442,28 +5482,28 @@ pre {
.topo-nav-node__icon {
width: 44px;
height: 44px;
- border-radius: 12px;
+ border-radius: 8px;
display: grid;
place-items: center;
flex: 0 0 auto;
- color: #1d4ed8;
- background: color-mix(in srgb, #2563eb 12%, #fff);
+ color: #93c5fd;
+ background: rgba(37, 99, 235, 0.18);
}
.topo-nav-node--region .topo-nav-node__icon {
- color: #0f766e;
- background: color-mix(in srgb, #0d9488 14%, #fff);
+ color: #5eead4;
+ background: rgba(13, 148, 136, 0.22);
}
.topo-nav-node--core .topo-nav-node__icon {
- color: #b45309;
- background: color-mix(in srgb, #f59e0b 16%, #fff);
+ color: #fbbf24;
+ background: rgba(245, 158, 11, 0.2);
}
.topo-nav-node--aggregation .topo-nav-node__icon {
- color: #1d4ed8;
- background: color-mix(in srgb, #3b82f6 14%, #fff);
+ color: #93c5fd;
+ background: rgba(59, 130, 246, 0.2);
}
.topo-nav-node--access .topo-nav-node__icon {
- color: #6d28d9;
- background: color-mix(in srgb, #8b5cf6 14%, #fff);
+ color: #93c5fd;
+ background: rgba(37, 99, 235, 0.22);
}
.topo-nav-node__body {
min-width: 0;
@@ -6138,7 +6178,7 @@ pre {
display: block;
font-weight: 650;
font-size: 12px;
- color: var(--nm-brand, #0f1b2d);
+ color: var(--nm-brand, #f1f5f9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -6148,7 +6188,7 @@ pre {
.topo-palette__meta {
display: block;
font-size: 11px;
- color: var(--nm-muted, #64748b);
+ color: var(--nm-muted, #94a3b8);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
overflow: hidden;
text-overflow: ellipsis;
@@ -6160,12 +6200,12 @@ pre {
flex-direction: column;
min-width: 0;
min-height: 0;
- background: #070b14;
- border: 1px solid rgba(148, 163, 184, 0.2);
- border-radius: 8px;
+ background: var(--nm-chrome, #070b14);
+ border: 0;
+ border-radius: 0;
overflow: hidden;
box-shadow: none;
- color: #e2e8f0;
+ color: var(--nm-text, #e2e8f0);
}
.topo-toolbar {
@@ -6232,11 +6272,11 @@ pre {
width: min(340px, 72vw);
max-height: 280px;
overflow: auto;
- background: #0f172a;
- border: 1px solid rgba(148, 163, 184, 0.28);
+ background: var(--nm-panel-solid, #0f172a);
+ border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.18));
border-radius: 8px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
- color: #e2e8f0;
+ color: var(--nm-text, #e2e8f0);
}
.topo-find-suggest__item {
@@ -6284,11 +6324,16 @@ pre {
height: 30px;
box-sizing: border-box;
padding: 0 8px;
- border: 1px solid #b8c4d2;
+ border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 6px;
font-size: 12px;
line-height: 1.2;
- background: #fff;
+ background: #0b1220;
+ color: var(--nm-text, #e2e8f0);
+}
+
+.topo-toolbar__find::placeholder {
+ color: var(--nm-muted, #94a3b8);
}
.topo-toolbar__find:disabled {
@@ -6308,13 +6353,13 @@ pre {
white-space: nowrap;
font-size: 14px;
font-weight: 650;
- color: var(--nm-brand, #0f1b2d);
+ color: var(--nm-brand, #f1f5f9);
letter-spacing: -0.01em;
}
.topo-toolbar__meta {
font-size: 12px;
- color: var(--nm-muted, #5b6b7c);
+ color: var(--nm-muted, #94a3b8);
white-space: nowrap;
}
@@ -6327,7 +6372,19 @@ pre {
flex-shrink: 0;
}
+.topo-toolbar__primary {
+ gap: 6px;
+}
+
+.app-main .topo-toolbar__save--dirty,
+.topo-toolbar__save--dirty {
+ background: var(--nm-accent, #3b82f6) !important;
+ border-color: transparent !important;
+ color: #f8fafc !important;
+}
+
.topo-toolbar__actions > .btn,
+.topo-toolbar__primary > button,
.topo-toolbar__group > .btn {
width: auto;
flex: 0 0 auto;
@@ -6335,6 +6392,7 @@ pre {
padding: 0 10px;
font-size: 12px;
line-height: 1;
+ border-radius: 6px;
}
.topo-toolbar__group > select.topo-toolbar__find {
@@ -6353,15 +6411,20 @@ pre {
align-items: center;
gap: 2px;
flex-shrink: 0;
+ padding: 2px;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 8px;
+ background: rgba(11, 18, 32, 0.65);
}
.topo-tools__btn {
display: inline-flex;
align-items: center;
gap: 4px;
+ min-height: 30px;
border: 0;
background: transparent;
- color: #94a3b8;
+ color: var(--nm-muted, #94a3b8);
font-size: 12px;
font-weight: 600;
padding: 5px 9px;
@@ -6372,12 +6435,14 @@ pre {
.topo-tools__btn:hover {
background: rgba(30, 41, 59, 0.9);
- color: #f1f5f9;
+ color: var(--nm-text, #f1f5f9);
}
-.topo-tools__btn.is-active {
- background: var(--nm-accent, #3b82f6);
- color: #f8fafc;
+.topo-tools__btn.is-active,
+.app-main .topo-tools__btn.is-active {
+ background: var(--nm-accent, #3b82f6) !important;
+ border-color: transparent !important;
+ color: #f8fafc !important;
}
.topo-tools__key {
@@ -6456,78 +6521,32 @@ pre {
max-height: min(70vh, 520px);
overflow: auto;
padding: 10px 12px;
- border: 1px solid rgba(148, 163, 184, 0.28);
+ border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.18));
border-radius: 8px;
- background: #0f172a;
+ background: var(--nm-panel-solid, #0f172a);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
- color: #e2e8f0;
+ color: var(--nm-text, #e2e8f0);
}
-.topo-toolbar__export {
- position: relative;
-}
-
-.topo-toolbar__export > summary {
- list-style: none;
- cursor: pointer;
- user-select: none;
- height: 30px;
- padding: 0 10px;
- border: 1px solid rgba(148, 163, 184, 0.28);
- border-radius: 6px;
- background: #0b1220;
- color: #e2e8f0;
- font-size: 12px;
- font-weight: 600;
- line-height: 1.2;
- display: inline-flex;
- align-items: center;
-}
-
-.topo-toolbar__export > summary::-webkit-details-marker {
- display: none;
-}
-
-.topo-toolbar__export[open] > summary {
- border-color: rgba(59, 130, 246, 0.55);
- background: rgba(37, 99, 235, 0.22);
- color: #93c5fd;
-}
-
-.topo-export-menu {
- position: absolute;
- top: calc(100% + 4px);
- right: 0;
- z-index: 20;
- min-width: 140px;
+.topo-toolbar__more-popover {
+ min-width: 168px;
padding: 4px;
- border: 1px solid rgba(148, 163, 184, 0.28);
+ border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.22));
border-radius: 8px;
- background: #0b1220;
- box-shadow: 0 8px 24px rgba(15, 23, 42, 0.45);
+ background: var(--nm-panel-solid, #0f172a);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+ color: var(--nm-text, #e2e8f0);
}
-.topo-export-menu__btn {
- display: block;
- width: 100%;
- padding: 8px 10px;
- border: 0;
+.topo-toolbar__more-popover [role="menuitem"] {
border-radius: 6px;
- background: transparent;
- color: #e2e8f0;
font-size: 12px;
- text-align: left;
- cursor: pointer;
+ color: var(--nm-text, #e2e8f0);
}
-.topo-export-menu__btn:hover:not(:disabled) {
- background: rgba(37, 99, 235, 0.22);
- color: #93c5fd;
-}
-
-.topo-export-menu__btn:disabled {
+.topo-toolbar__more-popover [role="menuitem"][data-disabled],
+.topo-toolbar__more-popover [role="menuitem"][aria-disabled="true"] {
opacity: 0.45;
- cursor: not-allowed;
}
.topo-mode-hint {
@@ -6690,7 +6709,7 @@ pre {
align-items: center;
gap: 6px;
font-size: 12px;
- color: #cbd5e1;
+ color: var(--nm-text, #e2e8f0);
white-space: nowrap;
cursor: pointer;
user-select: none;
@@ -6704,11 +6723,11 @@ pre {
padding: 4px 0;
margin: 0;
list-style: none;
- background: #0f172a;
- color: #e2e8f0;
- border: 1px solid #334155;
+ background: var(--nm-panel-solid, #0f172a);
+ color: var(--nm-text, #e2e8f0);
+ border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.18));
border-radius: 8px;
- box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28);
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
font-size: 12px;
line-height: 1.3;
}
@@ -6859,7 +6878,7 @@ pre {
.topo-ctx__sep {
height: 1px;
margin: 4px 0;
- background: #334155;
+ background: var(--nm-glass-border, rgba(148, 163, 184, 0.18));
}
.topo-discover {
@@ -6899,7 +6918,7 @@ pre {
.topo-discover__bar {
height: 6px;
- border-radius: 999px;
+ border-radius: 6px;
background: rgba(51, 65, 85, 0.9);
overflow: hidden;
}
@@ -6964,25 +6983,29 @@ pre {
.topo-discover__detail-btn {
font-size: 11px;
- color: #2563eb;
+ color: var(--nm-accent, #3b82f6);
white-space: nowrap;
}
.topo-discover__badge--lg {
font-size: 12px;
padding: 2px 8px;
- border-radius: 999px;
- background: #ecfdf5;
+ border-radius: 6px;
+ border: 1px solid rgba(34, 197, 94, 0.35);
+ background: rgba(20, 83, 45, 0.45);
+ color: #bbf7d0;
}
.topo-discover__badge--lg.is-fail {
- background: #fff1f2;
- color: #b91c1c;
+ border-color: rgba(248, 113, 113, 0.4);
+ background: rgba(127, 29, 29, 0.45);
+ color: #fecaca;
}
.topo-discover__badge--lg.is-warn {
- background: #fffbeb;
- color: #b45309;
+ border-color: rgba(245, 158, 11, 0.4);
+ background: rgba(120, 53, 15, 0.45);
+ color: #fde68a;
}
.topo-discover-modal {
@@ -6990,6 +7013,7 @@ pre {
width: min(960px, 100%);
max-height: min(88vh, 900px);
overflow: auto;
+ color: var(--nm-text, #e2e8f0);
}
.topo-discover-modal__head {
@@ -7001,35 +7025,36 @@ pre {
.topo-discover-modal__head h3 {
margin: 0;
+ color: var(--nm-brand, #f1f5f9);
}
.topo-discover-modal__sub {
margin: 4px 0 0;
font-size: 12px;
- color: #64748b;
+ color: var(--nm-muted, #94a3b8);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
}
.topo-discover-modal__stats {
margin-top: 8px;
font-size: 13px;
- color: #334155;
+ color: var(--nm-muted, #94a3b8);
}
.topo-discover-modal__section {
margin: 14px 0 8px;
font-size: 13px;
font-weight: 700;
- color: #0f172a;
+ color: var(--nm-brand, #f1f5f9);
}
.topo-discover-modal__table-wrap {
overflow-x: auto;
overflow-y: visible;
max-height: none;
- border: 1px solid #e2e8f0;
+ border: 1px solid var(--nm-panel-border, rgba(148, 163, 184, 0.22));
border-radius: 8px;
- background: #fff;
+ background: var(--nm-panel-solid, #0f172a);
}
.topo-discover-modal__table {
@@ -7042,21 +7067,25 @@ pre {
.topo-discover-modal__table th,
.topo-discover-modal__table td {
padding: 8px 10px;
- border-bottom: 1px solid #e2e8f0;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.16);
text-align: left;
vertical-align: top;
- color: #0f172a;
- background: #fff;
+ color: var(--nm-text, #e2e8f0);
+ background: transparent;
}
.topo-discover-modal__table th {
position: static;
- background: #f8fafc;
- color: #475569;
+ background: rgba(15, 23, 42, 0.95);
+ color: var(--nm-muted, #94a3b8);
font-weight: 600;
white-space: nowrap;
}
+.topo-discover-modal__table tbody tr:hover td {
+ background: rgba(30, 41, 59, 0.55);
+}
+
.topo-discover-modal__table tbody tr:last-child td {
border-bottom: 0;
}
@@ -7068,21 +7097,21 @@ pre {
}
.topo-discover-modal__peer span {
- color: #64748b;
+ color: var(--nm-muted, #94a3b8);
}
.topo-discover-action.is-added {
- color: #166534;
+ color: #86efac;
font-weight: 600;
}
.topo-discover-action.is-updated {
- color: #1d4ed8;
+ color: #93c5fd;
font-weight: 600;
}
.topo-discover-action.is-kept_manual {
- color: #a16207;
+ color: #fbbf24;
font-weight: 600;
}
@@ -7091,8 +7120,9 @@ pre {
padding: 10px 12px;
max-height: min(36vh, 360px);
overflow: auto;
- background: #0f172a;
- color: #e2e8f0;
+ background: #0b1220;
+ color: var(--nm-text, #e2e8f0);
+ border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
font-size: 11px;
line-height: 1.45;
@@ -7479,10 +7509,10 @@ pre {
.topo-node {
position: relative;
- width: 80px;
+ width: 88px;
min-width: 0;
max-width: none;
- height: 25px;
+ height: 28px;
padding: 0;
border: 0;
background: transparent;
@@ -7579,28 +7609,32 @@ pre {
}
.topo-node.is-placeholder .topo-node__glyph {
- outline: 1px dashed rgba(148, 163, 184, 0.65);
- outline-offset: 2px;
+ outline: none;
opacity: 0.88;
}
+.topo-node.is-placeholder .topo-node__icon {
+ filter: drop-shadow(0 0 1px rgba(148, 163, 184, 0.85));
+ opacity: 0.92;
+}
.topo-node__badge {
position: absolute;
- top: -3px;
- right: -4px;
+ top: -4px;
+ right: -6px;
z-index: 2;
- min-width: 14px;
- padding: 0 2px;
- border-radius: 2px;
- background: rgba(15, 23, 42, 0.92);
- border: 1px solid rgba(148, 163, 184, 0.45);
- color: #fbbf24;
- font-size: 6px;
+ min-width: 16px;
+ padding: 1px 4px;
+ border-radius: 999px;
+ background: color-mix(in srgb, #0f172a 88%, #fbbf24 12%);
+ border: 1px solid rgba(251, 191, 36, 0.45);
+ color: #fde68a;
+ font-size: 7px;
font-weight: 700;
- line-height: 1.2;
+ line-height: 1.25;
text-transform: uppercase;
- letter-spacing: 0.02em;
+ letter-spacing: 0.04em;
pointer-events: none;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.topo-node__conn {
@@ -7630,18 +7664,19 @@ pre {
.topo-node__alarm {
position: absolute;
- top: -4px;
- right: -5px;
+ top: -5px;
+ right: -6px;
z-index: 3;
- min-width: 14px;
- padding: 0 3px;
- border-radius: 8px;
+ min-width: 16px;
+ padding: 1px 4px;
+ border-radius: 999px;
font-size: 8px;
font-weight: 700;
line-height: 1.35;
color: #fff;
pointer-events: none;
border: 1px solid rgba(15, 23, 42, 0.75);
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
}
.topo-node__alarm--critical {
background: #dc2626;
@@ -7678,23 +7713,41 @@ pre {
pointer-events: none;
}
-.topo-node.is-level-external .topo-node__glyph {
- box-shadow: inset 0 0 0 2px #38bdf8;
-}
-.topo-node.is-level-core .topo-node__glyph {
- box-shadow: inset 0 0 0 2px #a78bfa;
-}
-.topo-node.is-level-aggregation .topo-node__glyph {
- box-shadow: inset 0 0 0 2px #34d399;
-}
-.topo-node.is-level-access .topo-node__glyph {
- box-shadow: inset 0 0 0 2px #fbbf24;
-}
-.topo-node.is-level-edge .topo-node__glyph {
- box-shadow: inset 0 0 0 2px #fb7185;
-}
+.topo-node.is-level-external .topo-node__glyph,
+.topo-node.is-level-core .topo-node__glyph,
+.topo-node.is-level-aggregation .topo-node__glyph,
+.topo-node.is-level-access .topo-node__glyph,
+.topo-node.is-level-edge .topo-node__glyph,
.topo-node.is-level-unmatched .topo-node__glyph {
- box-shadow: inset 0 0 0 2px rgba(148, 163, 184, 0.55);
+ box-shadow: none;
+ background: transparent;
+}
+/* Level tint on the icon art only — no rounded plate behind the glyph. */
+.topo-node.is-level-external {
+ --topo-level-ring: #38bdf8;
+}
+.topo-node.is-level-core {
+ --topo-level-ring: #60a5fa;
+}
+.topo-node.is-level-aggregation {
+ --topo-level-ring: #34d399;
+}
+.topo-node.is-level-access {
+ --topo-level-ring: #fbbf24;
+}
+.topo-node.is-level-edge {
+ --topo-level-ring: #fb7185;
+}
+.topo-node.is-level-unmatched {
+ --topo-level-ring: rgba(148, 163, 184, 0.7);
+}
+.topo-node.is-level-external .topo-node__icon,
+.topo-node.is-level-core .topo-node__icon,
+.topo-node.is-level-aggregation .topo-node__icon,
+.topo-node.is-level-access .topo-node__icon,
+.topo-node.is-level-edge .topo-node__icon,
+.topo-node.is-level-unmatched .topo-node__icon {
+ filter: drop-shadow(0 0 1.5px var(--topo-level-ring));
}
.topo-node.has-alarm--critical .topo-node__glyph {
@@ -7839,19 +7892,23 @@ pre {
.topo-node__glyph {
position: relative;
- width: 25px;
- height: 25px;
+ width: 28px;
+ height: 28px;
margin: 0 auto;
+ border-radius: 0;
+ background: transparent;
+ box-shadow: none;
+ transition: filter 0.15s ease;
}
.topo-node.is-region .topo-node__glyph {
- width: 25px;
- height: 17px;
+ width: 28px;
+ height: 19px;
}
.topo-node__icon--region {
- width: 25px;
- height: 17px;
+ width: 28px;
+ height: 19px;
}
.react-flow__node.is-search-hit {
@@ -7867,9 +7924,11 @@ pre {
.react-flow__node.is-search-hit .topo-node__glyph,
.react-flow__node.is-search-hit .topo-node__pixel,
.react-flow__node.is-search-hit .topo-node__pin {
- outline: 3px solid #fbbf24;
- outline-offset: 3px;
- border-radius: 50%;
+ outline: none;
+ border-radius: 0;
+}
+.react-flow__node.is-search-hit .topo-node__icon {
+ filter: drop-shadow(0 0 6px rgba(251, 191, 36, 0.95));
}
.react-flow__node.is-search-hit .topo-node__pixel,
@@ -7913,9 +7972,14 @@ pre {
}
.topo-node.is-selected .topo-node__glyph {
- outline: 2px solid #38bdf8;
- outline-offset: 2px;
- border-radius: 4px;
+ outline: none;
+ box-shadow: none;
+ background: transparent;
+}
+
+.topo-node.is-selected .topo-node__icon {
+ transform: none;
+ filter: drop-shadow(0 0 3px rgba(56, 189, 248, 0.95));
}
.topo-node__handle {
@@ -7941,10 +8005,6 @@ pre {
transform: translate(-50%, -50%) !important;
}
-.topo-node.is-selected .topo-node__icon {
- transform: none;
-}
-
.topo-node.is-selected .topo-node__caption {
font-weight: 700;
}
@@ -7963,8 +8023,8 @@ pre {
.topo-node__icon {
position: relative;
display: block;
- width: 25px;
- height: 25px;
+ width: 28px;
+ height: 28px;
margin: 0;
pointer-events: none;
user-select: none;
@@ -7993,50 +8053,64 @@ pre {
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
+ -webkit-mask-mode: alpha;
+ mask-mode: alpha;
+ pointer-events: none;
transition: background-color 0.12s ease;
}
.topo-node__caption {
position: absolute;
left: 50%;
- top: calc(100% + 1px);
+ top: calc(100% + 4px);
transform: translateX(-50%);
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
- gap: 0;
+ gap: 1px;
margin-top: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
+ box-shadow: none;
color: var(--topo-label-color, #f1f5f9);
- line-height: 1.15;
+ line-height: 1.2;
width: max-content;
- max-width: none;
+ max-width: 148px;
pointer-events: none;
- text-shadow:
- 0 0 2px rgba(15, 23, 42, 0.95),
- 0 1px 1px rgba(15, 23, 42, 0.85);
}
.topo-node__caption-name {
display: block;
- max-width: none;
- font-size: 8px;
- font-weight: 600;
+ max-width: 134px;
+ font-size: 10px;
+ font-weight: 650;
+ letter-spacing: 0.01em;
color: var(--topo-label-color, #f1f5f9);
white-space: nowrap;
- overflow: visible;
- text-overflow: clip;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.9),
+ 0 0 6px rgba(0, 0, 0, 0.65);
}
.topo-node__caption-meta {
display: block;
- max-width: none;
- font-size: 7px;
- color: color-mix(in srgb, var(--topo-label-color, #f1f5f9) 55%, #64748b);
+ max-width: 134px;
+ font-size: 8px;
+ color: color-mix(in srgb, var(--topo-label-color, #f1f5f9) 55%, #94a3b8);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
+ text-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.9),
+ 0 0 6px rgba(0, 0, 0, 0.55);
white-space: nowrap;
- overflow: visible;
- text-overflow: clip;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.topo-canvas .react-flow__edge-textbg {
@@ -8849,7 +8923,7 @@ html.login-page--paused .login-page__flare {
}
.network-main .filter-inline input,
-.network-main .filter-inline select {
+.network-main .filter-inline > select {
width: auto;
min-width: 140px;
flex: 1 1 160px;
@@ -8864,6 +8938,30 @@ html.login-page--paused .login-page__flare {
font-size: 13px;
}
+/* FieldSelect wrapper is the flex item; nested select must NOT inherit flex-basis as height */
+.network-main .filter-inline > .ui-field {
+ flex: 1 1 160px;
+ max-width: 280px;
+ min-width: 140px;
+ align-self: center;
+ gap: 0;
+}
+
+.network-main .filter-inline > .ui-field > .ui-field__select {
+ width: 100%;
+ height: 32px;
+ min-height: 32px;
+ max-height: 32px;
+ flex: none;
+ margin: 0;
+ border: 1px solid rgba(148, 163, 184, 0.28);
+ border-radius: 6px;
+ background: #0b1220;
+ color: #e2e8f0;
+ padding: 0 10px;
+ font-size: 13px;
+}
+
.network-main .filter-inline button {
height: 32px;
flex: 0 0 auto;
@@ -10598,39 +10696,31 @@ option {
.topo-browser,
.topo-main {
- background: #070b14 !important;
- color: #e2e8f0 !important;
- border-color: rgba(148, 163, 184, 0.2) !important;
+ background: var(--nm-chrome, #070b14);
+ color: var(--nm-text, #e2e8f0);
+ border-color: rgba(148, 163, 184, 0.2);
}
.topo-browser__head,
.topo-toolbar {
- background: rgba(15, 23, 42, 0.9) !important;
- border-color: rgba(148, 163, 184, 0.18) !important;
+ background: rgba(15, 23, 42, 0.9);
+ border-color: rgba(148, 163, 184, 0.18);
}
.topo-browser__grid,
.topo-browser__grid--regions {
- background: rgba(2, 8, 23, 0.4) !important;
+ background: rgba(2, 8, 23, 0.4);
}
-.topo-map-card {
- background: rgba(15, 23, 42, 0.85) !important;
- border-color: rgba(148, 163, 184, 0.22) !important;
- color: #e2e8f0 !important;
-}
-.topo-map-card__name { color: #f1f5f9 !important; }
.topo-map-list__row,
.topo-palette__row {
- background: rgba(15, 23, 42, 0.75) !important;
- border-color: rgba(148, 163, 184, 0.22) !important;
+ background: rgba(15, 23, 42, 0.75);
+ border-color: rgba(148, 163, 184, 0.22);
}
-.app-main .topo-toolbar__find,
-.topo-toolbar__find,
.app-main .topo-toolbar__select,
.topo-toolbar__select,
.app-main .topo-toolbar__display > summary,
.topo-toolbar__display > summary,
-.app-main .topo-toolbar__actions > .btn,
-.topo-toolbar__actions > .btn,
+.app-main .topo-toolbar__actions > .btn:not([data-variant="primary"]):not([data-variant="secondary"]),
+.topo-toolbar__actions > .btn:not([data-variant="primary"]):not([data-variant="secondary"]),
.app-main .topo-toolbar__group > .btn,
.topo-toolbar__group > .btn {
background: #0b1220 !important;
@@ -10639,12 +10729,12 @@ option {
}
.topo-nav-node {
- background: linear-gradient(165deg, #1e293b 0%, #0f172a 100%) !important;
- border-color: rgba(148, 163, 184, 0.28) !important;
- color: #e2e8f0 !important;
+ background: linear-gradient(165deg, #1e293b 0%, #0f172a 100%);
+ border-color: rgba(148, 163, 184, 0.28);
+ color: #e2e8f0;
}
.topo-nav-node__icon {
- background: rgba(37, 99, 235, 0.18) !important;
+ background: rgba(37, 99, 235, 0.18);
}
.webcrt-menubar__menu {
@@ -10710,3 +10800,135 @@ option {
.system-page .audit-detail-meta {
margin: 8px 0 0;
}
+
+/* HeroUI Modal chrome used by NetX dialogs */
+.app-heroui-modal-backdrop {
+ z-index: 1200;
+}
+
+.app-heroui-modal .modal__dialog,
+.app-heroui-modal__dialog,
+.topo-heroui-modal .modal__dialog,
+.topo-heroui-modal__dialog {
+ background: #0f172a;
+ color: #e2e8f0;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ max-height: min(90vh, 920px);
+ overflow: hidden;
+}
+
+/*
+ * HeroUI lg = max-w-lg (~32rem) — too narrow for NetX ops tables.
+ * Match legacy ops-detail-modal--xl (~1100px).
+ */
+.app-heroui-modal .modal__dialog--lg,
+.topo-heroui-modal .modal__dialog--lg {
+ width: min(1100px, 96vw);
+ max-width: min(1100px, 96vw);
+}
+
+.app-heroui-modal--xl .modal__dialog,
+.app-heroui-modal--xl .modal__dialog--lg,
+.app-heroui-modal--xl .modal__dialog--md {
+ width: min(1280px, 98vw);
+ max-width: min(1280px, 98vw);
+}
+
+.app-heroui-modal__dialog [data-slot="modal-body"],
+.app-heroui-modal__dialog .modal__body {
+ overflow: auto;
+ max-height: min(78vh, 780px);
+}
+
+.app-heroui-modal .pt-list-table-wrap {
+ overflow-x: auto;
+ max-width: 100%;
+ width: 100%;
+}
+
+.app-heroui-modal .pt-list-table {
+ width: 100%;
+ min-width: 100%;
+ table-layout: auto;
+}
+
+.app-heroui-modal .pt-list-table th,
+.app-heroui-modal .pt-list-table td {
+ white-space: nowrap;
+}
+
+/* Last flexible column absorbs leftover width so the table fills the row */
+.app-heroui-modal .pt-list-table td.ume-col-error,
+.app-heroui-modal .pt-list-table th.ume-col-error {
+ white-space: normal;
+ min-width: 12rem;
+ width: 100%;
+ max-width: none;
+ word-break: break-word;
+}
+
+/* Keep dense filter bars compact when FieldSelect has no label */
+.filter-inline > .ui-field {
+ gap: 0;
+ align-self: center;
+}
+
+.filter-inline > .ui-field > .ui-field__select {
+ height: 32px;
+ min-height: 32px;
+ max-height: 32px;
+ flex: none;
+}
+
+.login-card--heroui {
+ gap: 0;
+}
+
+.login-card__form {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ width: 100%;
+}
+
+.ui-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+
+.ui-field--full {
+ grid-column: 1 / -1;
+}
+
+.ui-field__label {
+ font-size: 12px;
+ color: var(--nm-muted, #94a3b8);
+}
+
+.ui-field__select {
+ width: 100%;
+ min-height: 36px;
+ padding: 0 10px;
+ border-radius: 10px;
+ border: 1px solid rgba(148, 163, 184, 0.28);
+ background: color-mix(in oklab, var(--field-background, #0f172a) 92%, #020617);
+ color: inherit;
+ font: inherit;
+}
+
+.ui-field__hint {
+ font-size: 11px;
+ color: var(--nm-muted, #94a3b8);
+ line-height: 1.35;
+}
+
+.pager__size-field {
+ min-width: 7.5rem;
+}
+
+.pager__jump .input,
+.pager__jump-input {
+ width: 4.25rem;
+}
diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx
index 83d6179..134a65e 100644
--- a/web/src/layout/AppLayout.tsx
+++ b/web/src/layout/AppLayout.tsx
@@ -1,6 +1,7 @@
import { Link, useLocation } from "react-router-dom";
import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
+import { Button, Chip } from "@heroui/react";
import { AppsGridIcon } from "../components/AppsGridIcon";
import { HeaderMenu } from "../components/HeaderMenu";
import { getPageTitleKey, isWorkbenchPath } from "../config/modules";
@@ -48,15 +49,16 @@ export function AppLayout({ children }: Props) {
{!onWorkbench ? (
<>
-
-
+
{pageTitle}
>
) : null}
@@ -64,24 +66,26 @@ export function AppLayout({ children }: Props) {
{user ? (
- 0 ? "up" : "unknown"}`}
- title={t("layout.activeTasksHint")}
+ 0 ? "success" : "default"}
+ variant="soft"
+ className="conn-pill conn-pill--on-brand conn-pill--tasks cursor-pointer"
onClick={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })}
>
- {t("layout.activeTasks", { count: activeTasks })}
-
-
- {user.username}
-
- {t("layout.activeTasks", { count: activeTasks })}
+
+
+ {user.username}
+
+ void logout()}
+ onPress={() => void logout()}
>
{t("auth.logout")}
-
+
) : (
diff --git a/web/src/pages/ApiTokensPage.tsx b/web/src/pages/ApiTokensPage.tsx
index 3a0a6f8..a82019b 100644
--- a/web/src/pages/ApiTokensPage.tsx
+++ b/web/src/pages/ApiTokensPage.tsx
@@ -1,6 +1,9 @@
+import { Button, Checkbox, Input, Modal } from "@heroui/react";
import { useMemo, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext";
+import { AppModalShell } from "../components/ui/AppModalShell";
+import { FieldSelect } from "../components/ui/FieldSelect";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { apiDelete, apiGet, apiPatch, apiPost } from "../services/api";
@@ -86,7 +89,8 @@ export function ApiTokensPage() {
const tokensQuery = useQuery({
queryKey: ["apiTokens"],
- queryFn: () => apiGet<{ items: TokenRow[] }>("/v1/api-tokens"),
+ queryFn: () =>
+ apiGet<{ items: TokenRow[]; active_count?: number; max_count?: number }>("/v1/api-tokens"),
enabled: ready,
});
@@ -98,6 +102,9 @@ export function ApiTokensPage() {
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
+ const tokenMax = Number(tokensQuery.data?.max_count ?? 20);
+ const tokenActive = Number(tokensQuery.data?.active_count ?? items.filter((r) => !r.revoked).length);
+ const atTokenLimit = tokenMax > 0 && tokenActive >= tokenMax;
const availableForCreate = useMemo(() => {
if (ownerUserId) {
@@ -203,40 +210,51 @@ export function ApiTokensPage() {
{t("auth.apiKeysTitle")}
-
+
?
- {t("auth.apiKeysHelp")}
+ {t("auth.apiKeysHelp", { max: tokenMax || 20 })}
+ {tokenMax > 0 ? (
+
+ {t("auth.tokenQuota", { active: tokenActive, max: tokenMax })}
+
+ ) : null}
+ {atTokenLimit ? (
+
{t("auth.tokenLimitReached", { max: tokenMax })}
+ ) : null}
@@ -260,12 +283,12 @@ export function ApiTokensPage() {
{t("auth.tokenOnceHint")}
{createdPlain}
-
void copyToken()}>
+ void copyToken()}>
{t("auth.copyToken")}
-
-
+
+
{t("common.close")}
-
+
) : null}
@@ -318,24 +341,24 @@ export function ApiTokensPage() {
- startEdit(row)}
+ startEdit(row)}
>
{t("auth.editScopes")}
-
- {
+
+ {
if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id);
}}
>
{t("auth.revokeToken")}
-
+
|
@@ -347,59 +370,52 @@ export function ApiTokensPage() {
- {editingRow ? (
-
-
setEditingRow(null)} />
-
-
-
-
{t("auth.editScopes")}
-
- {editingRow.name}
- {editingRow.username ? ` · ${editingRow.username}` : ""}
-
-
-
setEditingRow(null)}>
- {t("common.cancel")}
-
-
-
-
- updateMut.mutate({ id: editingRow.id, scopes: editScopes })}
- >
- {t("auth.saveScopes")}
-
- setEditingRow(null)}>
- {t("common.cancel")}
-
-
-
-
- ) : null}
+
setEditingRow(null)} size="md">
+
+ {t("auth.editScopes")}
+
+
+
+ {editingRow ? (
+
+ {editingRow.name}
+ {editingRow.username ? ` · ${editingRow.username}` : ""}
+
+ ) : null}
+
+ {ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => (
+ -
+ toggleEditScope(scope)}
+ >
+
+
+
+ {t(SCOPE_LABEL_KEYS[scope])}
+
+
+ ))}
+ {!editAvailable.length ? (
+ - {t("auth.scopesNoneAvailable")}
+ ) : null}
+
+
+
+ {
+ if (editingRow) updateMut.mutate({ id: editingRow.id, scopes: editScopes });
+ }}
+ >
+ {t("auth.saveScopes")}
+
+ setEditingRow(null)}>
+ {t("common.cancel")}
+
+
+
);
}
diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx
index 43bea5e..da96f09 100644
--- a/web/src/pages/AuditPage.tsx
+++ b/web/src/pages/AuditPage.tsx
@@ -1,3 +1,4 @@
+import { Button, Input } from "@heroui/react";
import { Fragment, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext";
@@ -132,7 +133,7 @@ export function AuditPage() {
queryFn: () => {
const p = new URLSearchParams();
p.set("page", String(page));
- p.set("page_size", "50");
+ p.set("page_size", "10");
p.set("exclude_noise", excludeNoise ? "true" : "false");
if (username.trim()) p.set("username", username.trim());
if (effectiveAction) p.set("action", effectiveAction);
@@ -145,7 +146,7 @@ export function AuditPage() {
const items = useMemo(() => query.data?.items || [], [query.data]);
const total = query.data?.total || 0;
- const pages = Math.max(1, Math.ceil(total / 50));
+ const pages = Math.max(1, Math.ceil(total / 10));
const hasFilters = Boolean(username.trim() || action.trim() || quick !== "business");
const setQuickFilter = (next: QuickFilter) => {
@@ -174,20 +175,20 @@ export function AuditPage() {
["all", "audit.filterAll"],
] as const
).map(([value, labelKey]) => (
- setQuickFilter(value)}
+ size="sm"
+ variant={quick === value && !action.trim() ? "primary" : "secondary"}
+ onPress={() => setQuickFilter(value)}
>
{t(labelKey)}
-
+
))}
{isAdmin ? (
- {
@@ -196,7 +197,7 @@ export function AuditPage() {
}}
/>
) : null}
- {
@@ -205,13 +206,14 @@ export function AuditPage() {
if (e.target.value.trim()) setQuick("all");
}}
/>
- void query.refetch()} disabled={query.isFetching}>
+ void query.refetch()} isDisabled={query.isFetching}>
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
-
- {
+
+ {
setUsername("");
setAction("");
setQuick("business");
@@ -219,7 +221,7 @@ export function AuditPage() {
}}
>
{t("common.clearFilters")}
-
+
{query.isLoading ? {t("common.refreshing")}
: null}
@@ -266,13 +268,14 @@ export function AuditPage() {
{row.client_ip || t("common.empty")} |
- setExpanded(open ? null : row.id)}
+ onPress={() => setExpanded(open ? null : row.id)}
>
{open ? t("audit.hideDetail") : t("audit.showDetail")}
-
+
|
{open ? (
@@ -304,16 +307,22 @@ export function AuditPage() {
})}
- setPage((p) => Math.max(1, p - 1))}>
+ setPage((p) => Math.max(1, p - 1))}
+ >
{t("common.prevPage")}
-
- = pages}
- onClick={() => setPage((p) => Math.min(pages, p + 1))}
+
+ = pages}
+ onPress={() => setPage((p) => Math.min(pages, p + 1))}
>
{t("common.nextPage")}
-
+
diff --git a/web/src/pages/CollectPage.tsx b/web/src/pages/CollectPage.tsx
index 4653a29..a1d03a9 100644
--- a/web/src/pages/CollectPage.tsx
+++ b/web/src/pages/CollectPage.tsx
@@ -1,7 +1,9 @@
+import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../components/ListPager";
+import { AppModalShell } from "../components/ui/AppModalShell";
import {
createNeCollection,
deleteCollectionJob,
@@ -29,8 +31,8 @@ import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
const POLL_MS = 2000;
-const ELIGIBLE_PAGE_SIZE = 20;
-const POLICY_TARGET_PAGE_SIZE = 20;
+const ELIGIBLE_PAGE_SIZE = 10;
+const POLICY_TARGET_PAGE_SIZE = 10;
const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const;
const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const RUN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
@@ -71,7 +73,7 @@ export function CollectPage() {
const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1);
const [jobPage, setJobPage] = useState(1);
- const [jobPageSize, setJobPageSize] = useState(20);
+ const [jobPageSize, setJobPageSize] = useState(10);
const [jobStatus, setJobStatus] = useState("");
const [jobKeyword, setJobKeyword] = useState("");
const [exportingJobs, setExportingJobs] = useState(false);
@@ -468,35 +470,42 @@ export function CollectPage() {
) : null}
- void exportJobsCsv()}
+ void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
-
-
+
+
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
-
- startFromPolicyMutation.mutate()}
+
+ startFromPolicyMutation.mutate()}
>
{startFromPolicyMutation.isPending ? t("collect.form.starting") : t("collect.collectNow")}
-
- setCreateOpen(true)}>
+
+ setCreateOpen(true)}>
{t("collect.create.expand")}
-
+
{running?.status === "running" || running?.status === "pending" ? (
- pauseMutation.mutate(running.id)}
- disabled={pauseMutation.isPending}
+ pauseMutation.mutate(running.id)}
>
{t("collect.jobs.pause")}
-
+
) : null}
@@ -600,14 +609,13 @@ export function CollectPage() {
- savePolicyMutation.mutate()}
+ savePolicyMutation.mutate()}
>
{t("collect.savePolicy")}
-
+
@@ -726,17 +734,18 @@ export function CollectPage() {
))}
- {
+ {
setJobKeyword("");
setJobStatus("");
setJobPage(1);
}}
>
{t("common.clearFilters")}
-
+
{jobsQuery.isLoading ? {t("common.refreshing")}
: null}
@@ -795,324 +804,312 @@ export function CollectPage() {
- {createOpen ? (
- setCreateOpen(false)}>
-
e.stopPropagation()}
- >
-
-
-
{t("collect.create.title")}
-
{t("collect.create.hint")}
-
-
-
setCreateOpen(false)}>
- {t("networkConfigs.close")}
-
+
setCreateOpen(false)}
+ dismissible={!createMutation.isPending}
+ size="lg"
+ className="ops-detail-modal"
+ >
+
+ {t("collect.create.title")}
+
+
+
+ {t("collect.create.hint")}
+
+
+
+ {t("collect.form.jobTitle")}
+ setTitle(e.target.value)}
+ placeholder={t("collect.form.jobTitlePh")}
+ />
+
+
+ {t("collect.form.commands")}
+
+
+
{t("collect.form.commandsHint")}
+
+
+
+
{t("collect.create.selectedTitle")}
+
+ {t("collect.create.selectedCount", { count: selectedList.length })}
+
+ {selectedList.length > 0 ? (
+
+ {t("collect.create.clearSelected")}
+
+ ) : null}
+ {selectedList.length === 0 ? (
+
{t("collect.create.selectedEmpty")}
+ ) : (
+
+ {selectedList.map((row) => {
+ const key = eligibleKey(row);
+ const src = String(row.source || "managed").toLowerCase();
+ return (
+
+
+ {row.name || row.ip_address}
+
+ {src}
+ {row.ip_address ? ` · ${row.ip_address}` : ""}
+ {row.vendor ? ` · ${row.vendor}` : ""}
+
+
+ removeNe(key)}>
+ {t("collect.create.remove")}
+
+
+ );
+ })}
+
+ )}
-
-
-
- {t("collect.form.jobTitle")}
- setTitle(e.target.value)}
- placeholder={t("collect.form.jobTitlePh")}
- />
-
-
- {t("collect.form.commands")}
-
-
-
{t("collect.form.commandsHint")}
-
-
-
-
{t("collect.create.selectedTitle")}
-
- {t("collect.create.selectedCount", { count: selectedList.length })}
-
- {selectedList.length > 0 ? (
-
- {t("collect.create.clearSelected")}
-
- ) : null}
+
+
+
+
{t("collect.create.pickTitle")}
+
{t("collect.create.pickHint")}
- {selectedList.length === 0 ? (
-
{t("collect.create.selectedEmpty")}
- ) : (
-
- {selectedList.map((row) => {
+
+ {
+ const rows = eligibleItems.filter(
+ (x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
+ );
+ addBatchNe(rows);
+ setPickSelectedIds([]);
+ }}
+ >
+ {t("collect.create.addBatch", { count: batchPickCount })}
+
+ void eligibleQuery.refetch()}
+ >
+ {eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
+
+
+
+
+ {
+ setNeKeyword(e.target.value);
+ setNePage(1);
+ setPickSelectedIds([]);
+ }}
+ />
+
+ {eligibleQuery.isLoading ?
{t("common.refreshing")}
: null}
+
-
-
-
-
-
{t("collect.create.pickTitle")}
-
{t("collect.create.pickHint")}
-
-
-
{
- const rows = eligibleItems.filter(
- (x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
- );
- addBatchNe(rows);
+ {neTotal > 0 ? (
+
+
+ {t("common.pagerMeta", {
+ total: String(neTotal),
+ page: String(nePage),
+ pages: String(nePages),
+ })}
+
+
+ {
+ setNePage(nePage - 1);
setPickSelectedIds([]);
}}
>
- {t("collect.create.addBatch", { count: batchPickCount })}
-
- eligibleQuery.refetch()}
- disabled={eligibleQuery.isFetching}
+ {t("common.prevPage")}
+
+ = nePages}
+ onPress={() => {
+ setNePage(nePage + 1);
+ setPickSelectedIds([]);
+ }}
>
- {eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
-
+ {t("common.nextPage")}
+
-
- {
- setNeKeyword(e.target.value);
- setNePage(1);
- setPickSelectedIds([]);
- }}
- />
-
- {eligibleQuery.isLoading ? {t("common.refreshing")}
: null}
-
- {neTotal > 0 ? (
-
-
- {t("common.pagerMeta", {
- total: String(neTotal),
- page: String(nePage),
- pages: String(nePages),
- })}
-
-
- {
- setNePage(nePage - 1);
- setPickSelectedIds([]);
- }}
- >
- {t("common.prevPage")}
-
- = nePages}
- onClick={() => {
- setNePage(nePage + 1);
- setPickSelectedIds([]);
- }}
- >
- {t("common.nextPage")}
-
-
-
- ) : null}
-
-
-
-
-
- {t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
-
-
- setCreateOpen(false)}>
- {t("networkConfigs.close")}
-
- createMutation.mutate()}
- >
- {createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
-
-
+ ) : null}
-
- ) : null}
-
- {expandedJobId ? (
-
setExpandedJobId("")}
- >
-
e.stopPropagation()}
+
+
+
+ {t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
+
+ setCreateOpen(false)}
>
-
-
-
{t("collect.jobs.detailTitle")}
-
- {(() => {
- const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job;
- return job
- ? `${job.title} · ${job.status} · ${job.success_count}/${job.ne_count}`
- : expandedJobId.slice(0, 8);
- })()}
-
-
-
- setExpandedJobId("")}>
- {t("networkConfigs.close")}
-
-
-
- {detailQuery.isLoading ? {t("common.refreshing")}
: null}
- j.id === expandedJobId)?.status ||
- detailQuery.data?.job.status ||
- ""
- }
- failCount={
- jobs.find((j) => j.id === expandedJobId)?.fail_count ??
- detailQuery.data?.job.fail_count ??
- 0
- }
- commands={detailQuery.data?.job.commands ?? ""}
- onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)}
- retryPending={actionPending}
- />
-
-
- ) : null}
+ {t("networkConfigs.close")}
+
+
createMutation.mutate()}
+ >
+ {createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
+
+
+
+
+
setExpandedJobId("")}
+ size="lg"
+ className="ops-detail-modal"
+ >
+
+ {t("collect.jobs.detailTitle")}
+
+
+
+
+ {(() => {
+ const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job;
+ return job
+ ? `${job.title} · ${job.status} · ${job.success_count}/${job.ne_count}`
+ : expandedJobId.slice(0, 8);
+ })()}
+
+ {detailQuery.isLoading ? {t("common.refreshing")}
: null}
+ j.id === expandedJobId)?.status ||
+ detailQuery.data?.job.status ||
+ ""
+ }
+ failCount={
+ jobs.find((j) => j.id === expandedJobId)?.fail_count ??
+ detailQuery.data?.job.fail_count ??
+ 0
+ }
+ commands={detailQuery.data?.job.commands ?? ""}
+ onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)}
+ retryPending={actionPending}
+ />
+
+
+ setExpandedJobId("")}>
+ {t("networkConfigs.close")}
+
+
+
);
}
@@ -1156,36 +1153,49 @@ function JobRow({
{formatSystemTime(job.created_at)} |
{job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")} |
-
+
{t("collect.jobs.expand")}
-
+
{canPause ? (
-
+
{t("collect.jobs.pause")}
-
+
) : null}
{canStart ? (
-
+
{startPending ? t("collect.jobs.starting") : t("collect.jobs.start")}
-
+
) : null}
{canRetryFailed ? (
-
+
{t("collect.jobs.retryFailed")}
-
+
) : null}
-
{t("collect.jobs.downloadResults")}
-
+
{canDelete ? (
-
+
{t("collect.jobs.delete")}
-
+
) : null}
|
@@ -1212,7 +1222,7 @@ function JobRunsPanel({
const { t } = useI18n();
const { showOk, showError } = useToast();
const [runPage, setRunPage] = useState(1);
- const [runPageSize, setRunPageSize] = useState(20);
+ const [runPageSize, setRunPageSize] = useState(10);
const [runStatus, setRunStatus] = useState("");
const [runKeyword, setRunKeyword] = useState("");
const [exportingRuns, setExportingRuns] = useState(false);
@@ -1322,17 +1332,22 @@ function JobRunsPanel({
))}
{hasRunFilters ? (
-
+
{t("common.clearFilters")}
-
+
) : null}
-
void exportRunsCsv()}>
+ void exportRunsCsv()}
+ >
{exportingRuns ? t("common.exporting") : t("common.exportCsv")}
-
+
{jobStatus !== "running" && failCount > 0 ? (
-
+
{t("collect.jobs.retryFailed")}
-
+
) : null}
{runsQuery.isLoading ?
{t("common.refreshing")}
: null}
diff --git a/web/src/pages/ConfigSyncPage.tsx b/web/src/pages/ConfigSyncPage.tsx
index 0d9972f..369efc6 100644
--- a/web/src/pages/ConfigSyncPage.tsx
+++ b/web/src/pages/ConfigSyncPage.tsx
@@ -1,7 +1,10 @@
+import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../components/ListPager";
+import { AppModalShell } from "../components/ui/AppModalShell";
+import { FieldSelect } from "../components/ui/FieldSelect";
import { queryKeys } from "../constants/queryKeys";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { useToast } from "../hooks/useToast";
@@ -24,7 +27,7 @@ import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
const POLL_MS = 2500;
-const TARGET_PAGE_SIZE = 20;
+const TARGET_PAGE_SIZE = 10;
export function ConfigSyncPage() {
const { t } = useI18n();
@@ -33,13 +36,13 @@ export function ConfigSyncPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [cyclePage, setCyclePage] = useState(1);
- const [cyclePageSize, setCyclePageSize] = useState(20);
+ const [cyclePageSize, setCyclePageSize] = useState(10);
const [cycleStatus, setCycleStatus] = useState("");
const [cycleKeyword, setCycleKeyword] = useState("");
const [exportingCycles, setExportingCycles] = useState(false);
const [expandedCycleId, setExpandedCycleId] = useState("");
const [taskPage, setTaskPage] = useState(1);
- const [taskPageSize, setTaskPageSize] = useState(20);
+ const [taskPageSize, setTaskPageSize] = useState(10);
const [taskStatus, setTaskStatus] = useState("");
const [taskKeyword, setTaskKeyword] = useState("");
const [exportingTasks, setExportingTasks] = useState(false);
@@ -354,44 +357,61 @@ export function ConfigSyncPage() {
{t("configSync.title")}
- void refresh()} disabled={dashQuery.isFetching}>
+ void refresh()}
+ >
{t("common.refresh")}
-
- startMut.mutate("full")}
+
+ startMut.mutate("full")}
>
{t("configSync.syncNow")}
-
- 0)}
- onClick={() => startMut.mutate("retry_failed")}
+
+ 0)}
+ onPress={() => startMut.mutate("retry_failed")}
>
{t("configSync.retryFailed")}
-
+
{running?.status === "running" || running?.status === "pending" ? (
- pauseMut.mutate(running.id)} disabled={pauseMut.isPending}>
+ pauseMut.mutate(running.id)}
+ >
{t("configSync.pause")}
-
+
) : null}
{running?.status === "paused" ? (
- resumeMut.mutate(running.id)} disabled={resumeMut.isPending}>
+ resumeMut.mutate(running.id)}
+ >
{t("configSync.resume")}
-
+
) : null}
{running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? (
- {
+ {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(running.id);
}}
- disabled={stopMut.isPending}
>
{t("configSync.stop")}
-
+
) : null}
@@ -436,41 +456,41 @@ export function ConfigSyncPage() {
{t("configSync.intervalDays")}
- setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
/>
{t("configSync.concurrency")}
- setConcurrency(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
/>
{t("configSync.historyKeep")}
- setHistoryKeep(Math.max(0, Math.min(30, Number(e.target.value) || 0)))}
/>
{t("configSync.cycleKeep")}
- setCycleKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))}
/>
@@ -484,14 +504,13 @@ export function ConfigSyncPage() {
-
savePolicyMut.mutate()}
+ savePolicyMut.mutate()}
>
{t("configSync.savePolicy")}
-
+
{scopeMode === "selected" ? (
@@ -540,9 +559,14 @@ export function ConfigSyncPage() {
- setTargetPage((p) => p - 1)}>
+ setTargetPage((p) => p - 1)}
+ >
{t("common.prevPage")}
-
+
{t("common.pagerMeta", {
total: String(targetsQuery.data?.total ?? 0),
@@ -550,13 +574,14 @@ export function ConfigSyncPage() {
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
})}
- = pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
- onClick={() => setTargetPage((p) => p + 1)}
+ = pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
+ onPress={() => setTargetPage((p) => p + 1)}
>
{t("common.nextPage")}
-
+
) : null}
@@ -565,7 +590,7 @@ export function ConfigSyncPage() {
{t("configSync.cyclesTitle")}
- {
@@ -573,7 +598,7 @@ export function ConfigSyncPage() {
setCyclePage(1);
}}
/>
-
- {
+
+ {
setCycleKeyword("");
setCycleStatus("");
setCyclePage(1);
}}
>
{t("common.clearFilters")}
-
- void exportCyclesCsv()}
+
+ void exportCyclesCsv()}
>
{exportingCycles ? t("common.exporting") : t("common.exportCsv")}
-
+
{cyclesQuery.isLoading ?
{t("common.refreshing")}
: null}
@@ -630,13 +657,9 @@ export function ConfigSyncPage() {
{cycles.map((c) => (
|
- openCycleTasks(c.id)}
- >
+ openCycleTasks(c.id)}>
{t("configSync.expand")}
-
+
|
{c.id.slice(0, 8)} |
{c.trigger_mode} |
@@ -665,25 +688,36 @@ export function ConfigSyncPage() {
{c.status === "running" || c.status === "pending" ? (
- pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
+ pauseMut.mutate(c.id)}
+ >
{t("configSync.pause")}
-
+
) : null}
{c.status === "paused" ? (
- resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
+ resumeMut.mutate(c.id)}
+ >
{t("configSync.resume")}
-
+
) : null}
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
- {
+ {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
}}
- disabled={stopMut.isPending}
>
{t("configSync.stop")}
-
+
) : null}
|
@@ -704,7 +738,7 @@ export function ConfigSyncPage() {
pages={cyclePages}
total={cycleTotal}
pageSize={cyclePageSize}
- pageSizeOptions={[20, 50, 100]}
+ pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setCyclePage}
onPageSizeChange={(size) => {
setCyclePageSize(size);
@@ -714,130 +748,117 @@ export function ConfigSyncPage() {
/>
- {expandedCycleId ? (
- setExpandedCycleId("")}
- >
-
e.stopPropagation()}
- >
-
-
-
{t("configSync.tasksTitle")}
-
- {expandedCycleId.slice(0, 8)}
- {(() => {
- const c = cycles.find((x) => x.id === expandedCycleId);
- return c
- ? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}`
- : "";
- })()}
-
-
-
- void exportTasksCsv()}
- >
- {exportingTasks ? t("common.exporting") : t("common.exportCsv")}
-
- setExpandedCycleId("")}>
- {t("networkConfigs.close")}
-
-
-
-
-
- {
- setTaskKeyword(e.target.value);
- setTaskPage(1);
- }}
- />
-
-
-
- {tasksQuery.isLoading ?
{t("common.refreshing")}
: null}
- {tasksQuery.isError ? (
-
{formatErr(tasksQuery.error)}
- ) : null}
-
-
-
-
-
-
- | {t("configSync.col.name")} |
- IP |
- {t("configSync.col.vendor")} |
- {t("configSync.col.source")} |
- {t("configSync.col.status")} |
- {t("configSync.col.message")} |
-
-
-
- {(tasksQuery.data?.items ?? []).map((task) => (
-
- | {task.ne_name || task.target_id} |
- {task.ne_ip} |
- {task.vendor || "-"} |
- {task.source} |
- {task.status} |
- {task.message || "-"} |
-
- ))}
- {!tasksQuery.isLoading && !(tasksQuery.data?.items ?? []).length ? (
-
- |
- {t("common.empty")}
- |
-
- ) : null}
-
-
-
-
-
-
- {
- setTaskPageSize(size);
- setTaskPage(1);
- }}
- disabled={tasksQuery.isLoading}
- />
-
+
setExpandedCycleId("")}
+ size="lg"
+ >
+
+ {t("configSync.tasksTitle")}
+
+
+
+
+ {expandedCycleId.slice(0, 8)}
+ {(() => {
+ const c = cycles.find((x) => x.id === expandedCycleId);
+ return c
+ ? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}`
+ : "";
+ })()}
+
+
+ {
+ setTaskKeyword(e.target.value);
+ setTaskPage(1);
+ }}
+ />
+ {
+ setTaskStatus(e.target.value);
+ setTaskPage(1);
+ }}
+ >
+
+
+
+
+
+
+
+ void exportTasksCsv()}
+ >
+ {exportingTasks ? t("common.exporting") : t("common.exportCsv")}
+
-
- ) : null}
+
+ {tasksQuery.isLoading ?
{t("common.refreshing")}
: null}
+ {tasksQuery.isError ? (
+
{formatErr(tasksQuery.error)}
+ ) : null}
+
+
+
+
+
+ | {t("configSync.col.name")} |
+ IP |
+ {t("configSync.col.vendor")} |
+ {t("configSync.col.source")} |
+ {t("configSync.col.status")} |
+ {t("configSync.col.message")} |
+
+
+
+ {(tasksQuery.data?.items ?? []).map((task) => (
+
+ | {task.ne_name || task.target_id} |
+ {task.ne_ip} |
+ {task.vendor || "-"} |
+ {task.source} |
+ {task.status} |
+ {task.message || "-"} |
+
+ ))}
+ {!tasksQuery.isLoading && !(tasksQuery.data?.items ?? []).length ? (
+
+ |
+ {t("common.empty")}
+ |
+
+ ) : null}
+
+
+
+
+
{
+ setTaskPageSize(size);
+ setTaskPage(1);
+ }}
+ disabled={tasksQuery.isLoading}
+ />
+
+
+ setExpandedCycleId("")}>
+ {t("networkConfigs.close")}
+
+
+
);
}
diff --git a/web/src/pages/ForceChangePasswordPage.tsx b/web/src/pages/ForceChangePasswordPage.tsx
index ef7959d..7470c08 100644
--- a/web/src/pages/ForceChangePasswordPage.tsx
+++ b/web/src/pages/ForceChangePasswordPage.tsx
@@ -1,3 +1,4 @@
+import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react";
import { useState, type FormEvent } from "react";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n";
@@ -44,72 +45,79 @@ export function ForceChangePasswordPage() {
return (
-
+
+
+
+ {t("auth.forceChangeHint", { user: user?.username || "admin" })}
+
+
+
+
);
}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
index 3b416e9..812c812 100644
--- a/web/src/pages/LoginPage.tsx
+++ b/web/src/pages/LoginPage.tsx
@@ -1,3 +1,4 @@
+import { Alert, Button, Card, Form, Input, InputGroup, Label, TextField } from "@heroui/react";
import { useState, type FormEvent } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
@@ -34,75 +35,96 @@ export function LoginPage() {
return (
-
+ {t("auth.username")}
+
+
+
+
+ {t("auth.password")}
+
+
+
+ setShowPassword((v) => !v)}
+ >
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {error ? (
+
+
+ {error}
+
+
+ ) : null}
+
+
+ {busy ? t("auth.loggingIn") : t("auth.login")}
+
+
+
+
);
}
diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx
index b085cf1..7eddf4a 100644
--- a/web/src/pages/NePage.tsx
+++ b/web/src/pages/NePage.tsx
@@ -1,6 +1,7 @@
-import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react";
import {
batchApplyAccountManagedNe,
batchApplyHopManagedNe,
@@ -20,6 +21,8 @@ import {
} from "../services/api";
import { HelpHint } from "../components/HelpHint";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields";
+import { AppModalShell } from "../components/ui/AppModalShell";
+import { FieldSelect } from "../components/ui/FieldSelect";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
@@ -51,20 +54,6 @@ const emptyAccount = (): AccountState => ({
password: "",
});
-function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
- return (
-
- {children}
- {required ? (
-
- {" "}
- *
-
- ) : null}
-
- );
-}
-
export function NePage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
@@ -79,7 +68,7 @@ export function NePage() {
const [vendorFilter, setVendorFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(50);
+ const [pageSize, setPageSize] = useState(10);
const [selected, setSelected] = useState([]);
const [modalOpen, setModalOpen] = useState(false);
const [batchHopOpen, setBatchHopOpen] = useState(false);
@@ -438,38 +427,41 @@ export function NePage() {
{t("managedNe.stats.title")}
- {
+ {
setBulkTagAction("proxy");
setBulkHop(emptyHopProxyFields());
setBulkTagModalOpen(true);
}}
>
{t("managedNe.stats.batchProxy")}
-
- {
+
+ {
setBulkTagAction("account");
setBulkAccount(emptyAccount());
setBulkTagModalOpen(true);
}}
>
{t("managedNe.account.batchByTag")}
-
- {
+
+ {
setBulkTagAction("test");
setBulkTagModalOpen(true);
}}
>
{t("managedNe.stats.batchTest")}
-
+
@@ -499,50 +491,53 @@ export function NePage() {
{t("managedNe.title")}
@@ -614,7 +612,7 @@ export function NePage() {
- {
@@ -622,7 +620,7 @@ export function NePage() {
setPage(1);
}}
/>
-
-
- {
+
+ {
setKeyword("");
setVendorFilter("");
setStatusFilter("");
@@ -660,7 +659,7 @@ export function NePage() {
}}
>
{t("common.clearFilters")}
-
+
{listQuery.isLoading ?
{t("common.refreshing")}
: null}
@@ -675,12 +674,15 @@ export function NePage() {
|
-
+ >
+
+
+
+
|
{t("managedNe.col.name")} |
{t("managedNe.col.vendor")} |
@@ -753,38 +755,44 @@ export function NePage() {
-
+
openOrFocusModule({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`,
})
}
- title={t("managedNe.openTerminal")}
>
{t("managedNe.openTerminal")}
-
- setConnectDetailRow(row)}
- disabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail}
+
+ setConnectDetailRow(row)}
>
{t("managedNe.connectDetail")}
-
- openEdit(row)} disabled={!canWriteNe}>
+
+ openEdit(row)}
+ >
{t("managedNe.edit")}
-
- {
+
+ {
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
}}
>
{t("managedNe.delete")}
-
+
|
@@ -804,23 +812,34 @@ export function NePage() {
})}
- setPage(Math.max(1, page - 1))}>
+ setPage(Math.max(1, page - 1))}
+ >
{t("common.prevPage")}
-
- = pages} onClick={() => setPage(page + 1)}>
+
+ = pages}
+ onPress={() => setPage(page + 1)}
+ >
{t("common.nextPage")}
-
-
+
@@ -845,185 +864,213 @@ export function NePage() {
}}
/>
- {batchHopOpen ? (
- setBatchHopOpen(false)}>
-
e.stopPropagation()}>
-
{t("managedNe.hop.batchTitle")}
-
{t("managedNe.hop.batchHint", { n: selected.length })}
-
setBatchHop((prev) => ({ ...prev, ...patch }))} />
-
- setBatchHopOpen(false)}>
- {t("managedNe.form.cancel")}
-
- {
- if (!batchHop.hop_host.trim()) {
- showError(t("managedNe.hop.hostRequired"));
- return;
- }
- if (!batchHop.hop_username.trim()) {
- showError(t("managedNe.hop.userRequired"));
- return;
- }
- if (!batchHop.hop_password && batchHop.hop_target_auth_mode !== "bastion_managed") {
- showError(t("managedNe.hop.passwordRequired"));
- return;
- }
- batchHopMutation.mutate();
- }}
- >
- {batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.apply")}
-
-
-
-
- ) : null}
+ setBatchHopOpen(false)}
+ dismissible={!batchHopMutation.isPending}
+ size="lg"
+ >
+
+ {t("managedNe.hop.batchTitle")}
+
+
+
+ {t("managedNe.hop.batchHint", { n: selected.length })}
+ setBatchHop((prev) => ({ ...prev, ...patch }))} />
+
+
+ setBatchHopOpen(false)}>
+ {t("managedNe.form.cancel")}
+
+ {
+ if (!batchHop.hop_host.trim()) {
+ showError(t("managedNe.hop.hostRequired"));
+ return;
+ }
+ if (!batchHop.hop_username.trim()) {
+ showError(t("managedNe.hop.userRequired"));
+ return;
+ }
+ if (!batchHop.hop_password && batchHop.hop_target_auth_mode !== "bastion_managed") {
+ showError(t("managedNe.hop.passwordRequired"));
+ return;
+ }
+ batchHopMutation.mutate();
+ }}
+ >
+ {batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.apply")}
+
+
+
- {batchAccountOpen ? (
- setBatchAccountOpen(false)}>
-
e.stopPropagation()}>
-
{t("managedNe.account.batchTitle")}
-
{t("managedNe.account.batchHint", { n: selected.length })}
-
-
- ) : null}
-
- {bulkTagModalOpen ? (
-
setBulkTagModalOpen(false)}>
-
e.stopPropagation()}>
-
- {bulkTagAction === "proxy"
- ? t("managedNe.stats.batchProxy")
+ value={bulkAccount.password}
+ onChange={(password) => setBulkAccount((prev) => ({ ...prev, password }))}
+ >
+
+ {t("managedNe.col.password")}
+ ({t("managedNe.account.passwordOptionalBatch")})
+
+
+
+
+ >
+ ) : (
+
{t("managedNe.stats.confirm", { n: "?" })}
+ )}
+
+
+ setBulkTagModalOpen(false)}>
+ {t("managedNe.form.cancel")}
+
+ {
+ 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
+ ? t("managedNe.stats.loadingIds")
+ : bulkTagAction === "proxy"
+ ? t("managedNe.hop.apply")
: bulkTagAction === "account"
- ? t("managedNe.account.batchByTag")
- : t("managedNe.stats.batchTest")}
- {bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""}
- {bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""}
- {bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}
-
-
- {t("managedNe.stats.tagFilter")}
-
-
- {bulkTagAction === "proxy" ? (
- <>
- {t("managedNe.hop.batchHint", { n: "?" })}
- setBulkHop((prev) => ({ ...prev, ...patch }))} />
- >
- ) : bulkTagAction === "account" ? (
- <>
- {t("managedNe.account.batchByTagHint")}
-
-
- {t("managedNe.col.user")}
- setBulkAccount((prev) => ({ ...prev, username: e.target.value }))}
- />
-
-
-
- {t("managedNe.col.password")}
- ({t("managedNe.account.passwordOptionalBatch")})
-
- setBulkAccount((prev) => ({ ...prev, password: e.target.value }))}
- />
-
-
- >
- ) : (
- {t("managedNe.stats.confirm", { n: "?" })}
- )}
-
- setBulkTagModalOpen(false)}>
- {t("managedNe.form.cancel")}
-
- {
- 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
- ? t("managedNe.stats.loadingIds")
- : bulkTagAction === "proxy"
- ? t("managedNe.hop.apply")
- : bulkTagAction === "account"
- ? t("managedNe.account.apply")
- : t("managedNe.connect.run")}
-
-
-
-
- ) : null}
+ ? t("managedNe.account.apply")
+ : t("managedNe.connect.run")}
+
+
+
- !s.current).length === 0}
- onClick={() => revokeOthersMut.mutate()}
+ !s.current).length === 0}
+ onPress={() => revokeOthersMut.mutate()}
>
{t("auth.revokeOtherSessions")}
-
- void sessionsQuery.refetch()} disabled={sessionsQuery.isFetching}>
+
+ void sessionsQuery.refetch()}
+ isDisabled={sessionsQuery.isFetching}
+ >
{t("common.refresh")}
-
+
{sessionsQuery.isLoading ? {t("common.refreshing")}
: null}
@@ -76,6 +114,7 @@ export function SessionsPage() {
| {t("auth.colSession")} |
{t("auth.colIp")} |
+ {t("auth.colClient")} |
{t("auth.colLastSeen")} |
{t("auth.colCreated")} |
{t("auth.actions")} |
@@ -85,27 +124,31 @@ export function SessionsPage() {
{items.map((row) => (
- {row.id.slice(0, 10)}…
+ {row.id.slice(0, 10)}…
{row.current ? (
{t("auth.sessionCurrent")}
) : null}
|
- {row.client_ip || "—"} |
+
+ {row.client_ip || "—"}
+ |
+ {summarizeUa(row.user_agent)} |
{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"} |
{row.created_at ? formatSystemTime(row.created_at) : "—"} |
- {
+ {
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
revokeMut.mutate(row.id);
}}
>
{t("auth.revokeSession")}
-
+
|
))}
diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx
index 54e5400..aca84fe 100644
--- a/web/src/pages/TopologyPage.tsx
+++ b/web/src/pages/TopologyPage.tsx
@@ -1,3 +1,4 @@
+import { Button } from "@heroui/react";
import {
DISCOVER_AUTO_ADD_KEY,
DISCOVER_PROJECT_NEIGHBORS_KEY,
@@ -189,7 +190,6 @@ export function TopologyPage() {
const searchHitTimerRef = useRef(null);
const findBoxRef = useRef(null);
const displayMenuRef = useRef(null);
- const exportMenuRef = useRef(null);
const [exporting, setExporting] = useState(false);
const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState(null);
const findJustLocatedRef = useRef(false);
@@ -1780,7 +1780,6 @@ export function TopologyPage() {
return;
}
setExporting(true);
- exportMenuRef.current?.removeAttribute("open");
const filenameBase = activeLeafName || view.name || "topology";
try {
if (format === "svg") {
@@ -2027,7 +2026,6 @@ export function TopologyPage() {
savePending={saveMut.isPending}
onSave={() => saveMut.mutate()}
onFit={fitCanvas}
- exportMenuRef={exportMenuRef}
exporting={exporting}
onExport={runTopologyExport}
mapId={mapId}
@@ -2304,34 +2302,34 @@ export function TopologyPage() {
{t("topology.treeLoadFailed")}
- void treeQuery.refetch()}
+ void treeQuery.refetch()}
>
{t("topology.treeRetry")}
-
-
+
{t("topology.newRegion")}
-
+
) : (
{t("topology.emptyMaps")}
-
{t("topology.newRegion")}
-
+
)}
{treeRoot && canvasGraphError ? (
@@ -2341,13 +2339,13 @@ export function TopologyPage() {
{canvasGraphErrorMsg}
) : null}
- void graphQuery.refetch()}
+ void graphQuery.refetch()}
>
{t("topology.treeRetry")}
-
+
) : null}
@@ -2374,16 +2372,16 @@ export function TopologyPage() {
{worldNeedsApply ? (
- applyWorldMut.mutate()}
+ applyWorldMut.mutate()}
>
{applyWorldMut.isPending
? t("topology.worldApplying")
: t("topology.worldApply")}
-
+
) : null}
diff --git a/web/src/pages/UmePage.tsx b/web/src/pages/UmePage.tsx
index 267f4d4..6c80fb0 100644
--- a/web/src/pages/UmePage.tsx
+++ b/web/src/pages/UmePage.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Button, Modal, Input } from "@heroui/react";
import {
apiPost,
applyUmeTopologyToFabric,
@@ -24,6 +25,7 @@ import {
} from "../services/api";
import { HelpHint } from "../components/HelpHint";
import { UmeCliConnectPanel } from "../components/UmeCliConnectPanel";
+import { AppModalShell } from "../components/ui/AppModalShell";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
@@ -41,10 +43,10 @@ export function UmePage() {
const [runtimeTaskError, setRuntimeTaskError] = useState("");
const [subscriptionOpError, setSubscriptionOpError] = useState("");
const [syncPage, setSyncPage] = useState(1);
- const [syncPageSize, setSyncPageSize] = useState(20);
+ const [syncPageSize, setSyncPageSize] = useState(10);
const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1);
- const [nePageSize, setNePageSize] = useState(50);
+ const [nePageSize, setNePageSize] = useState(10);
const [expandedNeId, setExpandedNeId] = useState("");
const [nePanelOpen, setNePanelOpen] = useState(false);
const [syncStatusPanelOpen, setSyncStatusPanelOpen] = useState(false);
@@ -58,7 +60,7 @@ export function UmePage() {
Array<{ notification_id: string; native_probable_cause_sample: string }>
>([]);
const [keyAlertPage, setKeyAlertPage] = useState(1);
- const [keyAlertPageSize, setKeyAlertPageSize] = useState(20);
+ const [keyAlertPageSize, setKeyAlertPageSize] = useState(10);
const [keyAlertFilterKeyword, setKeyAlertFilterKeyword] = useState("");
const [keyAlertFilterEnabled, setKeyAlertFilterEnabled] = useState<"" | "true" | "false">("");
const [keyAlertFilterMatchType, setKeyAlertFilterMatchType] = useState<"" | "notification_id" | "keyword">("");
@@ -494,9 +496,11 @@ export function UmePage() {
) : null}
- queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus })}
- disabled={tokenStatusQuery.isFetching}
+ queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus })}
>
{tokenStatusQuery.isFetching ? (
<>
@@ -506,11 +510,12 @@ export function UmePage() {
) : (
t("ume.token.refreshStatus")
)}
-
- tokenRefreshMutation.mutate()}
- disabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
+
+ tokenRefreshMutation.mutate()}
>
{tokenRefreshMutation.isPending ? (
<>
@@ -520,11 +525,12 @@ export function UmePage() {
) : (
t("ume.token.renewLogin")
)}
-
- tokenDisconnectMutation.mutate()}
- disabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
+
+ tokenDisconnectMutation.mutate()}
>
{tokenDisconnectMutation.isPending ? (
<>
@@ -534,7 +540,7 @@ export function UmePage() {
) : (
t("ume.token.disconnect")
)}
-
+
{(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && (
@@ -550,13 +556,14 @@ export function UmePage() {
0 ? "unknown" : "up"}`}>
{t("ume.tasks.running")}: {runningTasks.length}
- queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
- disabled={syncStatusQuery.isFetching}
+ queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
>
{t("common.refresh")}
-
+
{needsFabricApply ? (
@@ -572,16 +579,16 @@ export function UmePage() {
)}
- applyFabricMut.mutate()}
+ applyFabricMut.mutate()}
>
{applyFabricMut.isPending
? t("ume.syncStatus.applyingFabric")
: t("ume.syncStatus.applyFabric")}
-
+
) : null}
@@ -648,23 +655,25 @@ export function UmePage() {
{runtimeLastError(x.last_error, t) || t("common.empty")} |
{Boolean(x.paused) ? (
- runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
+ isDisabled={runtimeTaskMutation.isPending}
+ onPress={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
>
resume
-
+
) : (
- runtimeTaskMutation.mutate({ task: x.task, action: "pause" })}
+ isDisabled={runtimeTaskMutation.isPending}
+ onPress={() => runtimeTaskMutation.mutate({ task: x.task, action: "pause" })}
>
pause
-
+
)}
|
@@ -721,9 +730,11 @@ export function UmePage() {
) : null}
- {
+ {
if (serverSubLost && subscriptionActive) {
if (window.confirm(t("ume.subscription.confirmReestablish"))) {
subscriptionEstablishMutation.mutate({ forceReestablish: true });
@@ -732,14 +743,6 @@ export function UmePage() {
}
subscriptionEstablishMutation.mutate(undefined);
}}
- disabled={subPending || (!serverSubLost && subscriptionActive) || !hasToken}
- title={
- !hasToken
- ? t("ume.subscription.titleLoginFirst")
- : serverSubLost
- ? t("ume.subscription.titleReestablish")
- : undefined
- }
>
{subscriptionEstablishMutation.isPending ? (
<>
@@ -751,14 +754,15 @@ export function UmePage() {
) : (
t("ume.subscription.establish")
)}
-
+
{serverSubLost && subscriptionActive ? (
- {
+ {
if (confirmClearLocalSubscription()) subscriptionClearLocalMutation.mutate();
}}
- disabled={subPending}
>
{subscriptionClearLocalMutation.isPending ? (
<>
@@ -768,12 +772,13 @@ export function UmePage() {
) : (
t("ume.subscription.clearLocalOnly")
)}
-
+
) : null}
- subscriptionCancelMutation.mutate(undefined)}
- disabled={subPending || !subscriptionActive}
+ subscriptionCancelMutation.mutate(undefined)}
>
{subscriptionCancelMutation.isPending ? (
<>
@@ -783,14 +788,15 @@ export function UmePage() {
) : (
t("ume.subscription.cancel")
)}
-
- queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription })}
- disabled={subscriptionStatusQuery.isFetching}
+
+ queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription })}
>
{t("ume.subscription.refreshStatus")}
-
+
{subscriptionOpError ? (
@@ -839,9 +845,9 @@ export function UmePage() {
{t("ume.cli.title")}
- setCliPanelOpen(true)}>
+ setCliPanelOpen(true)}>
{t("ume.cli.showPanel")}
-
+
{t("ume.cli.hint")}
@@ -852,9 +858,9 @@ export function UmePage() {
{t("ume.keyAlert.title")}
- setKeyAlertPanelOpen(true)}>
+ setKeyAlertPanelOpen(true)}>
{t("ume.keyAlert.showPanel")}
-
+
@@ -888,9 +894,9 @@ export function UmePage() {
{t("ume.syncStatus.title")}
- setSyncStatusPanelOpen(true)}>
+ setSyncStatusPanelOpen(true)}>
{t("ume.syncStatus.showPanel")}
-
+
{syncTotal
@@ -905,9 +911,9 @@ export function UmePage() {
{t("ume.ne.title")}
- setNePanelOpen(true)}>
+ setNePanelOpen(true)}>
{t("ume.ne.showPanel")}
-
+
{t("ume.ne.summary", { total: String(neTotal) })}
@@ -934,80 +940,60 @@ export function UmePage() {
- {cliPanelOpen ? (
- setCliPanelOpen(false)}>
-
e.stopPropagation()}
- >
-
-
-
{t("ume.cli.title")}
-
{t("ume.cli.hint")}
-
-
- setCliPanelOpen(false)}>
- {t("networkConfigs.close")}
-
-
-
-
-
-
-
-
- ) : null}
+ setCliPanelOpen(false)}
+ size="lg"
+ className="app-heroui-modal--xl"
+ >
+
+ {t("ume.cli.title")}
+
+
+
+ {t("ume.cli.hint")}
+
+
+
- {keyAlertPanelOpen ? (
- setKeyAlertPanelOpen(false)}>
-
e.stopPropagation()}
+
setKeyAlertPanelOpen(false)}
+ size="lg"
+ className="app-heroui-modal--xl"
+ >
+
+ {t("ume.keyAlert.title")}
+ queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll })}
>
-
-
-
{t("ume.keyAlert.title")}
-
- {t("ume.keyAlert.hub")}:{" "}
- {keyAlertHubSubscribers > 0
- ? t("ume.keyAlert.hubConnected")
- : t("ume.keyAlert.hubEmpty")}
- {` · ${t("ume.keyAlert.hubSubscribers")} ${keyAlertHubSubscribers}`}
- {` · ${t("ume.keyAlert.hubPublished")} ${Number(keyAlertHub?.published || 0)}`}
- {` · ${t("ume.keyAlert.hubDeliverOk")} ${Number(keyAlertHub?.deliver_ok || 0)}`}
- {keyAlertHub?.path ? ` · ${t("ume.keyAlert.hubPath")} ${keyAlertHub.path}` : ""}
- {showLegacyOclaw
- ? ` · ${t("ume.keyAlert.legacyOclaw")}: ${
- keyAlertForwarder?.paused
- ? t("ume.keyAlert.wsPaused")
- : keyAlertForwarder?.connected
- ? t("ume.keyAlert.wsConnected")
- : t("ume.keyAlert.wsDisconnected")
- }`
- : ""}
-
-
-
- queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll })}
- disabled={keyAlertMonitorQuery.isFetching}
- >
- {keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
-
- setKeyAlertPanelOpen(false)}>
- {t("networkConfigs.close")}
-
-
-
-
-
+ {keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
+
+
+
+
+
+ {t("ume.keyAlert.hub")}:{" "}
+ {keyAlertHubSubscribers > 0
+ ? t("ume.keyAlert.hubConnected")
+ : t("ume.keyAlert.hubEmpty")}
+ {` · ${t("ume.keyAlert.hubSubscribers")} ${keyAlertHubSubscribers}`}
+ {` · ${t("ume.keyAlert.hubPublished")} ${Number(keyAlertHub?.published || 0)}`}
+ {` · ${t("ume.keyAlert.hubDeliverOk")} ${Number(keyAlertHub?.deliver_ok || 0)}`}
+ {keyAlertHub?.path ? ` · ${t("ume.keyAlert.hubPath")} ${keyAlertHub.path}` : ""}
+ {showLegacyOclaw
+ ? ` · ${t("ume.keyAlert.legacyOclaw")}: ${
+ keyAlertForwarder?.paused
+ ? t("ume.keyAlert.wsPaused")
+ : keyAlertForwarder?.connected
+ ? t("ume.keyAlert.wsConnected")
+ : t("ume.keyAlert.wsDisconnected")
+ }`
+ : ""}
+
{t("ume.keyAlert.hubConnections")}
{keyAlertHubConnections.length === 0 ? (
@@ -1105,9 +1091,10 @@ export function UmePage() {
))}
-
{
+ {
try {
if (keyAlertMatchType === "keyword") {
const resp = await fetchUmeAlarmKeywords(100);
@@ -1134,16 +1121,17 @@ export function UmePage() {
}}
>
{t("ume.keyAlert.pickFromAlarms")}
-
- keyAlertAddMutation.mutate()}
- disabled={
+
+ keyAlertAddMutation.mutate()}
>
{keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")}
-
+
{t("ume.keyAlert.neTypesLabel")}
@@ -1284,29 +1272,29 @@ export function UmePage() {
- {
+ {
setKeyAlertEditRule(rule);
setKeyAlertEditNeTypes(rule.ne_types || []);
}}
- disabled={keyAlertEditMutation.isPending}
>
{t("ume.keyAlert.edit")}
-
- {
+
+ {
if (window.confirm(t("ume.keyAlert.confirmDelete"))) {
keyAlertDeleteMutation.mutate(rule.notification_id);
}
}}
- disabled={keyAlertDeleteMutation.isPending}
>
{t("ume.keyAlert.delete")}
-
+
|
@@ -1331,25 +1319,27 @@ export function UmePage() {
})}
- setKeyAlertPage(Math.max(1, keyAlertPage - 1))}
+ setKeyAlertPage(Math.max(1, keyAlertPage - 1))}
>
{t("common.prevPage")}
-
- = keyAlertPages}
- onClick={() => setKeyAlertPage(keyAlertPage + 1)}
+
+ = keyAlertPages}
+ onPress={() => setKeyAlertPage(keyAlertPage + 1)}
>
{t("common.nextPage")}
-
+
-
-
-
- ) : null}
+
+
- {syncStatusPanelOpen ? (
- setSyncStatusPanelOpen(false)}
- >
-
e.stopPropagation()}
+
setSyncStatusPanelOpen(false)}
+ size="lg"
+ className="app-heroui-modal--xl"
+ >
+
+ {t("ume.syncStatus.title")}
+ queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
>
-
-
-
{t("ume.syncStatus.title")}
-
- {t("ume.syncStatus.summary", {
- total: String(syncTotal),
- running: String(runningTasks.length),
- })}
-
-
-
- queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
- disabled={syncStatusQuery.isFetching}
- >
- {syncStatusQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
-
- setSyncStatusPanelOpen(false)}>
- {t("networkConfigs.close")}
-
-
-
-
-
-
+ {syncStatusQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
+
+
+
+
+
+ {t("ume.syncStatus.summary", {
+ total: String(syncTotal),
+ running: String(runningTasks.length),
+ })}
+
+
@@ -1415,7 +1390,7 @@ export function UmePage() {
| deleted |
started_at |
ended_at |
- error |
+ error |
@@ -1432,7 +1407,9 @@ export function UmePage() {
{Number(x.deleted ?? 0)} |
{formatSystemTime(x.started_at)} |
{x.ended_at ? formatSystemTime(x.ended_at) : t("common.empty")} |
- {x.error_message || t("common.empty")} |
+
+ {x.error_message || t("common.empty")}
+ |
))}
{!syncStatusQuery.isLoading && (syncStatusQuery.data?.items || []).length === 0 ? (
@@ -1445,7 +1422,6 @@ export function UmePage() {
-
@@ -1456,28 +1432,31 @@ export function UmePage() {
})}
- setSyncPage(Math.max(1, syncPage - 1))}
+ setSyncPage(Math.max(1, syncPage - 1))}
>
{t("common.prevPage")}
-
- = syncPages}
- onClick={() => setSyncPage(syncPage + 1)}
+
+ = syncPages}
+ onPress={() => setSyncPage(syncPage + 1)}
>
{t("common.nextPage")}
-
+
-
-
- ) : null}
+
+
- {nePanelOpen ? (
-
{
- setNePanelOpen(false);
- setExpandedNeId("");
- }}
- >
-
e.stopPropagation()}
- >
-
-
-
{t("ume.ne.title")}
-
{t("ume.ne.summary", { total: String(neTotal) })}
-
-
- {
- setNePanelOpen(false);
- setExpandedNeId("");
- }}
- >
- {t("networkConfigs.close")}
-
-
-
+
{
+ setNePanelOpen(false);
+ setExpandedNeId("");
+ }}
+ size="lg"
+ className="app-heroui-modal--xl"
+ >
+
+ {t("ume.ne.title")}
+
+
+
+ {t("ume.ne.summary", { total: String(neTotal) })}
- {
@@ -1532,23 +1492,26 @@ export function UmePage() {
setNePage(1);
}}
/>
- queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll })}>
+ queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll })}
+ >
{t("common.query")}
-
- {
+
+ {
setNeKeyword("");
setNePage(1);
}}
- disabled={!neKeyword.trim()}
>
{t("common.clearFilters")}
-
+
-
@@ -1580,13 +1543,9 @@ export function UmePage() {
| {x.hardware_version || t("common.empty")} |
{x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")} |
- setExpandedNeId(x.ne_id)}
- >
+ setExpandedNeId(x.ne_id)}>
{t("ume.ne.expand")}
-
+
|
))}
@@ -1600,7 +1559,6 @@ export function UmePage() {
-
@@ -1611,28 +1569,31 @@ export function UmePage() {
})}
- setNePage(Math.max(1, nePage - 1))}
+ setNePage(Math.max(1, nePage - 1))}
>
{t("common.prevPage")}
-
- = nePages}
- onClick={() => setNePage(nePage + 1)}
+
+ = nePages}
+ onPress={() => setNePage(nePage + 1)}
>
{t("common.nextPage")}
-
+
-
-
- ) : null}
+
+
- {expandedNe ? (
-
setExpandedNeId("")}>
-
e.stopPropagation()}
- >
-
-
-
{expandedNe.user_label || expandedNe.ne_id}
-
- {expandedNe.ne_id}
- {expandedNe.ip_address ? ` · ${expandedNe.ip_address}` : ""}
- {expandedNe.ne_type ? ` · ${expandedNe.ne_type}` : ""}
-
-
-
- setExpandedNeId("")}>
- {t("networkConfigs.close")}
-
-
-
-
+
setExpandedNeId("")}
+ size="lg"
+ >
+
+ {expandedNe?.user_label || expandedNe?.ne_id || t("ume.ne.expand")}
+
+
+
+ {expandedNe ? (
+ <>
+
+ {expandedNe.ne_id}
+ {expandedNe.ip_address ? ` · ${expandedNe.ip_address}` : ""}
+ {expandedNe.ne_type ? ` · ${expandedNe.ne_type}` : ""}
+
consistent_state
@@ -1740,87 +1692,86 @@ export function UmePage() {
-
-
-
- ) : null}
+ >
+ ) : null}
+
+
- {keyAlertEditRule ? (
- setKeyAlertEditRule(null)}>
-
e.stopPropagation()}
+
{
+ if (!keyAlertEditMutation.isPending) setKeyAlertEditRule(null);
+ }}
+ dismissible={!keyAlertEditMutation.isPending}
+ size="lg"
+ >
+
+ {t("ume.keyAlert.editTitle")}
+
+
+
+ {keyAlertEditRule ? (
+ <>
+
+ {keyAlertEditRule.match_type === "keyword"
+ ? t("ume.keyAlert.matchKeyword")
+ : t("ume.keyAlert.matchNotificationId")}
+ : {keyAlertEditRule.match_value || keyAlertEditRule.notification_id}
+ {keyAlertEditRule.label ? ` · ${keyAlertEditRule.label}` : ""}
+
+ {t("ume.keyAlert.neTypesLabel")}
+ {keyAlertNeTypesQuery.isLoading ? (
+ {t("common.refreshing")}
+ ) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? (
+ {t("ume.keyAlert.neTypesEmpty")}
+ ) : (
+
+ {(keyAlertNeTypesQuery.data?.items || []).map((item) => (
+
+ {
+ if (e.target.checked) {
+ setKeyAlertEditNeTypes((prev) => [...prev, item.ne_type]);
+ } else {
+ setKeyAlertEditNeTypes((prev) => prev.filter((x) => x !== item.ne_type));
+ }
+ }}
+ />
+ {item.ne_type}
+ ({item.ne_count})
+
+ ))}
+
+ )}
+ {t("ume.keyAlert.neTypesHint")}
+ >
+ ) : null}
+
+
+ setKeyAlertEditRule(null)}
>
-
-
-
{t("ume.keyAlert.editTitle")}
-
- {keyAlertEditRule.match_type === "keyword"
- ? t("ume.keyAlert.matchKeyword")
- : t("ume.keyAlert.matchNotificationId")}
- : {keyAlertEditRule.match_value || keyAlertEditRule.notification_id}
- {keyAlertEditRule.label ? ` · ${keyAlertEditRule.label}` : ""}
-
-
-
- setKeyAlertEditRule(null)} disabled={keyAlertEditMutation.isPending}>
- {t("ume.keyAlert.cancel")}
-
-
-
- {t("ume.keyAlert.neTypesLabel")}
- {keyAlertNeTypesQuery.isLoading ? (
- {t("common.refreshing")}
- ) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? (
- {t("ume.keyAlert.neTypesEmpty")}
- ) : (
-
- {(keyAlertNeTypesQuery.data?.items || []).map((item) => (
-
- {
- if (e.target.checked) {
- setKeyAlertEditNeTypes((prev) => [...prev, item.ne_type]);
- } else {
- setKeyAlertEditNeTypes((prev) => prev.filter((x) => x !== item.ne_type));
- }
- }}
- />
- {item.ne_type}
- ({item.ne_count})
-
- ))}
-
- )}
- {t("ume.keyAlert.neTypesHint")}
-
-
-
-
- keyAlertEditMutation.mutate({
- ruleKey: keyAlertEditRule.notification_id,
- ne_types: keyAlertEditNeTypes,
- })
- }
- disabled={keyAlertEditMutation.isPending}
- >
- {keyAlertEditMutation.isPending ? t("ume.keyAlert.saving") : t("ume.keyAlert.save")}
-
- setKeyAlertEditRule(null)} disabled={keyAlertEditMutation.isPending}>
- {t("ume.keyAlert.cancel")}
-
-
-
-
-
- ) : null}
+ {t("ume.keyAlert.cancel")}
+
+ {
+ if (!keyAlertEditRule) return;
+ keyAlertEditMutation.mutate({
+ ruleKey: keyAlertEditRule.notification_id,
+ ne_types: keyAlertEditNeTypes,
+ });
+ }}
+ >
+ {keyAlertEditMutation.isPending ? t("ume.keyAlert.saving") : t("ume.keyAlert.save")}
+
+
+
>
);
}
diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx
index aafc63e..6555eb8 100644
--- a/web/src/pages/UsersPage.tsx
+++ b/web/src/pages/UsersPage.tsx
@@ -1,7 +1,9 @@
+import { Button, Input } from "@heroui/react";
import { useMemo, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
+import { FieldSelect } from "../components/ui/FieldSelect";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { apiGet, apiPatch, apiPost } from "../services/api";
@@ -71,13 +73,13 @@ export function UsersPage() {
{usersQuery.isLoading ?
{t("common.refreshing")}
: null}
@@ -127,15 +129,16 @@ export function UsersPage() {
-
+
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
}
>
{u.is_active ? t("auth.disable") : t("auth.enable")}
-
-
-
+
- {
+ {
const pwd = resetPwd[u.id];
patchMut.mutate({ id: u.id, body: { password: pwd } });
setResetPwd((m) => ({ ...m, [u.id]: "" }));
}}
>
{t("auth.resetPassword")}
-
+
|
diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx
index 16f414e..a7be5b7 100644
--- a/web/src/pages/WebcrtPage.tsx
+++ b/web/src/pages/WebcrtPage.tsx
@@ -12,7 +12,9 @@ import {
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
+import { Button, Input, Modal } from "@heroui/react";
import type { WebTerminalHandle } from "../components/WebTerminal";
+import { AppModalShell } from "../components/ui/AppModalShell";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import {
@@ -50,7 +52,7 @@ import {
const WebTerminal = lazy(() =>
import("../components/WebTerminal").then((m) => ({ default: m.WebTerminal })),
);
-const PAGE_SIZE = 50;
+const PAGE_SIZE = 10;
const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions";
const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs";
const SFTP_WIDTH_KEY = "netx.webcrt.sftpWidth";
@@ -2222,12 +2224,26 @@ export function WebcrtPage() {
{t("common.pagerMeta", { total, page, pages })}
- setPage((p) => Math.max(1, p - 1))} aria-label="prev">
+ setPage((p) => Math.max(1, p - 1))}
+ >
‹
-
- = pages} onClick={() => setPage((p) => p + 1)} aria-label="next">
+
+ = pages}
+ aria-label="next"
+ onPress={() => setPage((p) => p + 1)}
+ >
›
-
+
@@ -2546,15 +2562,16 @@ export function WebcrtPage() {
onDrop={onSftpDrop}
>
- navigateSftp(sftpParentPath(sftpPathRef.current))}
+ navigateSftp(sftpParentPath(sftpPathRef.current))}
>
{t("webcrt.sftp.up")}
-
-
+ {
@@ -2569,40 +2586,44 @@ export function WebcrtPage() {
}
}}
/>
- void refreshSftp()}>
+ void refreshSftp()}>
{t("webcrt.sftp.refresh")}
-
- void mkdirSftp()}>
+
+ void mkdirSftp()}>
{t("webcrt.sftp.mkdir")}
-
- void renameSftpSelected()}
+
+ void renameSftpSelected()}
>
{t("webcrt.sftp.rename")}
-
- void downloadSftpItems(sftpSelectedFiles)}
+
+ void downloadSftpItems(sftpSelectedFiles)}
>
{t("webcrt.sftp.download")}
-
- void chmodSftpSelected()}
+
+ void chmodSftpSelected()}
>
{t("webcrt.sftp.chmod")}
-
- void removeSftpSelected()}
+
+ void removeSftpSelected()}
>
{t("webcrt.sftp.delete")}
-
+
{t("webcrt.sftp.upload")}
{sftpStatus || t("webcrt.sftp.loading")}
{sftpTransferring ? (
-
{t("webcrt.sftp.cancel")}
-
+
) : null}
) : null}
@@ -2792,50 +2814,55 @@ export function WebcrtPage() {
) : null}
- {renameDialog ? (
- setRenameDialog(null)}
- >
-
e.stopPropagation()}
+
setRenameDialog(null)}
+ size="md"
+ className="webcrt-auth-modal"
+ >
+
+ {t("webcrt.treeMenu.rename")}
+
+
+
+ {renameDialog ? (
+ <>
+
+ {renameDialog.target.ip_address}
+ {renameDialog.target.protocol
+ ? ` · ${String(renameDialog.target.protocol).toUpperCase()}`
+ : ""}
+
+
+
+ {t("webcrt.treeMenu.renameLabel")}
+ setRenameDialog({ ...renameDialog, name: e.target.value })}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") void renameWebcrtSession(renameDialog.target, renameDialog.name);
+ }}
+ />
+
+
+ >
+ ) : null}
+
+
+ setRenameDialog(null)}>
+ {t("webcrt.sessionOptionsCancel")}
+
+ {
+ if (renameDialog) void renameWebcrtSession(renameDialog.target, renameDialog.name);
+ }}
>
- {t("webcrt.treeMenu.rename")}
-
- {renameDialog.target.ip_address}
- {renameDialog.target.protocol ? ` · ${String(renameDialog.target.protocol).toUpperCase()}` : ""}
-
-
-
- {t("webcrt.treeMenu.renameLabel")}
- setRenameDialog({ ...renameDialog, name: e.target.value })}
- onKeyDown={(e) => {
- if (e.key === "Enter") void renameWebcrtSession(renameDialog.target, renameDialog.name);
- }}
- />
-
-
-
- setRenameDialog(null)}>
- {t("webcrt.sessionOptionsCancel")}
-
- void renameWebcrtSession(renameDialog.target, renameDialog.name)}
- >
- {t("webcrt.treeMenu.renameSave")}
-
-
-
-
- ) : null}
+ {t("webcrt.treeMenu.renameSave")}
+
+
+
{tabMenu ? (
) : null}
- {keywordHlModalOpen ? (
-
setKeywordHlModalOpen(false)}
- >
-
e.stopPropagation()}
- >
-
{t("webcrt.keywordHl.title")}
-
- {t("webcrt.keywordHl.enabled")}
-
- setKeywordHlDraft((prev) => ({ ...prev, enabled: e.target.checked }))
- }
- />
-
-
-
- {t("webcrt.keywordHl.newKeyword")}
- setKeywordDraftText(e.target.value)}
- onKeyDown={(e) => {
- if (e.key !== "Enter") return;
- e.preventDefault();
- const pattern = keywordDraftText.trim();
- if (!pattern) return;
- const row: KeywordRule = {
- id: newKeywordId(),
- pattern,
- regex: keywordDraftRegex,
- };
- setKeywordHlDraft((prev) => ({
- ...prev,
- enabled: true,
- keywords: [...prev.keywords, row],
- }));
- setKeywordSelectedId(row.id);
- setKeywordDraftText("");
- }}
- />
-
-
- setKeywordDraftRegex(e.target.checked)}
- />
- {t("webcrt.keywordHl.regex")}
-
-
{
+ setKeywordHlModalOpen(false)}
+ size="md"
+ className="webcrt-keyword-modal"
+ >
+
+ {t("webcrt.keywordHl.title")}
+
+
+
+
+ {t("webcrt.keywordHl.enabled")}
+
+ setKeywordHlDraft((prev) => ({ ...prev, enabled: e.target.checked }))
+ }
+ />
+
+
+
+ {t("webcrt.keywordHl.newKeyword")}
+ setKeywordDraftText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key !== "Enter") return;
+ e.preventDefault();
const pattern = keywordDraftText.trim();
if (!pattern) return;
const row: KeywordRule = {
@@ -2969,485 +2964,515 @@ export function WebcrtPage() {
setKeywordSelectedId(row.id);
setKeywordDraftText("");
}}
+ />
+
+
+ setKeywordDraftRegex(e.target.checked)}
+ />
+ {t("webcrt.keywordHl.regex")}
+
+ {
+ const pattern = keywordDraftText.trim();
+ if (!pattern) return;
+ const row: KeywordRule = {
+ id: newKeywordId(),
+ pattern,
+ regex: keywordDraftRegex,
+ };
+ setKeywordHlDraft((prev) => ({
+ ...prev,
+ enabled: true,
+ keywords: [...prev.keywords, row],
+ }));
+ setKeywordSelectedId(row.id);
+ setKeywordDraftText("");
+ }}
+ >
+ {t("webcrt.keywordHl.add")}
+
+
+
+
+ {t("webcrt.keywordHl.colKeyword")}
+ {t("webcrt.keywordHl.colRegex")}
+
+
+ {keywordHlDraft.keywords.length === 0 ? (
+ - {t("webcrt.keywordHl.empty")}
+ ) : (
+ keywordHlDraft.keywords.map((kw) => (
+ -
+ setKeywordSelectedId(kw.id)}
+ >
+
+ {kw.pattern}
+
+ {kw.regex ? "✓" : ""}
+
+
+ ))
+ )}
+
+
+ {
+ setKeywordHlDraft((prev) => {
+ const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId);
+ if (idx <= 0) return prev;
+ const next = [...prev.keywords];
+ [next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
+ return { ...prev, keywords: next };
+ });
+ }}
>
- {t("webcrt.keywordHl.add")}
-
+ ↑
+
+ {
+ setKeywordHlDraft((prev) => {
+ const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId);
+ if (idx < 0 || idx >= prev.keywords.length - 1) return prev;
+ const next = [...prev.keywords];
+ [next[idx + 1], next[idx]] = [next[idx], next[idx + 1]];
+ return { ...prev, keywords: next };
+ });
+ }}
+ >
+ ↓
+
+ {
+ setKeywordHlDraft((prev) => {
+ const next = prev.keywords.filter((k) => k.id !== keywordSelectedId);
+ setKeywordSelectedId(next[0]?.id || "");
+ return { ...prev, keywords: next };
+ });
+ }}
+ >
+ ×
+
-
-
- {t("webcrt.keywordHl.colKeyword")}
- {t("webcrt.keywordHl.colRegex")}
-
-
- {keywordHlDraft.keywords.length === 0 ? (
- - {t("webcrt.keywordHl.empty")}
- ) : (
- keywordHlDraft.keywords.map((kw) => (
- -
- setKeywordSelectedId(kw.id)}
- >
-
- {kw.pattern}
-
- {kw.regex ? "✓" : ""}
-
-
- ))
- )}
-
-
- {
- setKeywordHlDraft((prev) => {
- const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId);
- if (idx <= 0) return prev;
- const next = [...prev.keywords];
- [next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
- return { ...prev, keywords: next };
- });
- }}
- >
- ↑
-
- {
- setKeywordHlDraft((prev) => {
- const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId);
- if (idx < 0 || idx >= prev.keywords.length - 1) return prev;
- const next = [...prev.keywords];
- [next[idx + 1], next[idx]] = [next[idx], next[idx + 1]];
- return { ...prev, keywords: next };
- });
- }}
- >
- ↓
-
- {
- setKeywordHlDraft((prev) => {
- const next = prev.keywords.filter((k) => k.id !== keywordSelectedId);
- setKeywordSelectedId(next[0]?.id || "");
- return { ...prev, keywords: next };
- });
- }}
- >
- ×
-
-
-
-
-
- {t("webcrt.keywordHl.caseSensitive")}
-
- setKeywordHlDraft((prev) => ({ ...prev, caseSensitive: e.target.checked }))
+
+
+
+ {t("webcrt.keywordHl.caseSensitive")}
+
+ setKeywordHlDraft((prev) => ({ ...prev, caseSensitive: e.target.checked }))
+ }
+ />
+
+
+ {t("webcrt.keywordHl.color")}
+
+ setKeywordHlDraft((prev) => ({ ...prev, color: e.target.value }))
+ }
+ />
+
+
+
+
+ setKeywordHlModalOpen(false)}>
+ {t("webcrt.sessionOptionsCancel")}
+
+ {
+ const keywords = keywordHlDraft.keywords
+ .map((k) => ({
+ id: k.id || newKeywordId(),
+ pattern: String(k.pattern || "").trim(),
+ regex: Boolean(k.regex),
+ }))
+ .filter((k) => k.pattern);
+ const next: KeywordHighlightConfig = {
+ ...defaultKeywordHighlightConfig(),
+ enabled: keywords.length > 0,
+ caseSensitive: Boolean(keywordHlDraft.caseSensitive),
+ color: keywordHlDraft.color || "#ffff00",
+ keywords,
+ };
+ saveKeywordHighlightConfig(next);
+ setKeywordHl(next);
+ setKeywordHlDraft(next);
+ setKeywordHlModalOpen(false);
+ showOk(t("webcrt.keywordHl.saved"));
+ }}
+ >
+ {t("webcrt.sessionOptionsSave")}
+
+
+
+
+
setSessionOptsModalOpen(false)}
+ size="md"
+ className="webcrt-session-opts-modal"
+ >
+
+ {t("webcrt.globalSessionOptions")}
+
+
+
+
+
+ {t("webcrt.encoding")}
+
+
+
+ {t("webcrt.fontSize")}
+
+
+
+ {t("webcrt.colorScheme")}
+
-
- {t("webcrt.keywordHl.color")}
+ setSessionOptsDraft((prev) => ({ ...prev, colorScheme: "custom" }));
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {t("webcrt.backgroundColor")}
- setKeywordHlDraft((prev) => ({ ...prev, color: e.target.value }))
- }
- />
-
-
-
- setKeywordHlModalOpen(false)}>
- {t("webcrt.sessionOptionsCancel")}
-
- {
- const keywords = keywordHlDraft.keywords
- .map((k) => ({
- id: k.id || newKeywordId(),
- pattern: String(k.pattern || "").trim(),
- regex: Boolean(k.regex),
- }))
- .filter((k) => k.pattern);
- const next: KeywordHighlightConfig = {
- ...defaultKeywordHighlightConfig(),
- enabled: keywords.length > 0,
- caseSensitive: Boolean(keywordHlDraft.caseSensitive),
- color: keywordHlDraft.color || "#ffff00",
- keywords,
- };
- saveKeywordHighlightConfig(next);
- setKeywordHl(next);
- setKeywordHlDraft(next);
- setKeywordHlModalOpen(false);
- showOk(t("webcrt.keywordHl.saved"));
- }}
- >
- {t("webcrt.sessionOptionsSave")}
-
-
-
-
- ) : null}
-
- {sessionOptsModalOpen ? (
- setSessionOptsModalOpen(false)}
- >
-
e.stopPropagation()}
- >
-
{t("webcrt.globalSessionOptions")}
-
-
- {t("webcrt.encoding")}
-
-
-
- {t("webcrt.fontSize")}
-
+ />
-
- {t("webcrt.colorScheme")}
-
+
+ {t("webcrt.foregroundColor")}
+
+ setSessionOptsDraft((prev) => ({
+ ...prev,
+ colorScheme: "custom",
+ foreground: e.target.value,
+ }))
+ }
+ />
-
-
- {t("webcrt.backgroundColor")}
-
- setSessionOptsDraft((prev) => ({
- ...prev,
- colorScheme: "custom",
- background: e.target.value,
- }))
- }
- />
-
-
- {t("webcrt.foregroundColor")}
-
- setSessionOptsDraft((prev) => ({
- ...prev,
- colorScheme: "custom",
- foreground: e.target.value,
- }))
- }
- />
-
-
- Aa 192.168.0.1
-
+
+ Aa 192.168.0.1
-
- {t("webcrt.copyOnSelect")}
-
- setSessionOptsDraft((prev) => ({ ...prev, copyOnSelect: e.target.checked }))
- }
- />
-
-
- {t("webcrt.pasteDelay")}
-
-
-
- {t("webcrt.keepalive")}
-
-
-
- setSessionOptsModalOpen(false)}>
- {t("webcrt.sessionOptionsCancel")}
-
- {
- const next = normalizeSessionOptions(sessionOptsDraft);
- saveSessionOptions(next);
- setSessionOpts(next);
- setSessionOptsModalOpen(false);
- showOk(t("webcrt.sessionOptionsSaved"));
+
+ {t("webcrt.copyOnSelect")}
+
+ setSessionOptsDraft((prev) => ({ ...prev, copyOnSelect: e.target.checked }))
+ }
+ />
+
+
+ {t("webcrt.pasteDelay")}
+
+
+
+ {t("webcrt.keepalive")}
+
+
+
+
+
+ setSessionOptsModalOpen(false)}>
+ {t("webcrt.sessionOptionsCancel")}
+
+ {
+ const next = normalizeSessionOptions(sessionOptsDraft);
+ saveSessionOptions(next);
+ setSessionOpts(next);
+ setSessionOptsModalOpen(false);
+ showOk(t("webcrt.sessionOptionsSaved"));
+ }}
+ >
+ {t("webcrt.sessionOptionsSave")}
+
+
+
+
+ {
+ if (!sessionBusy) setAuthDialog(null);
+ }}
+ dismissible={!sessionBusy}
+ size="md"
+ className="webcrt-auth-modal"
+ >
+
+ {t("webcrt.newSession.authTitle")}
+
+
+
+ {authDialog ? (
+ <>
+
+ {authDialog.host.ip_address}
+ {authDialog.host.port ? `:${authDialog.host.port}` : ""}
+ {" · SSH"}
+
+ {authDialog.errorHint ? (
+ {authDialog.errorHint}
+ ) : null}
+
+
+ {t("managedNe.col.user")}
+ setAuthForm({ ...authForm, username: e.target.value })}
+ />
+
+
+ {t("managedNe.col.password")}
+ setAuthForm({ ...authForm, password: e.target.value })}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") void submitAuthDialog();
+ }}
+ />
+
+
+ setAuthForm({ ...authForm, savePassword: e.target.checked })}
+ />
+ {t("webcrt.newSession.savePassword")}
+
+
+ >
+ ) : null}
+
+
+ setAuthDialog(null)}>
+ {t("webcrt.sessionOptionsCancel")}
+
+ void submitAuthDialog()}>
+ {sessionBusy ? t("webcrt.newSession.connecting") : t("webcrt.newSession.connect")}
+
+
+
+
+ {
+ if (!sessionBusy) {
+ setHostDialogOpen(false);
+ setHostDialogClaimNeId(null);
+ }
+ }}
+ dismissible={!sessionBusy}
+ size="md"
+ className="webcrt-new-session-modal"
+ >
+
+
+ {hostDialogClaimNeId
+ ? t("webcrt.newSession.claimTitle")
+ : t("webcrt.newSession.title")}
+
+
+
+
+ {hostDialogClaimNeId ? (
+ {t("webcrt.newSession.claimHint")}
+ ) : null}
+
+
+ {t("webcrt.newSession.protocol")}
+
+
+
+
+
+
+ {t("webcrt.newSession.host")}
+ setHostForm({ ...hostForm, ip_address: e.target.value })}
+ />
+
+
+ {t("webcrt.newSession.port")}
+
+ setHostForm({
+ ...hostForm,
+ port: Number(e.target.value) || (hostForm.protocol === "telnet" ? 23 : 22),
+ })
+ }
+ />
+
+
+ {t("webcrt.newSession.sessionName")}
+ setHostForm({ ...hostForm, name: e.target.value })}
+ />
+
-
- ) : null}
-
- {authDialog ? (
-
{
- if (!sessionBusy) setAuthDialog(null);
- }}
- >
-
e.stopPropagation()}
- >
-
{t("webcrt.newSession.authTitle")}
-
- {authDialog.host.ip_address}
- {authDialog.host.port ? `:${authDialog.host.port}` : ""}
- {" · SSH"}
-
- {authDialog.errorHint ? (
-
{authDialog.errorHint}
- ) : null}
-
-
- {t("managedNe.col.user")}
- setAuthForm({ ...authForm, username: e.target.value })}
- />
-
-
- {t("managedNe.col.password")}
- setAuthForm({ ...authForm, password: e.target.value })}
- onKeyDown={(e) => {
- if (e.key === "Enter") void submitAuthDialog();
- }}
- />
-
-
- setAuthForm({ ...authForm, savePassword: e.target.checked })}
- />
- {t("webcrt.newSession.savePassword")}
-
-
-
- setAuthDialog(null)}>
- {t("webcrt.sessionOptionsCancel")}
-
- void submitAuthDialog()}>
- {sessionBusy ? t("webcrt.newSession.connecting") : t("webcrt.newSession.connect")}
-
-
-
-
- ) : null}
-
- {hostDialogOpen ? (
-
{
- if (!sessionBusy) {
+
+
+ {
setHostDialogOpen(false);
setHostDialogClaimNeId(null);
- }
- }}
- >
-
-
- ) : null}
+ {t("webcrt.sessionOptionsCancel")}
+
+
submitHostDialog()}>
+ {sessionBusy ? t("webcrt.newSession.connecting") : t("webcrt.newSession.connect")}
+
+
+
);
diff --git a/web/src/pages/WorkbenchPage.tsx b/web/src/pages/WorkbenchPage.tsx
index cbdf380..1ea5e74 100644
--- a/web/src/pages/WorkbenchPage.tsx
+++ b/web/src/pages/WorkbenchPage.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
+import { Button } from "@heroui/react";
import { useI18n } from "../i18n";
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules";
@@ -250,16 +251,16 @@ export function WorkbenchPage() {
{mods.map((mod) => (
- openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
+ aria-label={t("workbench.openModule")}
+ onPress={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
>
{t(mod.labelKey)}
-
+
))}
diff --git a/web/src/pages/audit/TaskOverviewPage.tsx b/web/src/pages/audit/TaskOverviewPage.tsx
index 27a3e55..276801f 100644
--- a/web/src/pages/audit/TaskOverviewPage.tsx
+++ b/web/src/pages/audit/TaskOverviewPage.tsx
@@ -1,3 +1,4 @@
+import { Button } from "@heroui/react";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
@@ -87,9 +88,14 @@ export function TaskOverviewPage() {
{t("audit.tasks.title")}
- void query.refetch()} disabled={query.isFetching}>
+ void query.refetch()}
+ isDisabled={query.isFetching}
+ >
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
-
+
{t("audit.tasks.hint")}
diff --git a/web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx b/web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx
index 550996e..39d24bd 100644
--- a/web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx
+++ b/web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx
@@ -1,5 +1,7 @@
+import { Alert, Button, Chip, Modal } from "@heroui/react";
import { useMutation } from "@tanstack/react-query";
import { connectTestManagedNe } from "../../services/api";
+import { AppModalShell } from "../../components/ui/AppModalShell";
import { useI18n } from "../../i18n";
import { formatSystemTime } from "../../utils/time";
import type { ManagedNeItem } from "../../types";
@@ -12,6 +14,13 @@ export type ManagedNeConnectDetailDialogProps = {
onRetestSubmitted?: (rowId: string) => void;
};
+function chipColor(level: string): "success" | "danger" | "warning" | "default" {
+ if (level === "up" || level === "pass") return "success";
+ if (level === "down" || level === "fail") return "danger";
+ if (level === "testing") return "warning";
+ return "default";
+}
+
export function ManagedNeConnectDetailDialog({
row,
onClose,
@@ -25,47 +34,61 @@ export function ManagedNeConnectDetailDialog({
},
});
- if (!row) return null;
+ const open = Boolean(row);
+ const level = row ? connectPillLevel(row.connect_status) : "unknown";
return (
-
-
e.stopPropagation()}>
-
{t("managedNe.connectDetailTitle")}
-
- {row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
- {row.connect_tested_at
- ? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
- : ""}
-
-
-
- {row.connect_status}
-
- {row.connect_message ? (
- — {row.connect_message}
- ) : null}
-
-
- {row.connect_detail?.trim() || row.connect_message?.trim() || t("managedNe.connectDetailEmpty")}
-
-
- connectMutation.mutate([row.id])}
- >
- {connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
-
-
- {t("managedNe.form.cancel")}
-
-
- {connectMutation.isError ? (
-
- {String(connectMutation.error)}
-
+
+
+ {t("managedNe.connectDetailTitle")}
+
+
+
+ {row ? (
+ <>
+
+ {row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
+ {row.connect_tested_at
+ ? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
+ : ""}
+
+
+
+ {row.connect_status}
+
+ {row.connect_message ? (
+ — {row.connect_message}
+ ) : null}
+
+
+ {row.connect_detail?.trim() ||
+ row.connect_message?.trim() ||
+ t("managedNe.connectDetailEmpty")}
+
+ >
) : null}
-
-
+ {connectMutation.isError ? (
+
+
+ {String(connectMutation.error)}
+
+
+ ) : null}
+
+
+ {
+ if (row) connectMutation.mutate([row.id]);
+ }}
+ >
+ {connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
+
+
+ {t("managedNe.form.cancel")}
+
+
+
);
}
diff --git a/web/src/pages/managedNe/ManagedNeFormDialog.tsx b/web/src/pages/managedNe/ManagedNeFormDialog.tsx
index cb73841..a7d4490 100644
--- a/web/src/pages/managedNe/ManagedNeFormDialog.tsx
+++ b/web/src/pages/managedNe/ManagedNeFormDialog.tsx
@@ -1,7 +1,10 @@
-import { useEffect, useMemo, useState, type ReactNode } from "react";
+import { Alert, Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react";
+import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../services/api";
import { HopProxyFields } from "../../components/HopProxyFields";
+import { AppModalShell } from "../../components/ui/AppModalShell";
+import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { patchHopVendorChange } from "../../utils/hopProxy";
@@ -14,20 +17,6 @@ import {
type ManagedNeFormState,
} from "./formState";
-function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
- return (
-
- {children}
- {required ? (
-
- {" "}
- *
-
- ) : null}
-
- );
-}
-
export type ManagedNeFormDialogProps = {
open: boolean;
/** null = create mode */
@@ -55,8 +44,6 @@ export function ManagedNeFormDialog({
enabled: open,
});
- // Reset only when the dialog opens or the edited row changes — avoid clobbering
- // in-progress edits if the parent re-renders with a new initialValues object identity.
const editingId = editing?.id ?? "";
useEffect(() => {
if (!open) return;
@@ -94,142 +81,148 @@ export function ManagedNeFormDialog({
},
});
- if (!open) return null;
-
return (
- {
- if (saveMutation.isPending) return;
- onClose();
- }}
- >
-
e.stopPropagation()}>
-
{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}
-
{t("managedNe.form.requiredHint")}
+
+
+
+ {editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}
+
+
+
+
+ {t("managedNe.form.requiredHint")}
-
- {t("managedNe.col.name")}
- setForm({ ...form, name: e.target.value })} />
+ setForm({ ...form, name })}
+ >
+ {t("managedNe.col.name")}
+
{t("managedNe.form.nameConnectHint")}
-
-
- {t("managedNe.col.vendor")}
-
-
-
- {t("managedNe.col.deviceType")}
-
-
-
- {t("managedNe.col.ip")}
- setForm({ ...form, ip_address: e.target.value })}
- />
-
-
- {t("managedNe.col.port")}
- setForm({ ...form, port: Number(e.target.value) || 22 })}
- />
-
-
- {t("managedNe.col.protocol")}
-
-
-
- {t("managedNe.col.user")}
- setForm({ ...form, username: e.target.value })}
- />
-
-
-
+
+ {
+ const vendor = e.target.value;
+ setForm((prev) => {
+ const next = { ...prev, vendor };
+ const dt = String(prev.device_type || "").trim().toLowerCase();
+ if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
+ if (vendor === "ZTE") next.device_type = "zte_zxros";
+ else if (vendor === "Huawei") next.device_type = "huawei";
+ else if (vendor === "Cisco") next.device_type = "cisco_ios";
+ else if (vendor === "Juniper") next.device_type = "juniper_junos";
+ else if (vendor === "Nokia") next.device_type = "nokia_sros";
+ }
+ return next;
+ });
+ }}
+ >
+ {vendors.map((v) => (
+
+ ))}
+
+ setForm({ ...form, device_type: e.target.value })}
+ >
+ {deviceTypes.map((dt) => (
+
+ ))}
+
+ setForm({ ...form, ip_address })}
+ >
+ {t("managedNe.col.ip")}
+
+
+ setForm({ ...form, port: Number(port) || 22 })}
+ >
+ {t("managedNe.col.port")}
+
+
+ setForm({ ...form, protocol: e.target.value })}
+ >
+
+
+
+ setForm({ ...form, username })}
+ >
+ {t("managedNe.col.user")}
+
+
+ setForm({ ...form, password })}
+ >
+
{t("managedNe.col.password")}
({t("managedNe.form.passwordOptional")})
-
- setForm({ ...form, password: e.target.value })}
- />
-
-
- {t("managedNe.col.tags")}
- setForm({ ...form, tags: e.target.value })} />
-
-
- {t("managedNe.col.remark")}
- setForm({ ...form, remark: e.target.value })} />
-
+
+
+
+ setForm({ ...form, tags })}>
+ {t("managedNe.col.tags")}
+
+
+ setForm({ ...form, remark })}
+ >
+ {t("managedNe.col.remark")}
+
+
-
-
- {t("managedNe.form.cancel")}
-
- saveMutation.mutate()}
- >
- {saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
-
-
{saveMutation.isError ? (
-
- {String(saveMutation.error)}
-
+
+
+ {String(saveMutation.error)}
+
+
) : null}
-
-
+
+
+
+ {t("managedNe.form.cancel")}
+
+ saveMutation.mutate()}
+ >
+ {saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
+
+
+
);
}
diff --git a/web/src/pages/network/LldpLinksPage.tsx b/web/src/pages/network/LldpLinksPage.tsx
index e69d9c2..a739d4b 100644
--- a/web/src/pages/network/LldpLinksPage.tsx
+++ b/web/src/pages/network/LldpLinksPage.tsx
@@ -1,8 +1,11 @@
+import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
import { HelpHint } from "../../components/HelpHint";
+import { AppModalShell } from "../../components/ui/AppModalShell";
+import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
@@ -32,7 +35,7 @@ import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
const POLL_MS = 2500;
-const TARGET_PAGE_SIZE = 20;
+const TARGET_PAGE_SIZE = 10;
const SEP = " · ";
function lldpJobStatusLabel(t: (k: string) => string, status: string): string {
@@ -99,14 +102,14 @@ export function LldpLinksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [jobPage, setJobPage] = useState(1);
- const [jobPageSize, setJobPageSize] = useState(20);
+ const [jobPageSize, setJobPageSize] = useState(10);
const [jobStatus, setJobStatus] = useState("");
const [jobKeyword, setJobKeyword] = useState("");
const [exportingJobs, setExportingJobs] = useState(false);
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
const [expandedJobId, setExpandedJobId] = useState("");
const [itemPage, setItemPage] = useState(1);
- const [itemPageSize, setItemPageSize] = useState(20);
+ const [itemPageSize, setItemPageSize] = useState(10);
const [itemStatus, setItemStatus] = useState("");
const [itemKeyword, setItemKeyword] = useState("");
const [exportingItems, setExportingItems] = useState(false);
@@ -117,7 +120,7 @@ export function LldpLinksPage() {
const [edgeSource, setEdgeSource] = useState("");
const [edgeKeyword, setEdgeKeyword] = useState("");
const [edgePage, setEdgePage] = useState(1);
- const [edgePageSize, setEdgePageSize] = useState(50);
+ const [edgePageSize, setEdgePageSize] = useState(10);
const [exportingEdges, setExportingEdges] = useState(false);
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
@@ -554,45 +557,62 @@ export function LldpLinksPage() {
{t("lldpLinks.title")}
- void refresh()} disabled={dashQuery.isFetching}>
+ void refresh()}
+ >
{t("common.refresh")}
-
- startMut.mutate("full")}
+
+ startMut.mutate("full")}
>
{t("lldpLinks.collectNow")}
-
- 0)}
- onClick={() => startMut.mutate("retry_failed")}
+
+ 0)}
+ onPress={() => startMut.mutate("retry_failed")}
>
{t("lldpLinks.retryFailed")}
-
+
{running?.status === "running" || running?.status === "pending" ? (
- pauseMut.mutate(running.id)} disabled={pauseMut.isPending}>
+ pauseMut.mutate(running.id)}
+ >
{t("lldpLinks.pause")}
-
+
) : null}
{running?.status === "paused" ? (
- resumeMut.mutate(running.id)} disabled={resumeMut.isPending}>
+ resumeMut.mutate(running.id)}
+ >
{t("lldpLinks.resume")}
-
+
) : null}
{running &&
(running.status === "running" || running.status === "paused" || running.status === "pending") ? (
- {
+ {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(running.id);
}}
- disabled={stopMut.isPending}
>
{t("lldpLinks.stop")}
-
+
) : null}
@@ -654,11 +674,11 @@ export function LldpLinksPage() {
{t("lldpLinks.interval")}
- {
const max = intervalUnit === "days" ? 365 : 8760;
setIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
@@ -711,14 +731,13 @@ export function LldpLinksPage() {
- savePolicyMut.mutate()}
+ savePolicyMut.mutate()}
>
{t("lldpLinks.savePolicy")}
-
+
{scopeMode === "selected" ? (
@@ -767,9 +786,14 @@ export function LldpLinksPage() {
- setTargetPage((p) => p - 1)}>
+ setTargetPage((p) => p - 1)}
+ >
{t("common.prevPage")}
-
+
{t("common.pagerMeta", {
total: String(targetsQuery.data?.total ?? 0),
@@ -777,13 +801,14 @@ export function LldpLinksPage() {
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
})}
- = pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
- onClick={() => setTargetPage((p) => p + 1)}
+ = pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
+ onPress={() => setTargetPage((p) => p + 1)}
>
{t("common.nextPage")}
-
+
) : null}
@@ -791,7 +816,7 @@ export function LldpLinksPage() {
{t("lldpLinks.edgesTitle")}
-
-
-
+ {
@@ -822,13 +847,14 @@ export function LldpLinksPage() {
setEdgePage(1);
}}
/>
- void exportEdgesCsv()}
+ void exportEdgesCsv()}
>
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
-
+
@@ -893,7 +919,7 @@ export function LldpLinksPage() {
pages={edgePages}
total={edgeTotal}
pageSize={edgePageSize}
- pageSizeOptions={[20, 50, 100, 200]}
+ pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setEdgePage}
onPageSizeChange={(size) => {
setEdgePageSize(size);
@@ -904,7 +930,7 @@ export function LldpLinksPage() {
{t("lldpLinks.jobsTitle")}
-
-
+ {
@@ -928,13 +954,14 @@ export function LldpLinksPage() {
setJobPage(1);
}}
/>
- void exportJobsCsv()}
+ void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
-
+
@@ -958,10 +985,10 @@ export function LldpLinksPage() {
return (
|
- {
+ {
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
@@ -970,7 +997,7 @@ export function LldpLinksPage() {
}}
>
{t("lldpLinks.expand")}
-
+
|
{job.id.slice(0, 8)} |
{lldpTriggerLabel(t, job.trigger_mode)} |
@@ -1005,33 +1032,36 @@ export function LldpLinksPage() {
{job.status === "running" || job.status === "pending" ? (
- pauseMut.mutate(job.id)}
- disabled={pauseMut.isPending}
+ pauseMut.mutate(job.id)}
>
{t("lldpLinks.pause")}
-
+
) : null}
{job.status === "paused" ? (
- resumeMut.mutate(job.id)}
- disabled={resumeMut.isPending}
+ resumeMut.mutate(job.id)}
>
{t("lldpLinks.resume")}
-
+
) : null}
{job.status === "running" || job.status === "paused" || job.status === "pending" ? (
- {
+ {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(job.id);
}}
- disabled={stopMut.isPending}
>
{t("lldpLinks.stop")}
-
+
) : null}
|
@@ -1062,265 +1092,238 @@ export function LldpLinksPage() {
disabled={jobsQuery.isLoading}
/>
- {expandedJobId ? (
- {
- setExpandedJobId("");
- setItemDetail(null);
- }}
- >
-
e.stopPropagation()}
- >
-
-
-
{t("lldpLinks.jobDetailTitle")}
-
- {(() => {
- const job = jobDetailQuery.data;
- if (job) {
- return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`;
- }
- if (jobDetailQuery.isLoading) {
- return `${expandedJobId.slice(0, 8)} · …`;
- }
- return expandedJobId.slice(0, 8);
- })()}
-
-
-
- void exportItemsCsv()}
- >
- {exportingItems ? t("common.exporting") : t("common.exportCsv")}
-
- {
- setExpandedJobId("");
- setItemDetail(null);
- setItemStatus("");
- setItemKeyword("");
- setItemPage(1);
- }}
- >
- {t("networkConfigs.close")}
-
-
-
+
{
+ setExpandedJobId("");
+ setItemDetail(null);
+ setItemStatus("");
+ setItemKeyword("");
+ setItemPage(1);
+ }}
+ size="lg"
+ >
+
+ {t("lldpLinks.jobDetailTitle")}
+
+
+
+
+ {(() => {
+ const job = jobDetailQuery.data;
+ if (job) {
+ return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`;
+ }
+ if (jobDetailQuery.isLoading) {
+ return `${expandedJobId.slice(0, 8)} · …`;
+ }
+ return expandedJobId.slice(0, 8);
+ })()}
+
-
-
- {
- setItemKeyword(e.target.value);
- setItemPage(1);
- }}
- />
-
-
- {jobDetailQuery.isLoading ? {t("common.refreshing")}
: null}
- {jobDetailQuery.data?.error ? (
- {jobDetailQuery.data.error}
- ) : null}
-
-
-
-
-
-
- | {t("lldpLinks.col.name")} |
- IP |
- {t("lldpLinks.col.status")} |
- {t("lldpLinks.col.neighbors")} |
- {t("lldpLinks.col.edges")} |
- {t("lldpLinks.col.error")} |
- |
-
-
-
- {detailItems.map((it) => {
- const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
- return (
-
- | {it.ne_name || it.ne_id || "—"} |
- {it.ne_ip || "—"} |
-
- {lldpItemResultLabel(t, it)}
- |
- {it.neighbors} |
-
- +{it.edges_added} / ~{it.edges_updated}
- {unmatchedCount > 0
- ? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
- : ""}
- |
- {it.error || "—"} |
-
- setItemDetail(it)}
- >
- {t("topology.discoverViewDetail")}
-
- |
-
- );
- })}
- {!detailItems.length && !jobDetailQuery.isLoading ? (
-
- |
- {t("common.empty")}
- |
-
- ) : null}
-
-
-
-
-
-
- {
- setItemPageSize(size);
- setItemPage(1);
- }}
- disabled={jobDetailQuery.isLoading}
- />
-
+
+ {
+ setItemStatus(e.target.value);
+ setItemPage(1);
+ }}
+ >
+
+
+
+
+
+ {
+ setItemKeyword(e.target.value);
+ setItemPage(1);
+ }}
+ />
-
- ) : null}
- {itemDetail ? (
-
setItemDetail(null)}>
-
e.stopPropagation()}
- >
-
-
-
- {itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id}
-
-
- {[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
-
-
-
setItemDetail(null)}
- >
- {t("topology.discoverClose")}
-
-
+ {jobDetailQuery.isLoading ?
{t("common.refreshing")}
: null}
+ {jobDetailQuery.data?.error ? (
+
{jobDetailQuery.data.error}
+ ) : null}
- {!itemDetail.ok ? (
-
- {itemDetail.error === "vendor_or_device_type_required"
- ? t("topology.discoverVendorRequired")
- : itemDetail.error || t("topology.discoverNeFail")}
-
- ) : null}
- {itemDetail.parser_stub ? (
-
- {t("topology.discoverParserStub").replace(
- "{{parser}}",
- itemDetail.parser_key || "unknown",
- )}
-
- ) : null}
-
-
-
- {t("topology.discoverNeOk")
- .replace("{{neighbors}}", String(itemDetail.neighbors || 0))
- .replace("{{added}}", String(itemDetail.edges_added || 0))
- .replace("{{updated}}", String(itemDetail.edges_updated || 0))}
-
-
-
-
- {t("topology.discoverUnmatchedTitle").replace(
- "{{count}}",
- String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
- )}
-
- {(itemDetail.unmatched || []).length === 0 ? (
-
{t("topology.discoverUnmatchedEmpty")}
- ) : (
-
-
-
-
- | {t("topology.discoverColRemote")} |
- {t("topology.discoverColLocalPort")} |
- {t("topology.discoverColRemotePort")} |
-
-
-
- {(itemDetail.unmatched || []).map((u, idx) => (
-
+
+
+
+
+
+ | {t("lldpLinks.col.name")} |
+ IP |
+ {t("lldpLinks.col.status")} |
+ {t("lldpLinks.col.neighbors")} |
+ {t("lldpLinks.col.edges")} |
+ {t("lldpLinks.col.error")} |
+ |
+
+
+
+ {detailItems.map((it) => {
+ const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
+ return (
+
+ | {it.ne_name || it.ne_id || "—"} |
+ {it.ne_ip || "—"} |
+ {lldpItemResultLabel(t, it)} |
+ {it.neighbors} |
- {(u.remote_name || u.remote_ip || "?").trim()}
- {u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
+ +{it.edges_added} / ~{it.edges_updated}
+ {unmatchedCount > 0
+ ? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
+ : ""}
+ |
+ {it.error || "—"} |
+
+ setItemDetail(it)}>
+ {t("topology.discoverViewDetail")}
+
|
- {u.local_port || "?"} |
- {u.remote_port || "?"} |
- ))}
-
-
-
- )}
-
- {itemDetail.raw_preview ? (
- <>
-
{t("topology.discoverRawPreview")}
-
{itemDetail.raw_preview}
- >
- ) : null}
-
-
- setItemDetail(null)}>
- {t("topology.discoverClose")}
-
+ );
+ })}
+ {!detailItems.length && !jobDetailQuery.isLoading ? (
+
+ |
+ {t("common.empty")}
+ |
+
+ ) : null}
+
+
-
- ) : null}
+
+
{
+ setItemPageSize(size);
+ setItemPage(1);
+ }}
+ disabled={jobDetailQuery.isLoading}
+ />
+
+
+ void exportItemsCsv()}
+ >
+ {exportingItems ? t("common.exporting") : t("common.exportCsv")}
+
+ {
+ setExpandedJobId("");
+ setItemDetail(null);
+ setItemStatus("");
+ setItemKeyword("");
+ setItemPage(1);
+ }}
+ >
+ {t("networkConfigs.close")}
+
+
+
+
+ setItemDetail(null)} size="lg">
+
+
+ {itemDetail?.ne_name || itemDetail?.ne_ip || itemDetail?.ne_id}
+
+
+
+
+ {itemDetail ? (
+ <>
+
+ {[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
+
+
+ {!itemDetail.ok ? (
+
+ {itemDetail.error === "vendor_or_device_type_required"
+ ? t("topology.discoverVendorRequired")
+ : itemDetail.error || t("topology.discoverNeFail")}
+
+ ) : null}
+ {itemDetail.parser_stub ? (
+
+ {t("topology.discoverParserStub").replace(
+ "{{parser}}",
+ itemDetail.parser_key || "unknown",
+ )}
+
+ ) : null}
+
+
+
+ {t("topology.discoverNeOk")
+ .replace("{{neighbors}}", String(itemDetail.neighbors || 0))
+ .replace("{{added}}", String(itemDetail.edges_added || 0))
+ .replace("{{updated}}", String(itemDetail.edges_updated || 0))}
+
+
+
+
+ {t("topology.discoverUnmatchedTitle").replace(
+ "{{count}}",
+ String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
+ )}
+
+ {(itemDetail.unmatched || []).length === 0 ? (
+ {t("topology.discoverUnmatchedEmpty")}
+ ) : (
+
+
+
+
+ | {t("topology.discoverColRemote")} |
+ {t("topology.discoverColLocalPort")} |
+ {t("topology.discoverColRemotePort")} |
+
+
+
+ {(itemDetail.unmatched || []).map((u, idx) => (
+
+ |
+ {(u.remote_name || u.remote_ip || "?").trim()}
+ {u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
+ |
+ {u.local_port || "?"} |
+ {u.remote_port || "?"} |
+
+ ))}
+
+
+
+ )}
+
+ {itemDetail.raw_preview ? (
+ <>
+ {t("topology.discoverRawPreview")}
+ {itemDetail.raw_preview}
+ >
+ ) : null}
+ >
+ ) : null}
+
+
+ setItemDetail(null)}>
+ {t("topology.discoverClose")}
+
+
+
);
}
diff --git a/web/src/pages/network/NetworkAlarmsPage.tsx b/web/src/pages/network/NetworkAlarmsPage.tsx
index 24f1478..8e84c17 100644
--- a/web/src/pages/network/NetworkAlarmsPage.tsx
+++ b/web/src/pages/network/NetworkAlarmsPage.tsx
@@ -1,7 +1,9 @@
+import { Button, Input } from "@heroui/react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ListPager } from "../../components/ListPager";
+import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
@@ -32,7 +34,7 @@ export function NetworkAlarmsPage() {
const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState("");
const [curPage, setCurPage] = useState(1);
- const [curPageSize, setCurPageSize] = useState(50);
+ const [curPageSize, setCurPageSize] = useState(10);
const [exporting, setExporting] = useState(false);
// Deep-link from topology (and similar): ?host=&cleared=&severity=&keyword=
@@ -135,15 +137,20 @@ export function NetworkAlarmsPage() {
{t("ume.alarms.title")}
- void exportCsv()}>
+ void exportCsv()}
+ >
{exporting ? t("common.exporting") : t("common.exportCsv")}
-
+
- {
@@ -151,7 +158,7 @@ export function NetworkAlarmsPage() {
setCurPage(1);
}}
/>
- {
@@ -159,7 +166,7 @@ export function NetworkAlarmsPage() {
setCurPage(1);
}}
/>
-
-
- {
+
+ {
setCurKeyword("");
setCurHostName("");
setCurSeverity("");
setCurCleared("");
setCurPage(1);
}}
- disabled={!hasFilters}
+ isDisabled={!hasFilters}
>
{t("common.clearFilters")}
-
+
{currentQuery.isLoading ?
{t("common.refreshing")}
: null}
@@ -232,18 +240,19 @@ export function NetworkAlarmsPage() {
{x.ne_id} |
{(x.host_name || "").trim() ? (
- {
+ onPress={() => {
setCurHostName(x.host_name || "");
setCurKeyword("");
setCurPage(1);
}}
- title={t("ume.alarms.filterByHost")}
+ aria-label={t("ume.alarms.filterByHost")}
>
{x.host_name}
-
+
) : (
{t("common.empty")}
@@ -264,7 +273,7 @@ export function NetworkAlarmsPage() {
pages={curPages}
total={curTotal}
pageSize={curPageSize}
- pageSizeOptions={[50, 100, 200, 500]}
+ pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setCurPage}
onPageSizeChange={(size) => {
setCurPageSize(size);
diff --git a/web/src/pages/network/NetworkConfigsPage.tsx b/web/src/pages/network/NetworkConfigsPage.tsx
index fd2fb3f..9ef5e7f 100644
--- a/web/src/pages/network/NetworkConfigsPage.tsx
+++ b/web/src/pages/network/NetworkConfigsPage.tsx
@@ -1,7 +1,10 @@
+import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
+import { AppModalShell } from "../../components/ui/AppModalShell";
+import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
@@ -30,7 +33,7 @@ export function NetworkConfigsPage() {
const { showOk, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(50);
+ const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
const [source, setSource] = useState("");
const [vendor, setVendor] = useState("");
@@ -150,19 +153,20 @@ export function NetworkConfigsPage() {
{t("networkConfigs.title")}
- void exportListCsv()}
+ void exportListCsv()}
>
{exportingList ? t("common.exporting") : t("common.exportCsv")}
-
+
- {
@@ -170,7 +174,7 @@ export function NetworkConfigsPage() {
setPage(1);
}}
/>
-
-
+ {
@@ -189,10 +193,11 @@ export function NetworkConfigsPage() {
setPage(1);
}}
/>
- {
+ {
setKeyword("");
setSource("");
setVendor("");
@@ -200,7 +205,7 @@ export function NetworkConfigsPage() {
}}
>
{t("common.clearFilters")}
-
+
{listQuery.isLoading ? {t("common.refreshing")} : null}
@@ -239,22 +244,24 @@ export function NetworkConfigsPage() {
|
- {
+ {
setSelected({ source: row.source, id: row.target_id });
setTab("primary");
}}
>
{t("networkConfigs.view")}
-
-
+
+ onPress={() =>
void exportConfig(
row.source,
row.target_id,
@@ -263,10 +270,11 @@ export function NetworkConfigsPage() {
}
>
{t("networkConfigs.export")}
-
- {
+
+ {
const path =
row.source === "ume"
? `/webcrt?ne_id=${encodeURIComponent(row.target_id)}&source=ume`
@@ -275,7 +283,7 @@ export function NetworkConfigsPage() {
}}
>
WebCRT
-
+
|
@@ -291,7 +299,7 @@ export function NetworkConfigsPage() {
pages={pages}
total={total}
pageSize={pageSize}
- pageSizeOptions={[20, 50, 100]}
+ pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
@@ -301,89 +309,84 @@ export function NetworkConfigsPage() {
/>
- {selected ? (
- setSelected(null)}
- >
-
e.stopPropagation()}
- >
-
-
-
{detail?.ne_name || selected.id}
-
- {selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
-
-
-
-
- void exportConfig(
- selected.source,
- selected.id,
- showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
- )
- }
- >
- {t("networkConfigs.export")}
-
- {showAlt ? (
- void exportConfig(selected.source, selected.id, "both")}
- >
- {t("networkConfigs.exportBoth")}
-
- ) : null}
- setSelected(null)}>
- {t("networkConfigs.close")}
-
-
-
-
- {detailQuery.isLoading ?
{t("common.refreshing")}
: null}
-
- {showAlt ? (
-
- setTab("primary")}
- >
- {t("networkConfigs.tabSet")}
-
- setTab("alt")}
- >
- {t("networkConfigs.tabHier")}
-
-
- ) : null}
-
-
-
- SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
- {fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
+ setSelected(null)}
+ size="lg"
+ className="nm-config-modal"
+ >
+
+ {detail?.ne_name || selected?.id || t("networkConfigs.view")}
+
+
+
+ {selected ? (
+
+ {selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
-
-
- ) : null}
+ ) : null}
+ {detailQuery.isLoading ? {t("common.refreshing")}
: null}
+ {showAlt ? (
+
+ setTab("primary")}
+ >
+ {t("networkConfigs.tabSet")}
+
+ setTab("alt")}
+ >
+ {t("networkConfigs.tabHier")}
+
+
+ ) : null}
+
+
+ SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
+ {fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
+
+
+
+ {selected ? (
+ <>
+
+ void exportConfig(
+ selected.source,
+ selected.id,
+ showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
+ )
+ }
+ >
+ {t("networkConfigs.export")}
+
+ {showAlt ? (
+ void exportConfig(selected.source, selected.id, "both")}
+ >
+ {t("networkConfigs.exportBoth")}
+
+ ) : null}
+ >
+ ) : null}
+ setSelected(null)}>
+ {t("networkConfigs.close")}
+
+
+
);
}
diff --git a/web/src/pages/network/NetworkDevicesPage.tsx b/web/src/pages/network/NetworkDevicesPage.tsx
index 87d08da..f71612b 100644
--- a/web/src/pages/network/NetworkDevicesPage.tsx
+++ b/web/src/pages/network/NetworkDevicesPage.tsx
@@ -1,6 +1,8 @@
+import { Button, Input } from "@heroui/react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ListPager } from "../../components/ListPager";
+import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
@@ -36,7 +38,7 @@ export function NetworkDevicesPage() {
const [keyword, setKeyword] = useState("");
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(50);
+ const [pageSize, setPageSize] = useState(10);
const [exporting, setExporting] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300);
@@ -91,15 +93,20 @@ export function NetworkDevicesPage() {
{t("networkDevices.title")}
- void exportCsv()}>
+ void exportCsv()}
+ >
{exporting ? t("common.exporting") : t("common.exportCsv")}
-
+
- {
@@ -107,7 +114,7 @@ export function NetworkDevicesPage() {
setPage(1);
}}
/>
-
- {
+
+ {
setKeyword("");
setSource("all");
setPage(1);
}}
>
{t("common.clearFilters")}
-
+
{listQuery.isLoading ?
{t("common.refreshing")}
: null}
@@ -167,12 +175,13 @@ export function NetworkDevicesPage() {
- openWebcrt(row)}>
+ openWebcrt(row)}>
WebCRT
-
-
+
+
openNewModuleWindow({
moduleId: "network",
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
@@ -180,7 +189,7 @@ export function NetworkDevicesPage() {
}
>
{t("network.nav.configs")}
-
+
|
@@ -195,7 +204,7 @@ export function NetworkDevicesPage() {
pages={pages}
total={total}
pageSize={pageSize}
- pageSizeOptions={[20, 50, 100, 200]}
+ pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
diff --git a/web/src/pages/network/PortTrafficBoardListPage.tsx b/web/src/pages/network/PortTrafficBoardListPage.tsx
index a179aa6..ccd3041 100644
--- a/web/src/pages/network/PortTrafficBoardListPage.tsx
+++ b/web/src/pages/network/PortTrafficBoardListPage.tsx
@@ -1,3 +1,4 @@
+import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createPortTrafficBoard,
@@ -52,90 +53,91 @@ export function PortTrafficBoardListPage() {
{t("portTraffic.wallTitle")}
- createMut.mutate()}
+ createMut.mutate()}
>
{t("portTraffic.boardCreate")}
-
+
- {boardsQuery.isLoading ? (
-
…
- ) : boardsQuery.isError ? (
-
{(boardsQuery.error as Error)?.message || t("common.opFailed")}
- ) : !boards.length ? (
-
-
{t("portTraffic.boardEmpty")}
-
createMut.mutate()}
- >
- {t("portTraffic.boardCreate")}
-
-
- ) : (
-
-
-
-
- | {t("portTraffic.boardName")} |
- {t("portTraffic.boardPanelCount")} |
- {t("portTraffic.boardCols")} |
- {t("portTraffic.boardUpdated")} |
- {t("portTraffic.col.actions")} |
-
-
-
- {boards.map((b) => (
-
- |
- openPortTrafficBoardWindow(b.id)}
- >
- {b.name || t("portTraffic.boardDefaultName")}
-
- |
- {b.panel_count} |
- {b.cols} |
- {b.updated_at ? formatSystemTime(b.updated_at) : "—"} |
-
-
- openPortTrafficBoardWindow(b.id)}
- >
- {t("portTraffic.boardOpen")}
-
- {
- if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
- deleteMut.mutate(b.id);
- }
- }}
- >
- {t("portTraffic.delete")}
-
-
- |
+ {boardsQuery.isLoading ? (
+ …
+ ) : boardsQuery.isError ? (
+ {(boardsQuery.error as Error)?.message || t("common.opFailed")}
+ ) : !boards.length ? (
+
+
{t("portTraffic.boardEmpty")}
+
createMut.mutate()}
+ >
+ {t("portTraffic.boardCreate")}
+
+
+ ) : (
+
+
+
+
+ | {t("portTraffic.boardName")} |
+ {t("portTraffic.boardPanelCount")} |
+ {t("portTraffic.boardCols")} |
+ {t("portTraffic.boardUpdated")} |
+ {t("portTraffic.col.actions")} |
- ))}
-
-
-
- )}
+
+
+ {boards.map((b) => (
+
+ |
+ openPortTrafficBoardWindow(b.id)}
+ >
+ {b.name || t("portTraffic.boardDefaultName")}
+
+ |
+ {b.panel_count} |
+ {b.cols} |
+ {b.updated_at ? formatSystemTime(b.updated_at) : "—"} |
+
+
+ openPortTrafficBoardWindow(b.id)}
+ >
+ {t("portTraffic.boardOpen")}
+
+ {
+ if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
+ deleteMut.mutate(b.id);
+ }
+ }}
+ >
+ {t("portTraffic.delete")}
+
+
+ |
+
+ ))}
+
+
+
+ )}
);
diff --git a/web/src/pages/network/PortTrafficBoardPanelCell.tsx b/web/src/pages/network/PortTrafficBoardPanelCell.tsx
index 0442932..f0f6ce8 100644
--- a/web/src/pages/network/PortTrafficBoardPanelCell.tsx
+++ b/web/src/pages/network/PortTrafficBoardPanelCell.tsx
@@ -1,3 +1,4 @@
+import { Button } from "@heroui/react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { fetchPortTrafficCompare } from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
@@ -96,18 +97,18 @@ export function PortTrafficBoardPanelCell({
{title}
- onMove?.(-1)}>
+ onMove?.(-1)}>
↑
-
- onMove?.(1)}>
+
+ onMove?.(1)}>
↓
-
- onEdit?.()}>
+
+ onEdit?.()}>
{t("portTraffic.edit")}
-
- onRemove?.()}>
+
+ onRemove?.()}>
{t("portTraffic.delete")}
-
+
) : null}
@@ -115,9 +116,9 @@ export function PortTrafficBoardPanelCell({
{t("portTraffic.boardPanelStale")}
{editing ? (
-
onEdit?.()}>
+ onEdit?.()}>
{t("portTraffic.boardRetarget")}
-
+
) : null}
) : (
diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/pages/network/PortTrafficPage.tsx
index 0f19a06..beb37f2 100644
--- a/web/src/pages/network/PortTrafficPage.tsx
+++ b/web/src/pages/network/PortTrafficPage.tsx
@@ -1,7 +1,10 @@
+import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
+import { AppModalShell } from "../../components/ui/AppModalShell";
+import { FieldSelect } from "../../components/ui/FieldSelect";
import {
createPortTrafficDevice,
deletePortTrafficDevice,
@@ -35,7 +38,7 @@ import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
const POLL_MS = 5000;
-const TARGET_PAGE_SIZE = 20;
+const TARGET_PAGE_SIZE = 10;
type ViewMode = "list" | "wizard" | "edit";
@@ -128,7 +131,7 @@ export function PortTrafficPage() {
const [view, setView] = useState("list");
const [listPage, setListPage] = useState(1);
- const [listPageSize, setListPageSize] = useState(50);
+ const [listPageSize, setListPageSize] = useState(10);
const [listKeyword, setListKeyword] = useState("");
const [listStatus, setListStatus] = useState("");
const [exportingDevices, setExportingDevices] = useState(false);
@@ -697,30 +700,32 @@ export function PortTrafficPage() {
{t("portTraffic.title")}
{view !== "list" ? (
- {
+ {
resetWizard();
setView("list");
}}
>
{t("portTraffic.backList")}
-
+
) : (
<>
- navigate("/network/tasks/port-traffic/wall")}>
+ navigate("/network/tasks/port-traffic/wall")}>
{t("portTraffic.wall")}
-
- void exportDevicesCsv()}
+
+ void exportDevicesCsv()}
>
{exportingDevices ? t("common.exporting") : t("common.exportCsv")}
-
-
+
+
{t("portTraffic.create")}
-
+
>
)}
@@ -752,7 +757,7 @@ export function PortTrafficPage() {
- {
@@ -760,7 +765,7 @@ export function PortTrafficPage() {
setListPage(1);
}}
/>
-
- {
+
+ {
setListKeyword("");
setListStatus("");
setListPage(1);
}}
>
{t("common.clearFilters")}
-
+
{devicesQuery.isLoading ? {t("common.refreshing")}
: null}
@@ -793,9 +799,9 @@ export function PortTrafficPage() {
{t("portTraffic.empty")}
{!hasListFilters ? (
-
+
{t("portTraffic.create")}
-
+
) : null}
) : devices.length ? (
@@ -843,74 +849,70 @@ export function PortTrafficPage() {
- openWallList()}
- >
+ openWallList()}>
{t("portTraffic.wall")}
-
- openEdit(row)}>
+
+ openEdit(row)}>
{t("portTraffic.edit")}
-
+
{needsNeRebind(row) ? (
- openRebind(row)}
- >
+ openRebind(row)}>
{t("portTraffic.rebind")}
-
+
) : null}
{row.status !== "running" ? (
- startMut.mutate(row.id)}
+ startMut.mutate(row.id)}
>
{t("portTraffic.start")}
-
+
) : (
- pauseMut.mutate(row.id)}
+ pauseMut.mutate(row.id)}
>
{t("portTraffic.pause")}
-
+
)}
{row.status !== "stopped" ? (
- stopMut.mutate(row.id)}
+ stopMut.mutate(row.id)}
>
{t("portTraffic.stop")}
-
+
) : null}
- {
+ onPress={() => {
setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row));
setLogKeyword("");
}}
>
{t("portTraffic.log")}
-
- {
+
+ {
if (window.confirm(t("portTraffic.confirmDelete"))) {
deleteMut.mutate(row.id);
}
}}
>
{t("portTraffic.delete")}
-
+
|
@@ -926,7 +928,7 @@ export function PortTrafficPage() {
pages={pages}
total={listTotal}
pageSize={listPageSize}
- pageSizeOptions={[20, 50, 100]}
+ pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setListPage}
onPageSizeChange={(size) => {
setListPageSize(size);
@@ -1021,9 +1023,14 @@ export function PortTrafficPage() {
{selectedNe.name} ({selectedNe.ip_address})
- void discoverSelected()}>
+ void discoverSelected()}
+ >
{portsLoading ? "…" : t("portTraffic.discover")}
-
+
{portsError ? {portsError} : null}
{discoverMeta ? (
@@ -1165,24 +1172,24 @@ export function PortTrafficPage() {
})}
{pickedList.length > 40 ? … +{pickedList.length - 40} : null}
-
+
{t("portTraffic.confirmCreate")}
-
+
) : null}
- setWizardStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3) : s))}
+ setWizardStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3) : s))}
>
{t("portTraffic.prev")}
-
- = 3}
- onClick={() => {
+
+ = 3}
+ onPress={() => {
if (wizardStep === 1 && !selectedNe) {
showError(t("portTraffic.pickNeFirst"));
return;
@@ -1195,7 +1202,7 @@ export function PortTrafficPage() {
}}
>
{t("portTraffic.next")}
-
+
) : null}
@@ -1228,23 +1235,28 @@ export function PortTrafficPage() {
{t("portTraffic.concurrency")}
- setConcurrency(Number(e.target.value) || 1)}
/>
{t("portTraffic.note")}
- setNote(e.target.value)} />
+ setNote(e.target.value)} />
- void discoverSelected()}>
+ void discoverSelected()}
+ >
{portsLoading ? "…" : t("portTraffic.discover")}
-
+
{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}
{portsError ? {portsError}
: null}
@@ -1315,233 +1327,222 @@ export function PortTrafficPage() {
)}
- void saveEdit()}
+ void saveEdit()}
>
{t("portTraffic.save")}
-
+
) : null}
- {logDeviceId ? (
-
-
e.stopPropagation()}
- >
-
-
-
{t("portTraffic.logTitle")}
-
- {t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
-
-
-
-
- {t("common.exportCsv")}
-
-
- {t("portTraffic.logClose")}
-
-
-
-
- setLogKeyword(e.target.value)}
- />
-
- {logQuery.isLoading ? (
-
…
- ) : !logEvents.length ? (
-
{t("portTraffic.logEmpty")}
- ) : !filteredLogEvents.length ? (
-
{t("portTraffic.logEmpty")}
- ) : (
-
- {filteredLogEvents.map((ev) => {
- const level = String(ev.level || "error").toLowerCase();
- const tone =
- level === "warn" || level === "warning"
- ? "warn"
- : level === "info"
- ? "info"
- : "error";
- return (
-
-
-
{level}
-
- {formatSystemTime(ev.created_at) || "—"}
+
+
+ {t("portTraffic.logTitle")}
+
+
+
+
+ {t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
+
+
+ setLogKeyword(e.target.value)}
+ />
+
+ {logQuery.isLoading ? (
+ …
+ ) : !logEvents.length ? (
+ {t("portTraffic.logEmpty")}
+ ) : !filteredLogEvents.length ? (
+ {t("portTraffic.logEmpty")}
+ ) : (
+
+ {filteredLogEvents.map((ev) => {
+ const level = String(ev.level || "error").toLowerCase();
+ const tone =
+ level === "warn" || level === "warning"
+ ? "warn"
+ : level === "info"
+ ? "info"
+ : "error";
+ return (
+
+
+ {level}
+
+ {formatSystemTime(ev.created_at) || "—"}
+
+ {ev.ifname ? (
+
+ {ev.ifname}
- {ev.ifname ? (
-
- {ev.ifname}
-
- ) : null}
-
- {formatPortTrafficLogMessage(ev.message, t)}
-
- );
- })}
-
- )}
-
-
- ) : null}
-
- {rebindDevice ? (
-
-
e.stopPropagation()}
+ ) : null}
+
+
{formatPortTrafficLogMessage(ev.message, t)}
+
+ );
+ })}
+
+ )}
+
+
+
-
-
-
{t("portTraffic.rebindTitle")}
-
- {t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)}
- {rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""}
- {` · ${rebindSource}`}
- {rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
-
-
{t("portTraffic.rebindHint")}
-
-
- {t("portTraffic.logClose")}
-
-
+ {t("common.exportCsv")}
+
+
+ {t("portTraffic.logClose")}
+
+
+
-
-
{
- setRebindKeyword(e.target.value);
- setRebindPage(1);
- }}
- placeholder={t("portTraffic.neKeywordPh")}
- />
- {rebindSelectedNe ? (
+
+
+ {t("portTraffic.rebindTitle")}
+
+
+
+ {rebindDevice ? (
+ <>
+
+ {t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)}
+ {rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""}
+ {` · ${rebindSource}`}
+ {rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
+
+ {t("portTraffic.rebindHint")}
+
+
+ {
+ setRebindKeyword(e.target.value);
+ setRebindPage(1);
+ }}
+ placeholder={t("portTraffic.neKeywordPh")}
+ />
+ {rebindSelectedNe ? (
+
+ {t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} (
+ {rebindSelectedNe.ip_address})
+
+ ) : null}
+
+
+ {rebindNesQuery.isLoading ? (
+ …
+ ) : !(rebindNesQuery.data?.items || []).length ? (
+ {t("portTraffic.rebindEmpty")}
+ ) : (
+
+ )}
+
+
+ setRebindPage((p) => p - 1)}
+ >
+ ‹
+
- {t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} (
- {rebindSelectedNe.ip_address})
+ {rebindPage}/
+ {Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))}
- ) : null}
-
-
- {rebindNesQuery.isLoading ? (
- …
- ) : !(rebindNesQuery.data?.items || []).length ? (
- {t("portTraffic.rebindEmpty")}
- ) : (
-
-
+
=
+ Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
+ }
+ onPress={() => setRebindPage((p) => p + 1)}
+ >
+ ›
+
- )}
-
-
- setRebindPage((p) => p - 1)}
- >
- ‹
-
-
- {rebindPage}/
- {Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))}
-
- =
- Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
- }
- onClick={() => setRebindPage((p) => p + 1)}
- >
- ›
-
-
-
-
-
- {t("portTraffic.boardCancel")}
-
-
- {rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
-
-
-
-
- ) : null}
+ >
+ ) : null}
+
+
+
+ {t("portTraffic.boardCancel")}
+
+
+ {rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
+
+
+
);
}
diff --git a/web/src/pages/network/PortTrafficWallPage.tsx b/web/src/pages/network/PortTrafficWallPage.tsx
index 586470b..bc4f595 100644
--- a/web/src/pages/network/PortTrafficWallPage.tsx
+++ b/web/src/pages/network/PortTrafficWallPage.tsx
@@ -1,3 +1,4 @@
+import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
@@ -9,6 +10,8 @@ import {
putPortTrafficBoardPanels,
updatePortTrafficBoard,
} from "../../services/api";
+import { AppModalShell } from "../../components/ui/AppModalShell";
+import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
@@ -318,7 +321,7 @@ export function PortTrafficWallPage() {
<>
{t("portTraffic.boardName")}
- {
setDraftName(e.target.value);
@@ -326,21 +329,20 @@ export function PortTrafficWallPage() {
}}
/>
-
- {t("portTraffic.boardCols")}
-
-
+
{
+ setDraftCols(Number(e.target.value) || 2);
+ setDirty(true);
+ }}
+ >
+
+
+
+
+
>
) : (
@@ -351,47 +353,45 @@ export function PortTrafficWallPage() {
{panels.length && !editing ? (
- void toggleFullscreen()}
- title={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
+ onPress={() => void toggleFullscreen()}
aria-label={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
>
{fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
-
+
) : null}
{!editing ? (
-
+
{t("portTraffic.boardEdit")}
-
+
) : (
<>
- savePanelsMut.mutate()}
+ savePanelsMut.mutate()}
>
{t("portTraffic.boardSave")}
-
-
+
+
{t("portTraffic.boardCancel")}
-
+
>
)}
{!editing ? (
- {
+ {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) deleteMut.mutate();
}}
>
{t("portTraffic.delete")}
-
+
) : null}
@@ -436,9 +436,9 @@ export function PortTrafficWallPage() {
))}
-
+
{t("portTraffic.boardAddPanel")}
-
+
) : null}
@@ -446,9 +446,9 @@ export function PortTrafficWallPage() {
{t("portTraffic.boardNoPanels")}
{!editing ? (
-
+
{t("portTraffic.boardEdit")}
-
+
) : null}
) : (
@@ -499,25 +499,23 @@ export function PortTrafficWallPage() {
)}
{editing && editPanel ? (
-
-
e.stopPropagation()}
- >
-
{t("portTraffic.boardPanelSettings")}
-
+
+
+ {t("portTraffic.boardPanelSettings")}
+
+
+
+
{t("portTraffic.boardPanelTitle")}
- updatePanel(editPanel.id, { title: e.target.value })}
/>
{t("portTraffic.boardPickDevice")}
-
+
{t("portTraffic.wallPort")}
-
+
{t("portTraffic.range")}
-
+
{t("portTraffic.compare")}
-
+
{editPanel.baseline === "custom" ? (
{t("portTraffic.offsetHours")}
-
updatePanel(editPanel.id, {
offset_hours: Number(e.target.value) || 24,
@@ -612,7 +610,7 @@ export function PortTrafficWallPage() {
) : null}
{t("portTraffic.yMode")}
-
+
{t("portTraffic.boardColSpan")}
-
+
{t("portTraffic.filterMap")} · {t("portTraffic.wallDevice")}
-
+
{t("portTraffic.mapBaselinePort")}
-
+
{editPanel.baseline !== "off" || editPanel.baseline_target_id ? (
{t("portTraffic.aheadHours")}
-
updatePanel(editPanel.id, {
ahead_hours: Math.max(0, Math.min(24, Number(e.target.value) || 0)),
@@ -704,22 +702,18 @@ export function PortTrafficWallPage() {
) : null}
{editPanel.baseline === "week" ? (
-
- {t("portTraffic.retentionHint")}
-
+ {t("portTraffic.retentionHint")}
) : null}
{editPanel.baseline_target_id ? (
-
- {t("portTraffic.mapBaselineHint")}
-
+ {t("portTraffic.mapBaselineHint")}
) : null}
-
-
- {t("portTraffic.logClose")}
-
-
-
-
+
+
+
+ {t("portTraffic.logClose")}
+
+
+
) : null}
);
diff --git a/web/src/pages/network/TopologyClassifyPage.tsx b/web/src/pages/network/TopologyClassifyPage.tsx
index e2346bf..8796440 100644
--- a/web/src/pages/network/TopologyClassifyPage.tsx
+++ b/web/src/pages/network/TopologyClassifyPage.tsx
@@ -1,3 +1,4 @@
+import { Button, Input } from "@heroui/react";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -9,13 +10,15 @@ import {
matchFabricNodes,
patchFabricNodeTags,
} from "../../services/api";
+import { ListPager } from "../../components/ListPager";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { openOrFocusModule } from "../../utils/moduleWindows";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
+import { pageCount } from "../../utils/display";
-const PAGE_SIZE = 50;
+const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
if (!root) return [];
@@ -95,6 +98,7 @@ export function TopologyClassifyPage() {
const [unmatched, setUnmatched] = useState("");
const [linkStatus, setLinkStatus] = useState("");
const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(10);
const linkLabel = (status: string | undefined) => {
switch (String(status || "").toLowerCase()) {
@@ -144,6 +148,7 @@ export function TopologyClassifyPage() {
linkStatus,
page,
filterMajor,
+ pageSize,
),
queryFn: () =>
fetchFabricNodes({
@@ -153,7 +158,7 @@ export function TopologyClassifyPage() {
unmatched,
linkStatus,
page,
- pageSize: PAGE_SIZE,
+ pageSize,
}),
});
@@ -246,7 +251,7 @@ export function TopologyClassifyPage() {
.filter((n) => selected[n.id] && isFabricNodeDeletable(n))
.map((n) => n.id);
const total = listQuery.data?.total || 0;
- const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ const pages = pageCount(total, pageSize);
const selectedCount = Object.values(selected).filter(Boolean).length;
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
@@ -273,10 +278,9 @@ export function TopologyClassifyPage() {
{t("topoClassify.title")}
- {
+ {
const regionId = filterRegion || assignRegion || "";
const folder = regions.find((r) => r.id === regionId);
const views = folder?.views || [];
@@ -289,15 +293,14 @@ export function TopologyClassifyPage() {
}}
>
{t("topoClassify.openTopo")}
-
+
{t("topoClassify.inventory")}
-
{
@@ -305,8 +308,7 @@ export function TopologyClassifyPage() {
setPage(1);
}}
/>
-
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
- {t("common.prevPage")}
-
-
- {t("common.pagerMeta")
- .replace("{{total}}", String(total))
- .replace("{{page}}", String(page))
- .replace("{{pages}}", String(pages))}
-
- = pages}
- onClick={() => setPage((p) => p + 1)}
- >
- {t("common.nextPage")}
-
-
+
{
+ setPageSize(size);
+ setPage(1);
+ }}
+ disabled={listQuery.isLoading}
+ />
);
diff --git a/web/src/pages/topology/TopologyCtxMenu.tsx b/web/src/pages/topology/TopologyCtxMenu.tsx
index dcf09ea..8f25612 100644
--- a/web/src/pages/topology/TopologyCtxMenu.tsx
+++ b/web/src/pages/topology/TopologyCtxMenu.tsx
@@ -1,5 +1,6 @@
import type { RefObject } from "react";
import { createPortal } from "react-dom";
+import { Button } from "@heroui/react";
import type { Edge, Node } from "@xyflow/react";
import { useI18n } from "../../i18n";
import type { CtxMenu } from "./pageTypes";
@@ -372,16 +373,16 @@ export function TopologyCtxMenu({
))}
{!expandPhysicalLinks ? (
- {
+ {
onExpandPhysicalLinks();
onClose();
}}
>
{t("topology.expandPhysicalLinks")}
-
+
) : null}
) : null}
diff --git a/web/src/pages/topology/TopologyDiscoverPanel.tsx b/web/src/pages/topology/TopologyDiscoverPanel.tsx
index 2552648..5376b05 100644
--- a/web/src/pages/topology/TopologyDiscoverPanel.tsx
+++ b/web/src/pages/topology/TopologyDiscoverPanel.tsx
@@ -1,3 +1,4 @@
+import { Button } from "@heroui/react";
import type { TopologyDiscoverOut } from "../../types";
import { useI18n } from "../../i18n";
import { openOrFocusModule } from "../../utils/moduleWindows";
@@ -48,10 +49,10 @@ export function TopologyDiscoverPanel({
{t("topology.discoverReport")}
- {
+ {
if (discovering) {
void onCancelDiscover();
return;
@@ -60,7 +61,7 @@ export function TopologyDiscoverPanel({
}}
>
{discovering ? t("topology.discoverCancel") : t("topology.discoverClose")}
-
+
{discovering && discoverProgress.total > 0 ? (
@@ -96,10 +97,10 @@ export function TopologyDiscoverPanel({
.replace("{{failed}}", String(discoverSummary.failed))}
- {
+ {
const jobId = String(discoverReport?.job_id || "").trim();
const qs = jobId ? `?job_id=${encodeURIComponent(jobId)}` : "";
openOrFocusModule({
@@ -109,7 +110,7 @@ export function TopologyDiscoverPanel({
}}
>
{t("topology.discoverGoLldp")}
-
+
>
) : null}
diff --git a/web/src/pages/topology/TopologyHexBrowser.tsx b/web/src/pages/topology/TopologyHexBrowser.tsx
index 4584e8f..f91fe8c 100644
--- a/web/src/pages/topology/TopologyHexBrowser.tsx
+++ b/web/src/pages/topology/TopologyHexBrowser.tsx
@@ -1,3 +1,4 @@
+import { Button } from "@heroui/react";
import { Fragment } from "react";
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types";
import { useI18n } from "../../i18n";
@@ -125,17 +126,17 @@ export function TopologyHexBrowser({
{t("topology.treeLoadFailed")}
{treeError ?
({String(treeError)}) : null}
-
+
{t("topology.treeRetry")}
-
-
+
{t("topology.newRegion")}
-
+
) : regions.length === 0 ? (
@@ -144,14 +145,14 @@ export function TopologyHexBrowser({
{t("topology.emptyMaps")}
-
{t("topology.newRegion")}
-
+
) : (
diff --git a/web/src/pages/topology/TopologySidebar.tsx b/web/src/pages/topology/TopologySidebar.tsx
index 6536cb8..7a7b005 100644
--- a/web/src/pages/topology/TopologySidebar.tsx
+++ b/web/src/pages/topology/TopologySidebar.tsx
@@ -1,4 +1,5 @@
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
+import { Button, Input } from "@heroui/react";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
import { useI18n } from "../../i18n";
import { WORLD_MAP_ENABLED } from "./constants";
@@ -71,24 +72,24 @@ export function TopologySidebar({