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 (
- - - + + set({ hop_port: Number(hop_port) || 22 })} + > + + + {bastion ? ( - + set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })} + > + + + ) : null} {cliHop ? ( - + { + const hop_protocol = e.target.value; + set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) }); + }} + > + + + ) : null} - - + + + {bastion ? ( - + ) : null} {cliHop ? ( <> - + { + set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) }); + }} + > + + + {huawei ? ( - + set({ hop_enter_system_view: e.target.value === "yes" })} + > + + + ) : null} - + ) : 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({ })}
- + - + {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")}

- - - - - + setForm({ ...form, name })}> + + + + setForm({ ...form, username })} + > + + + + setForm({ ...form, password })} + > + + + + 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")}
- + { + 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 ? ( - + ) : null}

{t("ume.cli.connectTestTitle")}

- setSampleUmeNeId(e.target.value)} placeholder={t("ume.cli.sampleNePh")} /> - +
{testNeId ? (
@@ -385,10 +393,10 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl {t("ume.cli.existingProfiles")}
{(profilesQuery.data?.items || []).map((p) => ( - + ))}
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 }} />
- - + +
) : null} {findOpen ? (
- (function WebTerm } }} /> - - - +
) : null}
(function WebTerm style={{ left: ctxMenu.x, top: ctxMenu.y }} onMouseDown={(e) => e.stopPropagation()} > - - + - + +
) : 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 ( + + ); +} 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 ? (
- - - {user.username} - - +
) : ( 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}
- setName(e.target.value)} required + isDisabled={atTokenLimit} /> - + {isAdmin ? ( - + ) : null} - +
@@ -260,12 +283,12 @@ export function ApiTokensPage() { {t("auth.tokenOnceHint")}
{createdPlain} - - + +
) : null} @@ -318,24 +341,24 @@ export function ApiTokensPage() {
- - + +
@@ -347,59 +370,52 @@ export function ApiTokensPage() { - {editingRow ? ( -
-
setEditingRow(null)} /> -
-
-
-

{t("auth.editScopes")}

-

- {editingRow.name} - {editingRow.username ? ` · ${editingRow.username}` : ""} -

-
- -
-
    - {ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => ( -
  • - -
  • - ))} - {!editAvailable.length ? ( -
  • {t("auth.scopesNoneAvailable")}
  • - ) : null} -
-
- - -
-
-
- ) : 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} +
+
+ + + + +
); } 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]) => ( - + ))}
{isAdmin ? ( - { @@ -196,7 +197,7 @@ export function AuditPage() { }} /> ) : null} - { @@ -205,13 +206,14 @@ export function AuditPage() { if (e.target.value.trim()) setQuick("all"); }} /> - - + +
{query.isLoading ?

{t("common.refreshing")}

: null} @@ -266,13 +268,14 @@ export function AuditPage() { {row.client_ip || t("common.empty")} - + {open ? ( @@ -304,16 +307,22 @@ export function AuditPage() { })}
- - + +
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}
- - + - + - + + {running?.status === "running" || running?.status === "pending" ? ( - + ) : null}
@@ -600,14 +609,13 @@ export function CollectPage() { - +
@@ -726,17 +734,18 @@ export function CollectPage() { ))} - +
{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)} + dismissible={!createMutation.isPending} + size="lg" + className="ops-detail-modal" + > + + {t("collect.create.title")} + + + +

{t("collect.create.hint")}

+
+
+ +