Polish HeroUI chrome, topology toolbar, and session client IP.

Checkpoint before workbench facade redesign: list defaults, API key quota, toast portal, topology canvas editor toolbar with More menu, and trusted-proxy client IP for sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 15:10:57 +08:00
parent 5096117ad4
commit c4526e36d8
64 changed files with 6316 additions and 4521 deletions

View file

@ -64,7 +64,9 @@ def _is_public(path: str) -> bool:
def _client_ip(request: Request) -> str: 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: def _action_for(method: str, path: str) -> str:

View file

@ -23,6 +23,7 @@ from .auth_service import (
change_password, change_password,
create_api_token, create_api_token,
create_user, create_user,
api_token_quota,
list_api_tokens, list_api_tokens,
list_audit_logs, list_audit_logs,
list_auth_sessions, list_auth_sessions,
@ -42,13 +43,14 @@ from .auth_rate_limit import (
login_lock_remaining, login_lock_remaining,
register_login_failure, register_login_failure,
) )
from .client_ip import resolve_client_ip
from .db import get_db from .db import get_db
router = APIRouter(tags=["auth"]) router = APIRouter(tags=["auth"])
def _client_meta(request: Request) -> tuple[str, str]: 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] ua = str(request.headers.get("user-agent") or "")[:512]
return ip, ua return ip, ua
@ -401,7 +403,8 @@ def api_list_tokens(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> dict[str, Any]:
user_id = None if ctx.user.role == "admin" else ctx.user.id 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") @router.post("/v1/api-tokens")

View file

@ -235,6 +235,7 @@ def ensure_default_mcp_token(db: Session, user: AppUser | None = None) -> str |
name="mcp-default", name="mcp-default",
expires_in_days=0, expires_in_days=0,
scopes=list(MCP_DEFAULT_SCOPES), scopes=list(MCP_DEFAULT_SCOPES),
enforce_limit=False,
) )
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(plaintext + "\n", encoding="utf-8") 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) 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( def create_api_token(
db: Session, db: Session,
*, *,
@ -578,10 +590,20 @@ def create_api_token(
name: str, name: str,
expires_in_days: int | None = None, expires_in_days: int | None = None,
scopes: list[str] | None = None, scopes: list[str] | None = None,
enforce_limit: bool = True,
) -> tuple[ApiToken, str]: ) -> tuple[ApiToken, str]:
label = str(name or "").strip() or "default" label = str(name or "").strip() or "default"
if len(label) > 128: if len(label) > 128:
raise HTTPException(status_code=400, detail="token_name_too_long") 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 expires_at: datetime | None = None
if expires_in_days is not None and int(expires_in_days) > 0: if expires_in_days is not None and int(expires_in_days) > 0:
expires_at = utcnow_naive() + timedelta(days=int(expires_in_days)) expires_at = utcnow_naive() + timedelta(days=int(expires_in_days))

106
netx_api/client_ip.py Normal file
View file

@ -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)

View file

@ -163,6 +163,11 @@ class Settings(BaseSettings):
bootstrap_admin_password: str = "admin123" bootstrap_admin_password: str = "admin123"
# Written on first boot for MCP; path relative to cwd / absolute # Written on first boot for MCP; path relative to cwd / absolute
auth_mcp_token_file: str = "data/auth/mcp_token" 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). # Expose /docs /redoc /openapi.json without auth when true (lab only).
docs_enabled: bool = False docs_enabled: bool = False
# Refuse start when bind host is non-loopback and insecure defaults remain. # Refuse start when bind host is non-loopback and insecure defaults remain.

View file

@ -126,12 +126,13 @@ class WebcrtSftpChmodBody(BaseModel):
def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str: 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: if request is not None:
host = request.client.host if request.client else "" return resolve_client_ip(request)
elif websocket is not None: if websocket is not None:
host = websocket.client.host if websocket.client else "" return resolve_websocket_client_ip(websocket)
return str(host or "") return ""
@router.get("/sessions") @router.get("/sessions")

View file

@ -440,6 +440,30 @@ class AuthApiTests(unittest.TestCase):
) )
self.assertEqual(empty.status_code, 400) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

46
tests/test_client_ip.py Normal file
View file

@ -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()

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN" class="dark" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />

1084
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@heroui/react": "^3.2.5",
"@heroui/styles": "^3.2.5",
"@tanstack/react-query": "^5.100.8", "@tanstack/react-query": "^5.100.8",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
@ -21,10 +23,12 @@
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
"tailwind-variants": "^3.3.1",
"uplot": "^1.6.32" "uplot": "^1.6.32"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.3",
"@types/dagre": "^0.7.54", "@types/dagre": "^0.7.54",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@ -34,6 +38,7 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "^8.0.10" "vite": "^8.0.10"

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
/** Compact locale toggle: shows current language, click switches ZH ↔ EN. */ /** Compact locale toggle: shows current language, click switches ZH ↔ EN. */
@ -7,14 +8,14 @@ export function HeaderMenu() {
const label = locale === "zh" ? "ZH" : "EN"; const label = locale === "zh" ? "ZH" : "EN";
return ( return (
<button <Button
type="button" size="sm"
variant="ghost"
className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--locale" className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--locale"
title={t("layout.switchLanguage")}
aria-label={t("layout.switchLanguage")} aria-label={t("layout.switchLanguage")}
onClick={() => setLocale(next)} onPress={() => setLocale(next)}
> >
{label} {label}
</button> </Button>
); );
} }

View file

@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import { Input, Label, TextField } from "@heroui/react";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { FieldSelect } from "./ui/FieldSelect";
import { import {
HOP_VENDORS, HOP_VENDORS,
defaultHopTemplate, defaultHopTemplate,
@ -38,20 +39,6 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({
hop_enter_system_view: false, hop_enter_system_view: false,
}); });
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
function applyHopTemplate( function applyHopTemplate(
prev: HopProxyFieldsState, prev: HopProxyFieldsState,
protocol: string, protocol: string,
@ -111,10 +98,13 @@ export function HopProxyFields({
return ( return (
<div className="form-grid"> <div className="form-grid">
<label className="form-grid__full"> <FieldSelect
<FormLabel required>{t("managedNe.hop.type")}</FormLabel> fullWidth
<select className="form-grid__full"
label={t("managedNe.hop.type")}
required
value={value.hop_vendor} value={value.hop_vendor}
hint={t(hopHintKey(value.hop_vendor))}
onChange={(e) => { onChange={(e) => {
const hop_vendor = e.target.value as HopVendor; const hop_vendor = e.target.value as HopVendor;
set(patchHopVendorChange(hop_vendor, value)); set(patchHopVendorChange(hop_vendor, value));
@ -125,16 +115,12 @@ export function HopProxyFields({
{t(`managedNe.hop.vendor.${v}`)} {t(`managedNe.hop.vendor.${v}`)}
</option> </option>
))} ))}
</select> </FieldSelect>
<span className="form-field-hint">{t(hopHintKey(value.hop_vendor))}</span> <TextField
</label> fullWidth
<label> isRequired
<FormLabel required>{t("managedNe.hop.host")}</FormLabel>
<input
required
value={value.hop_host} value={value.hop_host}
placeholder={bastion ? t("managedNe.hop.hostPlaceholderBastion") : undefined} onChange={(hop_host) => set({ hop_host })}
onChange={(e) => set({ hop_host: e.target.value })}
onBlur={() => { onBlur={() => {
if (!bastion) return; if (!bastion) return;
const raw = String(value.hop_host || "").trim(); const raw = String(value.hop_host || "").trim();
@ -146,34 +132,36 @@ export function HopProxyFields({
hop_username: parsed.hop_username || value.hop_username, hop_username: parsed.hop_username || value.hop_username,
}); });
}} }}
/> >
<Label>{t("managedNe.hop.host")}</Label>
<Input placeholder={bastion ? t("managedNe.hop.hostPlaceholderBastion") : undefined} />
{bastion ? <span className="form-field-hint">{t("managedNe.hop.hostHintBastion")}</span> : null} {bastion ? <span className="form-field-hint">{t("managedNe.hop.hostHintBastion")}</span> : null}
</label> </TextField>
<label> <TextField
<FormLabel>{t("managedNe.hop.port")}</FormLabel> fullWidth
<input
type="number" type="number"
value={value.hop_port} value={String(value.hop_port)}
onChange={(e) => set({ hop_port: Number(e.target.value) || 22 })} onChange={(hop_port) => set({ hop_port: Number(hop_port) || 22 })}
/> >
</label> <Label>{t("managedNe.hop.port")}</Label>
<Input />
</TextField>
{bastion ? ( {bastion ? (
<label className="form-grid__full"> <FieldSelect
<FormLabel>{t("managedNe.hop.targetAuthMode")}</FormLabel> fullWidth
<select className="form-grid__full"
label={t("managedNe.hop.targetAuthMode")}
value={value.hop_target_auth_mode} value={value.hop_target_auth_mode}
hint={t("managedNe.hop.targetAuthHint")}
onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })} onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })}
> >
<option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option> <option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option>
<option value="manual">{t("managedNe.hop.targetAuthManual")}</option> <option value="manual">{t("managedNe.hop.targetAuthManual")}</option>
</select> </FieldSelect>
<span className="form-field-hint">{t("managedNe.hop.targetAuthHint")}</span>
</label>
) : null} ) : null}
{cliHop ? ( {cliHop ? (
<label> <FieldSelect
<FormLabel>{t("managedNe.hop.protocol")}</FormLabel> label={t("managedNe.hop.protocol")}
<select
value={value.hop_protocol} value={value.hop_protocol}
onChange={(e) => { onChange={(e) => {
const hop_protocol = e.target.value; const hop_protocol = e.target.value;
@ -182,72 +170,79 @@ export function HopProxyFields({
> >
<option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option> <option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option>
<option value="telnet">telnet</option> <option value="telnet">telnet</option>
</select> </FieldSelect>
</label>
) : null} ) : null}
<label> <TextField
<FormLabel required>{t("managedNe.hop.username")}</FormLabel> fullWidth
<input required value={value.hop_username} onChange={(e) => set({ hop_username: e.target.value })} /> isRequired
</label> value={value.hop_username}
<label> onChange={(hop_username) => set({ hop_username })}
<FormLabel required={hopPasswordRequired}> >
<Label>{t("managedNe.hop.username")}</Label>
<Input />
</TextField>
<TextField
fullWidth
type="password"
isRequired={hopPasswordRequired}
value={value.hop_password}
onChange={(hop_password) => set({ hop_password })}
>
<Label>
{t("managedNe.hop.password")} {t("managedNe.hop.password")}
{hopPasswordOptional ? ( {hopPasswordOptional ? (
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span> <span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
) : null} ) : null}
</FormLabel> </Label>
<input <Input />
type="password" </TextField>
required={hopPasswordRequired}
value={value.hop_password}
onChange={(e) => set({ hop_password: e.target.value })}
/>
</label>
{bastion ? ( {bastion ? (
<label className="form-grid__full"> <TextField
<FormLabel>{t("managedNe.hop.usernameTemplate")}</FormLabel> fullWidth
<input className="form-grid__full"
value={value.hop_command_template} value={value.hop_command_template}
onChange={(e) => set({ hop_command_template: e.target.value })} onChange={(hop_command_template) => set({ hop_command_template })}
placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} >
/> <Label>{t("managedNe.hop.usernameTemplate")}</Label>
<Input placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} />
<span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span> <span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
</label> </TextField>
) : null} ) : null}
{cliHop ? ( {cliHop ? (
<> <>
<label> <TextField
<FormLabel>{t(vrfLabelKey(value.hop_vendor))}</FormLabel> fullWidth
<input
value={value.hop_vrf} value={value.hop_vrf}
onChange={(e) => { onChange={(hop_vrf) => {
const hop_vrf = e.target.value;
set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) }); set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
}} }}
/> >
</label> <Label>{t(vrfLabelKey(value.hop_vendor))}</Label>
<Input />
</TextField>
{huawei ? ( {huawei ? (
<label className="form-grid__full"> <FieldSelect
<FormLabel>{t("managedNe.hop.enterSystemView")}</FormLabel> fullWidth
<select className="form-grid__full"
label={t("managedNe.hop.enterSystemView")}
value={value.hop_enter_system_view ? "yes" : "no"} value={value.hop_enter_system_view ? "yes" : "no"}
hint={t("managedNe.hop.enterSystemViewHint")}
onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })} onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })}
> >
<option value="no">{t("managedNe.hop.enterSystemViewNo")}</option> <option value="no">{t("managedNe.hop.enterSystemViewNo")}</option>
<option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option> <option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option>
</select> </FieldSelect>
<span className="form-field-hint">{t("managedNe.hop.enterSystemViewHint")}</span>
</label>
) : null} ) : null}
<label className="form-grid__full"> <TextField
<FormLabel>{t("managedNe.hop.commandTemplate")}</FormLabel> fullWidth
<input className="form-grid__full"
value={value.hop_command_template} value={value.hop_command_template}
onChange={(e) => set({ hop_command_template: e.target.value })} onChange={(hop_command_template) => set({ hop_command_template })}
placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} >
/> <Label>{t("managedNe.hop.commandTemplate")}</Label>
<Input placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} />
<span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span> <span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
</label> </TextField>
</> </>
) : null} ) : null}
</div> </div>

View file

@ -1,5 +1,7 @@
import { Button, Input } from "@heroui/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { FieldSelect } from "./ui/FieldSelect";
export type ListPagerProps = { export type ListPagerProps = {
page: number; page: number;
@ -19,7 +21,7 @@ export function ListPager({
pages, pages,
total, total,
pageSize, pageSize,
pageSizeOptions = [20, 50, 100, 200], pageSizeOptions = [10, 20, 50, 100, 200],
onPageChange, onPageChange,
onPageSizeChange, onPageSizeChange,
disabled = false, disabled = false,
@ -54,17 +56,18 @@ export function ListPager({
})} })}
</div> </div>
<div className="pager__controls btn-row"> <div className="pager__controls btn-row">
<button <Button
size="sm"
variant="secondary"
className="pager__btn" className="pager__btn"
type="button" isDisabled={disabled || page <= 1}
disabled={disabled || page <= 1} onPress={() => onPageChange(Math.max(1, page - 1))}
onClick={() => onPageChange(Math.max(1, page - 1))}
> >
{t("common.prevPage")} {t("common.prevPage")}
</button> </Button>
<label className="pager__jump"> <label className="pager__jump">
<span className="visually-hidden">{t("common.jumpPage")}</span> <span className="visually-hidden">{t("common.jumpPage")}</span>
<input <Input
className="pager__jump-input" className="pager__jump-input"
type="number" type="number"
min={1} min={1}
@ -72,7 +75,6 @@ export function ListPager({
value={jumpDraft} value={jumpDraft}
disabled={disabled} disabled={disabled}
aria-label={t("common.jumpPage")} aria-label={t("common.jumpPage")}
title={t("common.jumpPage")}
onChange={(e) => setJumpDraft(e.target.value)} onChange={(e) => setJumpDraft(e.target.value)}
onBlur={commitJump} onBlur={commitJump}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -84,20 +86,21 @@ export function ListPager({
/> />
<span className="muted">/ {safePages}</span> <span className="muted">/ {safePages}</span>
</label> </label>
<button <Button
size="sm"
variant="secondary"
className="pager__btn" className="pager__btn"
type="button" isDisabled={disabled || page >= safePages}
disabled={disabled || page >= safePages} onPress={() => onPageChange(Math.min(safePages, page + 1))}
onClick={() => onPageChange(Math.min(safePages, page + 1))}
> >
{t("common.nextPage")} {t("common.nextPage")}
</button> </Button>
{onPageSizeChange ? ( {onPageSizeChange ? (
<select <FieldSelect
className="pager__size" className="pager__size-field"
aria-label={t("common.pageSize")}
value={String(pageSize)} value={String(pageSize)}
disabled={disabled} disabled={disabled}
aria-label={t("common.pageSize")}
onChange={(e) => { onChange={(e) => {
const next = Number(e.target.value) || pageSizeOptions[0] || 50; const next = Number(e.target.value) || pageSizeOptions[0] || 50;
onPageSizeChange(next); onPageSizeChange(next);
@ -108,7 +111,7 @@ export function ListPager({
{t("common.perPage", { n: String(n) })} {t("common.perPage", { n: String(n) })}
</option> </option>
))} ))}
</select> </FieldSelect>
) : null} ) : null}
</div> </div>
</div> </div>

View file

@ -1,3 +1,4 @@
import { Button, Checkbox, Input, Label, TextField } from "@heroui/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -12,6 +13,7 @@ import {
} from "../services/api"; } from "../services/api";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields"; import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields";
import { HelpHint } from "./HelpHint"; import { HelpHint } from "./HelpHint";
import { FieldSelect } from "./ui/FieldSelect";
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
@ -214,31 +216,30 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
{cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")} {cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")}
</p> </p>
<div className="form-grid"> <div className="form-grid">
<label> <TextField fullWidth value={form.name} onChange={(name) => setForm({ ...form, name })}>
<span className="form-label">{t("ume.cli.profileName")}</span> <Label>{t("ume.cli.profileName")}</Label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <Input />
</label> </TextField>
<label> <TextField
<span className="form-label">{t("ume.cli.targetUsername")}</span> fullWidth
<input isRequired
required
value={form.username} value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })} onChange={(username) => setForm({ ...form, username })}
placeholder="ca-oper" >
/> <Label>{t("ume.cli.targetUsername")}</Label>
</label> <Input placeholder="ca-oper" />
<label> </TextField>
<span className="form-label">{t("ume.cli.targetPassword")}</span> <TextField
<input fullWidth
type="password" type="password"
value={form.password} value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} onChange={(password) => setForm({ ...form, password })}
placeholder={form.id ? t("ume.cli.passwordKeep") : ""} >
/> <Label>{t("ume.cli.targetPassword")}</Label>
</label> <Input placeholder={form.id ? t("ume.cli.passwordKeep") : ""} />
<label> </TextField>
<span className="form-label">{t("managedNe.form.deviceType")}</span> <FieldSelect
<select label={t("managedNe.form.deviceType")}
value={form.device_type_default} value={form.device_type_default}
onChange={(e) => setForm({ ...form, device_type_default: e.target.value })} onChange={(e) => setForm({ ...form, device_type_default: e.target.value })}
> >
@ -247,27 +248,24 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
{dt} {dt}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> <FieldSelect
<label> label={t("managedNe.form.vendor")}
<span className="form-label">{t("managedNe.form.vendor")}</span> value={form.vendor_default}
<select value={form.vendor_default} onChange={(e) => setForm({ ...form, vendor_default: e.target.value })}> onChange={(e) => setForm({ ...form, vendor_default: e.target.value })}
>
{vendors.map((v) => ( {vendors.map((v) => (
<option key={v} value={v}> <option key={v} value={v}>
{v} {v}
</option> </option>
))} ))}
</select> </FieldSelect>
</label>
</div> </div>
<div className="form-fieldset" style={{ marginTop: 16 }}> <div className="form-fieldset" style={{ marginTop: 16 }}>
<div className="form-fieldset__title">{t("managedNe.hop.sectionTitle")}</div> <div className="form-fieldset__title">{t("managedNe.hop.sectionTitle")}</div>
<label className="form-check"> <Checkbox
<input isSelected={form.hop_enabled}
type="checkbox" onChange={(hop_enabled) => {
checked={form.hop_enabled}
onChange={(e) => {
const hop_enabled = e.target.checked;
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
hop_enabled, hop_enabled,
@ -282,9 +280,12 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
: {}), : {}),
})); }));
}} }}
/> >
<span className="form-check__text">{t("managedNe.hop.enable")}</span> <Checkbox.Control>
</label> <Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
</Checkbox>
{form.hop_enabled ? ( {form.hop_enabled ? (
<div className="hop-proxy-fields"> <div className="hop-proxy-fields">
<HopProxyFields <HopProxyFields
@ -297,22 +298,23 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
) : null} ) : null}
</div> </div>
<div className="actions-row actions-row--inline" style={{ marginTop: 16 }}> <div className="actions-row actions-row--inline" style={{ marginTop: 16 }}>
<button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}> <Button
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")} size="sm"
</button> variant="primary"
<button isDisabled={saveMutation.isPending}
type="button" onPress={() => saveMutation.mutate()}
onClick={() => {
setForm(emptyForm());
}}
> >
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</Button>
<Button size="sm" variant="secondary" onPress={() => setForm(emptyForm())}>
{t("ume.cli.newProfile")} {t("ume.cli.newProfile")}
</button> </Button>
{form.id ? ( {form.id ? (
<button <Button
type="button" size="sm"
className="danger-btn" variant="danger"
onClick={async () => { onPress={() => {
void (async () => {
if (!window.confirm(t("ume.cli.deleteConfirm"))) return; if (!window.confirm(t("ume.cli.deleteConfirm"))) return;
try { try {
await apiDelete(`/v1/cli/profiles/${form.id}`); await apiDelete(`/v1/cli/profiles/${form.id}`);
@ -322,25 +324,31 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
} catch (e) { } catch (e) {
showError(e instanceof Error ? e.message : String(e)); showError(e instanceof Error ? e.message : String(e));
} }
})();
}} }}
> >
{t("managedNe.delete")} {t("managedNe.delete")}
</button> </Button>
) : null} ) : null}
</div> </div>
<div className={embedded ? undefined : "panel"} style={{ marginTop: 20 }}> <div className={embedded ? undefined : "panel"} style={{ marginTop: 20 }}>
<h3 className={embedded ? "card__section-title" : undefined}>{t("ume.cli.connectTestTitle")}</h3> <h3 className={embedded ? "card__section-title" : undefined}>{t("ume.cli.connectTestTitle")}</h3>
<div className="cli-connect-test-row"> <div className="cli-connect-test-row">
<input <Input
value={sampleUmeNeId} value={sampleUmeNeId}
onChange={(e) => setSampleUmeNeId(e.target.value)} onChange={(e) => setSampleUmeNeId(e.target.value)}
placeholder={t("ume.cli.sampleNePh")} placeholder={t("ume.cli.sampleNePh")}
/> />
<button type="button" onClick={() => connectTestMutation.mutate()} disabled={connectTestMutation.isPending}> <Button
size="sm"
variant="secondary"
isDisabled={connectTestMutation.isPending}
onPress={() => connectTestMutation.mutate()}
>
{connectTestMutation.isPending || overrideTesting {connectTestMutation.isPending || overrideTesting
? t("managedNe.connect.running") ? t("managedNe.connect.running")
: t("managedNe.connect.run")} : t("managedNe.connect.run")}
</button> </Button>
</div> </div>
{testNeId ? ( {testNeId ? (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
@ -385,10 +393,10 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
<span className="form-label">{t("ume.cli.existingProfiles")}</span> <span className="form-label">{t("ume.cli.existingProfiles")}</span>
<div className="actions-row actions-row--inline"> <div className="actions-row actions-row--inline">
{(profilesQuery.data?.items || []).map((p) => ( {(profilesQuery.data?.items || []).map((p) => (
<button key={p.id} type="button" className="link-btn" onClick={() => setForm(profileToForm(p))}> <Button key={p.id} size="sm" variant="ghost" onPress={() => setForm(profileToForm(p))}>
{p.name} {p.name}
{p.is_default ? ` (${t("ume.cli.default")})` : ""} {p.is_default ? ` (${t("ume.cli.default")})` : ""}
</button> </Button>
))} ))}
</div> </div>
</div> </div>

View file

@ -1,3 +1,4 @@
import { Button, Input } from "@heroui/react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
@ -1031,9 +1032,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}} }}
/> />
<div className="webcrt-paste-bridge__actions"> <div className="webcrt-paste-bridge__actions">
<button <Button
type="button" size="sm"
onClick={() => { variant="primary"
onPress={() => {
const text = pasteBridgeRef.current?.value || ""; const text = pasteBridgeRef.current?.value || "";
setPasteBridgeOpen(false); setPasteBridgeOpen(false);
if (text) sendStdinThrottled(text); if (text) sendStdinThrottled(text);
@ -1041,23 +1043,24 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}} }}
> >
{t("webcrt.term.paste")} {t("webcrt.term.paste")}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => { variant="tertiary"
onPress={() => {
setPasteBridgeOpen(false); setPasteBridgeOpen(false);
focusTerminal(); focusTerminal();
}} }}
> >
{t("webcrt.term.pasteBridgeCancel")} {t("webcrt.term.pasteBridgeCancel")}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
) : null} ) : null}
{findOpen ? ( {findOpen ? (
<div className="webcrt-find"> <div className="webcrt-find">
<input <Input
ref={findInputRef} ref={findInputRef}
type="search" type="search"
value={findQuery} value={findQuery}
@ -1088,11 +1091,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
} }
}} }}
/> />
<button <Button
type="button" size="sm"
variant="secondary"
aria-label={t("webcrt.term.findPrev")} aria-label={t("webcrt.term.findPrev")}
title={t("webcrt.term.findPrev")} onPress={() => {
onClick={() => {
const term = termRef.current; const term = termRef.current;
if (!term || !findQuery) return; if (!term || !findQuery) return;
const hit = findBufferLine(term, findQuery, Math.max(0, findLineRef.current) - 1, -1); const hit = findBufferLine(term, findQuery, Math.max(0, findLineRef.current) - 1, -1);
@ -1103,12 +1106,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}} }}
> >
↑ ↑
</button> </Button>
<button <Button
type="button" size="sm"
variant="secondary"
aria-label={t("webcrt.term.findNext")} aria-label={t("webcrt.term.findNext")}
title={t("webcrt.term.findNext")} onPress={() => {
onClick={() => {
const term = termRef.current; const term = termRef.current;
if (!term || !findQuery) return; if (!term || !findQuery) return;
const hit = findBufferLine(term, findQuery, findLineRef.current + 1, 1); const hit = findBufferLine(term, findQuery, findLineRef.current + 1, 1);
@ -1119,18 +1122,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}} }}
> >
↓ ↓
</button> </Button>
<button <Button
type="button" size="sm"
variant="ghost"
aria-label={t("webcrt.term.findClose")} aria-label={t("webcrt.term.findClose")}
title={t("webcrt.term.findClose")} onPress={() => {
onClick={() => {
setFindOpen(false); setFindOpen(false);
focusTerminal(); focusTerminal();
}} }}
> >
× ×
</button> </Button>
</div> </div>
) : null} ) : null}
<div <div
@ -1147,22 +1150,23 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
style={{ left: ctxMenu.x, top: ctxMenu.y }} style={{ left: ctxMenu.x, top: ctxMenu.y }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
<button type="button" onClick={() => void copySelection()}> <Button size="sm" variant="ghost" onPress={() => void copySelection()}>
{t("webcrt.term.copy")} {t("webcrt.term.copy")}
</button> </Button>
<button type="button" onClick={() => void pasteFromClipboard()}> <Button size="sm" variant="ghost" onPress={() => void pasteFromClipboard()}>
{t("webcrt.term.paste")} {t("webcrt.term.paste")}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => { variant="ghost"
onPress={() => {
termRef.current?.clear(); termRef.current?.clear();
termRef.current?.write("\x1b[H\x1b[2J"); termRef.current?.write("\x1b[H\x1b[2J");
setCtxMenu(null); setCtxMenu(null);
}} }}
> >
{t("webcrt.term.clear")} {t("webcrt.term.clear")}
</button> </Button>
</div> </div>
) : null} ) : null}
</div> </div>

View file

@ -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 (
<Modal state={state}>
<Modal.Backdrop isDismissable={dismissible} className="app-heroui-modal-backdrop">
<Modal.Container
size={size}
placement="center"
className={`app-heroui-modal${className ? ` ${className}` : ""}`}
>
<Modal.Dialog className="app-heroui-modal__dialog">{children}</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
}
/** @deprecated Prefer AppModalShell — kept for topology imports. */
export function TopoModalShell(props: AppModalShellProps) {
return <AppModalShell {...props} />;
}

View file

@ -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<HTMLSelectElement>) {
return (
<label className={`ui-field${fullWidth ? " ui-field--full" : ""}${className ? ` ${className}` : ""}`}>
{label ? (
<Label isRequired={required} className="ui-field__label">
{label}
</Label>
) : null}
<select className="ui-field__select" required={required} {...props}>
{children}
</select>
{hint ? <span className="ui-field__hint">{hint}</span> : null}
</label>
);
}

View file

@ -0,0 +1 @@
export { AppModalShell, TopoModalShell, type AppModalShellProps } from "./AppModalShell";

View file

@ -59,8 +59,19 @@ export const queryKeys = {
linkStatus: string, linkStatus: string,
page: number, page: number,
levelMajor?: string, 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, fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const,
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const, topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
fabricSummary: ["fabricSummary"] as const, fabricSummary: ["fabricSummary"] as const,

View file

@ -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; 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]); return useMemo(() => ({ toast, showOk, showError, clear }), [toast, showOk, showError, clear]);
} }
function ToastViewport({ toast }: { toast: NonNullable<ToastState> }) {
if (typeof document === "undefined") return null;
return createPortal(
<div className={`app-toast app-toast--${toast.type}`} role="status" aria-live="polite">
{toast.text}
</div>,
document.body,
);
}
export function ToastProvider({ children, autoHideMs = 2600 }: { children: ReactNode; autoHideMs?: number }) { export function ToastProvider({ children, autoHideMs = 2600 }: { children: ReactNode; autoHideMs?: number }) {
const api = useToastState(autoHideMs); const api = useToastState(autoHideMs);
return ( return (
<ToastContext.Provider value={api}> <ToastContext.Provider value={api}>
{children} {children}
{api.toast ? <div className={`toast toast--${api.toast.type}`}>{api.toast.text}</div> : null} {api.toast ? <ToastViewport toast={api.toast} /> : null}
</ToastContext.Provider> </ToastContext.Provider>
); );
} }

View file

@ -562,7 +562,8 @@ const en = {
loginFailed: "Login failed", loginFailed: "Login failed",
logout: "Sign out", logout: "Sign out",
sessionsTitle: "Login sessions", 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.", sessionsEmpty: "No active sessions.",
revokeOtherSessions: "Revoke other sessions", revokeOtherSessions: "Revoke other sessions",
revokeSession: "Revoke", revokeSession: "Revoke",
@ -571,6 +572,7 @@ const en = {
sessionsRevokedOthers: "Revoked {{count}} other session(s)", sessionsRevokedOthers: "Revoked {{count}} other session(s)",
revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?", revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?",
colSession: "Session", colSession: "Session",
colClient: "Client",
colLastSeen: "Last seen", colLastSeen: "Last seen",
colCreated: "Created", colCreated: "Created",
usersTitle: "User management", usersTitle: "User management",
@ -603,7 +605,9 @@ const en = {
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key management", apiKeysTitle: "API Key management",
apiKeysHelp: 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", tokenName: "Name",
expiresIn: "Expiry", expiresIn: "Expiry",
expire7d: "7 days", expire7d: "7 days",
@ -1511,6 +1515,7 @@ const en = {
browserRegionsSub: "{{count}} item(s)", browserRegionsSub: "{{count}} item(s)",
browserEmptyMaps: "No root map under this root yet.", browserEmptyMaps: "No root map under this root yet.",
addNe: "Add NE", addNe: "Add NE",
more: "More",
backUp: "Back", backUp: "Back",
newRegion: "New root", newRegion: "New root",
newRegionName: "New root", newRegionName: "New root",

View file

@ -558,7 +558,8 @@ const zh = {
loginFailed: "登录失败", loginFailed: "登录失败",
logout: "退出", logout: "退出",
sessionsTitle: "登录会话", sessionsTitle: "登录会话",
sessionsHint: "管理当前账号在各浏览器/设备上的登录。踢掉会话后对方需重新登录。", sessionsHint:
"管理当前账号在各浏览器/设备上的登录。IP 经反代时会取真实客户端地址;同公网出口可用「客户端」列区分浏览器/系统。踢掉会话后对方需重新登录。",
sessionsEmpty: "当前没有活跃会话。", sessionsEmpty: "当前没有活跃会话。",
revokeOtherSessions: "踢掉其他会话", revokeOtherSessions: "踢掉其他会话",
revokeSession: "踢掉", revokeSession: "踢掉",
@ -567,6 +568,7 @@ const zh = {
sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话", sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话",
revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?", revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?",
colSession: "会话", colSession: "会话",
colClient: "客户端",
colLastSeen: "最近活动", colLastSeen: "最近活动",
colCreated: "创建时间", colCreated: "创建时间",
usersTitle: "用户管理", usersTitle: "用户管理",
@ -599,7 +601,9 @@ const zh = {
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key 管理", apiKeysTitle: "API Key 管理",
apiKeysHelp: 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: "名称", tokenName: "名称",
expiresIn: "有效期", expiresIn: "有效期",
expire7d: "7 天", expire7d: "7 天",
@ -1500,6 +1504,7 @@ const zh = {
browserRegionsSub: "共 {{count}} 个", browserRegionsSub: "共 {{count}} 个",
browserEmptyMaps: "此根下还没有根图。", browserEmptyMaps: "此根下还没有根图。",
addNe: "添加网元", addNe: "添加网元",
more: "更多",
backUp: "返回", backUp: "返回",
newRegion: "新建根", newRegion: "新建根",
newRegionName: "新根", newRegionName: "新根",

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button, Chip } from "@heroui/react";
import { AppsGridIcon } from "../components/AppsGridIcon"; import { AppsGridIcon } from "../components/AppsGridIcon";
import { HeaderMenu } from "../components/HeaderMenu"; import { HeaderMenu } from "../components/HeaderMenu";
import { getPageTitleKey, isWorkbenchPath } from "../config/modules"; import { getPageTitleKey, isWorkbenchPath } from "../config/modules";
@ -48,15 +49,16 @@ export function AppLayout({ children }: Props) {
{!onWorkbench ? ( {!onWorkbench ? (
<> <>
<span className="app-brand__sep" aria-hidden /> <span className="app-brand__sep" aria-hidden />
<button <Button
type="button" isIconOnly
size="sm"
variant="ghost"
className="app-brand__apps" className="app-brand__apps"
onClick={returnToWorkbench} onPress={returnToWorkbench}
title={t("workbench.backToWorkbench")}
aria-label={t("workbench.backToWorkbench")} aria-label={t("workbench.backToWorkbench")}
> >
<AppsGridIcon /> <AppsGridIcon />
</button> </Button>
<span className="app-brand__module-title">{pageTitle}</span> <span className="app-brand__module-title">{pageTitle}</span>
</> </>
) : null} ) : null}
@ -64,24 +66,26 @@ export function AppLayout({ children }: Props) {
<div className="app-brand__actions"> <div className="app-brand__actions">
{user ? ( {user ? (
<div className="app-brand__actions-group"> <div className="app-brand__actions-group">
<button <Chip
type="button" size="sm"
className={`conn-pill conn-pill--on-brand conn-pill--tasks conn-pill--${activeTasks > 0 ? "up" : "unknown"}`} color={activeTasks > 0 ? "success" : "default"}
title={t("layout.activeTasksHint")} variant="soft"
className="conn-pill conn-pill--on-brand conn-pill--tasks cursor-pointer"
onClick={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })} onClick={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })}
> >
{t("layout.activeTasks", { count: activeTasks })} <Chip.Label>{t("layout.activeTasks", { count: activeTasks })}</Chip.Label>
</button> </Chip>
<span className="conn-pill conn-pill--on-brand conn-pill--user" title={user.role}> <Chip size="sm" variant="soft" className="conn-pill conn-pill--on-brand conn-pill--user" title={user.role}>
{user.username} <Chip.Label>{user.username}</Chip.Label>
</span> </Chip>
<button <Button
type="button" size="sm"
variant="ghost"
className="header-menu__trigger header-menu__trigger--on-brand" className="header-menu__trigger header-menu__trigger--on-brand"
onClick={() => void logout()} onPress={() => void logout()}
> >
{t("auth.logout")} {t("auth.logout")}
</button> </Button>
<HeaderMenu /> <HeaderMenu />
</div> </div>
) : ( ) : (

View file

@ -1,6 +1,9 @@
import { Button, Checkbox, Input, Modal } from "@heroui/react";
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { AppModalShell } from "../components/ui/AppModalShell";
import { FieldSelect } from "../components/ui/FieldSelect";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import { apiDelete, apiGet, apiPatch, apiPost } from "../services/api"; import { apiDelete, apiGet, apiPatch, apiPost } from "../services/api";
@ -86,7 +89,8 @@ export function ApiTokensPage() {
const tokensQuery = useQuery({ const tokensQuery = useQuery({
queryKey: ["apiTokens"], queryKey: ["apiTokens"],
queryFn: () => apiGet<{ items: TokenRow[] }>("/v1/api-tokens"), queryFn: () =>
apiGet<{ items: TokenRow[]; active_count?: number; max_count?: number }>("/v1/api-tokens"),
enabled: ready, enabled: ready,
}); });
@ -98,6 +102,9 @@ export function ApiTokensPage() {
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]); const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.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(() => { const availableForCreate = useMemo(() => {
if (ownerUserId) { if (ownerUserId) {
@ -203,40 +210,51 @@ export function ApiTokensPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2 className="token-page__title"> <h2 className="token-page__title">
{t("auth.apiKeysTitle")} {t("auth.apiKeysTitle")}
<span className="help-q" tabIndex={0} aria-label={t("auth.apiKeysHelp")}> <span className="help-q" tabIndex={0} aria-label={t("auth.apiKeysHelp", { max: tokenMax || 20 })}>
? ?
<span className="help-q__tip" role="tooltip"> <span className="help-q__tip" role="tooltip">
{t("auth.apiKeysHelp")} {t("auth.apiKeysHelp", { max: tokenMax || 20 })}
</span> </span>
</span> </span>
</h2> </h2>
{tokenMax > 0 ? (
<span className="muted" style={{ fontSize: 13 }}>
{t("auth.tokenQuota", { active: tokenActive, max: tokenMax })}
</span>
) : null}
</div> </div>
<div className="pt-list"> <div className="pt-list">
{atTokenLimit ? (
<p className="panel__hint panel__hint--live">{t("auth.tokenLimitReached", { max: tokenMax })}</p>
) : null}
<form className="token-create" onSubmit={onCreate}> <form className="token-create" onSubmit={onCreate}>
<div className="filter-inline"> <div className="filter-inline">
<input <Input
placeholder={t("auth.tokenName")} placeholder={t("auth.tokenName")}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
isDisabled={atTokenLimit}
/> />
<select <FieldSelect
value={expiresInDays}
onChange={(e) => setExpiresInDays(Number(e.target.value))}
aria-label={t("auth.expiresIn")} aria-label={t("auth.expiresIn")}
value={String(expiresInDays)}
onChange={(e) => setExpiresInDays(Number(e.target.value))}
disabled={atTokenLimit}
> >
{EXPIRY_OPTIONS.map((opt) => ( {EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{t(opt.labelKey)} {t(opt.labelKey)}
</option> </option>
))} ))}
</select> </FieldSelect>
{isAdmin ? ( {isAdmin ? (
<select <FieldSelect
aria-label={t("auth.tokenOwner")}
value={ownerUserId} value={ownerUserId}
onChange={(e) => setOwnerUserId(e.target.value)} onChange={(e) => setOwnerUserId(e.target.value)}
aria-label={t("auth.tokenOwner")} disabled={atTokenLimit}
> >
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option> <option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
{users {users
@ -246,11 +264,16 @@ export function ApiTokensPage() {
{u.username} ({u.role}) {u.username} ({u.role})
</option> </option>
))} ))}
</select> </FieldSelect>
) : null} ) : null}
<button type="submit" disabled={createMut.isPending}> <Button
type="submit"
size="sm"
variant="primary"
isDisabled={createMut.isPending || atTokenLimit}
>
{t("auth.createToken")} {t("auth.createToken")}
</button> </Button>
</div> </div>
</form> </form>
@ -260,12 +283,12 @@ export function ApiTokensPage() {
{t("auth.tokenOnceHint")} {t("auth.tokenOnceHint")}
</div> </div>
<code>{createdPlain}</code> <code>{createdPlain}</code>
<button type="button" onClick={() => void copyToken()}> <Button size="sm" variant="primary" onPress={() => void copyToken()}>
{t("auth.copyToken")} {t("auth.copyToken")}
</button> </Button>
<button type="button" onClick={dismissCreatedPlain}> <Button size="sm" variant="tertiary" onPress={dismissCreatedPlain}>
{t("common.close")} {t("common.close")}
</button> </Button>
</div> </div>
) : null} ) : null}
@ -318,24 +341,24 @@ export function ApiTokensPage() {
</td> </td>
<td> <td>
<div className="btn-row pt-list-actions table-actions"> <div className="btn-row pt-list-actions table-actions">
<button <Button
type="button" size="sm"
className="btn--ghost" variant="ghost"
disabled={row.revoked || row.expired} isDisabled={row.revoked || row.expired}
onClick={() => startEdit(row)} onPress={() => startEdit(row)}
> >
{t("auth.editScopes")} {t("auth.editScopes")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn--danger" variant="danger"
disabled={row.revoked || revokeMut.isPending} isDisabled={row.revoked || revokeMut.isPending}
onClick={() => { onPress={() => {
if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id); if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id);
}} }}
> >
{t("auth.revokeToken")} {t("auth.revokeToken")}
</button> </Button>
</div> </div>
</td> </td>
</tr> </tr>
@ -347,59 +370,52 @@ export function ApiTokensPage() {
</div> </div>
</section> </section>
<AppModalShell open={Boolean(editingRow)} onClose={() => setEditingRow(null)} size="md">
<Modal.Header>
<Modal.Heading>{t("auth.editScopes")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{editingRow ? ( {editingRow ? (
<div <p className="text-sm text-muted">
className="token-scopes-modal"
role="dialog"
aria-modal="true"
aria-label={t("auth.editScopes")}
>
<div className="token-scopes-modal__backdrop" onClick={() => setEditingRow(null)} />
<div className="token-scopes-modal__panel">
<div className="token-scopes-modal__head">
<div>
<h3>{t("auth.editScopes")}</h3>
<p className="panel__hint" style={{ margin: "4px 0 0" }}>
{editingRow.name} {editingRow.name}
{editingRow.username ? ` · ${editingRow.username}` : ""} {editingRow.username ? ` · ${editingRow.username}` : ""}
</p> </p>
</div> ) : null}
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}>
{t("common.cancel")}
</button>
</div>
<ul className="token-scopes-modal__list"> <ul className="token-scopes-modal__list">
{ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => ( {ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => (
<li key={scope}> <li key={scope}>
<label className="token-scopes-modal__row"> <Checkbox
<input isSelected={editScopes.includes(scope)}
type="checkbox"
checked={editScopes.includes(scope)}
onChange={() => toggleEditScope(scope)} onChange={() => toggleEditScope(scope)}
/> >
<span className="token-scopes-modal__label">{t(SCOPE_LABEL_KEYS[scope])}</span> <Checkbox.Control>
</label> <Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>{t(SCOPE_LABEL_KEYS[scope])}</Checkbox.Content>
</Checkbox>
</li> </li>
))} ))}
{!editAvailable.length ? ( {!editAvailable.length ? (
<li className="muted">{t("auth.scopesNoneAvailable")}</li> <li className="muted">{t("auth.scopesNoneAvailable")}</li>
) : null} ) : null}
</ul> </ul>
<div className="token-scopes-modal__foot"> </Modal.Body>
<button <Modal.Footer>
type="button" <Button
disabled={updateMut.isPending || editScopes.length === 0} variant="primary"
onClick={() => updateMut.mutate({ id: editingRow.id, scopes: editScopes })} isDisabled={updateMut.isPending || editScopes.length === 0 || !editingRow}
onPress={() => {
if (editingRow) updateMut.mutate({ id: editingRow.id, scopes: editScopes });
}}
> >
{t("auth.saveScopes")} {t("auth.saveScopes")}
</button> </Button>
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}> <Button variant="tertiary" onPress={() => setEditingRow(null)}>
{t("common.cancel")} {t("common.cancel")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null}
</div> </div>
); );
} }

View file

@ -1,3 +1,4 @@
import { Button, Input } from "@heroui/react";
import { Fragment, useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
@ -132,7 +133,7 @@ export function AuditPage() {
queryFn: () => { queryFn: () => {
const p = new URLSearchParams(); const p = new URLSearchParams();
p.set("page", String(page)); p.set("page", String(page));
p.set("page_size", "50"); p.set("page_size", "10");
p.set("exclude_noise", excludeNoise ? "true" : "false"); p.set("exclude_noise", excludeNoise ? "true" : "false");
if (username.trim()) p.set("username", username.trim()); if (username.trim()) p.set("username", username.trim());
if (effectiveAction) p.set("action", effectiveAction); if (effectiveAction) p.set("action", effectiveAction);
@ -145,7 +146,7 @@ export function AuditPage() {
const items = useMemo(() => query.data?.items || [], [query.data]); const items = useMemo(() => query.data?.items || [], [query.data]);
const total = query.data?.total || 0; 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 hasFilters = Boolean(username.trim() || action.trim() || quick !== "business");
const setQuickFilter = (next: QuickFilter) => { const setQuickFilter = (next: QuickFilter) => {
@ -174,20 +175,20 @@ export function AuditPage() {
["all", "audit.filterAll"], ["all", "audit.filterAll"],
] as const ] as const
).map(([value, labelKey]) => ( ).map(([value, labelKey]) => (
<button <Button
key={value} key={value}
type="button" size="sm"
className={quick === value && !action.trim() ? "is-active" : undefined} variant={quick === value && !action.trim() ? "primary" : "secondary"}
onClick={() => setQuickFilter(value)} onPress={() => setQuickFilter(value)}
> >
{t(labelKey)} {t(labelKey)}
</button> </Button>
))} ))}
</div> </div>
<div className="filter-inline"> <div className="filter-inline">
{isAdmin ? ( {isAdmin ? (
<input <Input
placeholder={t("auth.filterUsername")} placeholder={t("auth.filterUsername")}
value={username} value={username}
onChange={(e) => { onChange={(e) => {
@ -196,7 +197,7 @@ export function AuditPage() {
}} }}
/> />
) : null} ) : null}
<input <Input
placeholder={t("auth.filterAction")} placeholder={t("auth.filterAction")}
value={action} value={action}
onChange={(e) => { onChange={(e) => {
@ -205,13 +206,14 @@ export function AuditPage() {
if (e.target.value.trim()) setQuick("all"); if (e.target.value.trim()) setQuick("all");
}} }}
/> />
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}> <Button size="sm" variant="secondary" onPress={() => void query.refetch()} isDisabled={query.isFetching}>
{query.isFetching ? t("common.refreshing") : t("common.refresh")} {query.isFetching ? t("common.refreshing") : t("common.refresh")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={!hasFilters} variant="tertiary"
onClick={() => { isDisabled={!hasFilters}
onPress={() => {
setUsername(""); setUsername("");
setAction(""); setAction("");
setQuick("business"); setQuick("business");
@ -219,7 +221,7 @@ export function AuditPage() {
}} }}
> >
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
</div> </div>
{query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -266,13 +268,14 @@ export function AuditPage() {
</td> </td>
<td className="pt-list-num">{row.client_ip || t("common.empty")}</td> <td className="pt-list-num">{row.client_ip || t("common.empty")}</td>
<td> <td>
<button <Button
type="button" size="sm"
variant="ghost"
className="linkish" className="linkish"
onClick={() => setExpanded(open ? null : row.id)} onPress={() => setExpanded(open ? null : row.id)}
> >
{open ? t("audit.hideDetail") : t("audit.showDetail")} {open ? t("audit.hideDetail") : t("audit.showDetail")}
</button> </Button>
</td> </td>
</tr> </tr>
{open ? ( {open ? (
@ -304,16 +307,22 @@ export function AuditPage() {
})} })}
</span> </span>
<div className="btn-row"> <div className="btn-row">
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}> <Button
size="sm"
variant="secondary"
isDisabled={page <= 1}
onPress={() => setPage((p) => Math.max(1, p - 1))}
>
{t("common.prevPage")} {t("common.prevPage")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={page >= pages} variant="secondary"
onClick={() => setPage((p) => Math.min(pages, p + 1))} isDisabled={page >= pages}
onPress={() => setPage((p) => Math.min(pages, p + 1))}
> >
{t("common.nextPage")} {t("common.nextPage")}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,7 +1,9 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { ListPager } from "../components/ListPager"; import { ListPager } from "../components/ListPager";
import { AppModalShell } from "../components/ui/AppModalShell";
import { import {
createNeCollection, createNeCollection,
deleteCollectionJob, deleteCollectionJob,
@ -29,8 +31,8 @@ import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
const POLL_MS = 2000; const POLL_MS = 2000;
const ELIGIBLE_PAGE_SIZE = 20; const ELIGIBLE_PAGE_SIZE = 10;
const POLICY_TARGET_PAGE_SIZE = 20; const POLICY_TARGET_PAGE_SIZE = 10;
const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const; const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const;
const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const RUN_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 [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1); const [nePage, setNePage] = useState(1);
const [jobPage, setJobPage] = useState(1); const [jobPage, setJobPage] = useState(1);
const [jobPageSize, setJobPageSize] = useState(20); const [jobPageSize, setJobPageSize] = useState(10);
const [jobStatus, setJobStatus] = useState(""); const [jobStatus, setJobStatus] = useState("");
const [jobKeyword, setJobKeyword] = useState(""); const [jobKeyword, setJobKeyword] = useState("");
const [exportingJobs, setExportingJobs] = useState(false); const [exportingJobs, setExportingJobs] = useState(false);
@ -468,35 +470,42 @@ export function CollectPage() {
) : null} ) : null}
</div> </div>
<div className="btn-row"> <div className="btn-row">
<button <Button
type="button" size="sm"
disabled={exportingJobs || jobTotal === 0} variant="secondary"
onClick={() => void exportJobsCsv()} isDisabled={exportingJobs || jobTotal === 0}
onPress={() => void exportJobsCsv()}
> >
{exportingJobs ? t("common.exporting") : t("common.exportCsv")} {exportingJobs ? t("common.exporting") : t("common.exportCsv")}
</button> </Button>
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}> <Button
size="sm"
variant="secondary"
isDisabled={jobsQuery.isFetching || dashQuery.isFetching}
onPress={refreshAll}
>
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")} {jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn-primary" variant="primary"
disabled={startFromPolicyMutation.isPending || jobActive} isDisabled={startFromPolicyMutation.isPending || jobActive}
onClick={() => startFromPolicyMutation.mutate()} onPress={() => startFromPolicyMutation.mutate()}
> >
{startFromPolicyMutation.isPending ? t("collect.form.starting") : t("collect.collectNow")} {startFromPolicyMutation.isPending ? t("collect.form.starting") : t("collect.collectNow")}
</button> </Button>
<button type="button" onClick={() => setCreateOpen(true)}> <Button size="sm" variant="secondary" onPress={() => setCreateOpen(true)}>
{t("collect.create.expand")} {t("collect.create.expand")}
</button> </Button>
{running?.status === "running" || running?.status === "pending" ? ( {running?.status === "running" || running?.status === "pending" ? (
<button <Button
type="button" size="sm"
onClick={() => pauseMutation.mutate(running.id)} variant="secondary"
disabled={pauseMutation.isPending} isDisabled={pauseMutation.isPending}
onPress={() => pauseMutation.mutate(running.id)}
> >
{t("collect.jobs.pause")} {t("collect.jobs.pause")}
</button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>
@ -600,14 +609,13 @@ export function CollectPage() {
<option value="selected">{t("collect.scopeSelected")}</option> <option value="selected">{t("collect.scopeSelected")}</option>
</select> </select>
</label> </label>
<button <Button
type="button" variant="primary"
className="btn-primary" isDisabled={savePolicyMutation.isPending}
disabled={savePolicyMutation.isPending} onPress={() => savePolicyMutation.mutate()}
onClick={() => savePolicyMutation.mutate()}
> >
{t("collect.savePolicy")} {t("collect.savePolicy")}
</button> </Button>
</div> </div>
<div className="form-grid form-grid--single" style={{ marginTop: 12 }}> <div className="form-grid form-grid--single" style={{ marginTop: 12 }}>
@ -726,17 +734,18 @@ export function CollectPage() {
</option> </option>
))} ))}
</select> </select>
<button <Button
type="button" size="sm"
disabled={!hasJobFilters} variant="tertiary"
onClick={() => { isDisabled={!hasJobFilters}
onPress={() => {
setJobKeyword(""); setJobKeyword("");
setJobStatus(""); setJobStatus("");
setJobPage(1); setJobPage(1);
}} }}
> >
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
</div> </div>
{jobsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {jobsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -795,32 +804,24 @@ export function CollectPage() {
</div> </div>
</section> </section>
{createOpen ? ( <AppModalShell
<div className="modal-backdrop" role="presentation" onClick={() => setCreateOpen(false)}> open={createOpen}
<div onClose={() => setCreateOpen(false)}
className="modal modal--wide ops-detail-modal ops-detail-modal--xl" dismissible={!createMutation.isPending}
role="dialog" size="lg"
aria-modal="true" className="ops-detail-modal"
aria-label={t("collect.create.title")}
onClick={(e) => e.stopPropagation()}
> >
<div className="ops-detail-modal__head"> <Modal.Header>
<div className="ops-detail-modal__title"> <Modal.Heading>{t("collect.create.title")}</Modal.Heading>
<h3>{t("collect.create.title")}</h3> <Modal.CloseTrigger isDisabled={createMutation.isPending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">{t("collect.create.hint")}</p> <p className="muted">{t("collect.create.hint")}</p>
</div> <div className="collect-create-modal">
<div className="btn-row ops-detail-modal__actions">
<button type="button" onClick={() => setCreateOpen(false)}>
{t("networkConfigs.close")}
</button>
</div>
</div>
<div className="ops-detail-modal__scroll ops-detail-modal__scroll--pad collect-create-modal">
<div className="form-grid form-grid--single"> <div className="form-grid form-grid--single">
<label className="form-grid__full"> <label className="form-grid__full">
{t("collect.form.jobTitle")} {t("collect.form.jobTitle")}
<input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder={t("collect.form.jobTitlePh")} placeholder={t("collect.form.jobTitlePh")}
@ -846,9 +847,9 @@ export function CollectPage() {
{t("collect.create.selectedCount", { count: selectedList.length })} {t("collect.create.selectedCount", { count: selectedList.length })}
</span> </span>
{selectedList.length > 0 ? ( {selectedList.length > 0 ? (
<button type="button" className="link-btn" onClick={clearSelected}> <Button size="sm" variant="ghost" className="link-btn" onPress={clearSelected}>
{t("collect.create.clearSelected")} {t("collect.create.clearSelected")}
</button> </Button>
) : null} ) : null}
</div> </div>
{selectedList.length === 0 ? ( {selectedList.length === 0 ? (
@ -868,9 +869,9 @@ export function CollectPage() {
{row.vendor ? ` · ${row.vendor}` : ""} {row.vendor ? ` · ${row.vendor}` : ""}
</span> </span>
</span> </span>
<button type="button" className="link-btn" onClick={() => removeNe(key)}> <Button size="sm" variant="ghost" className="link-btn" onPress={() => removeNe(key)}>
{t("collect.create.remove")} {t("collect.create.remove")}
</button> </Button>
</div> </div>
); );
})} })}
@ -885,11 +886,12 @@ export function CollectPage() {
<p className="panel__hint">{t("collect.create.pickHint")}</p> <p className="panel__hint">{t("collect.create.pickHint")}</p>
</div> </div>
<div className="table-actions"> <div className="table-actions">
<button <Button
type="button" size="sm"
variant="ghost"
className="link-btn" className="link-btn"
disabled={batchPickCount === 0} isDisabled={batchPickCount === 0}
onClick={() => { onPress={() => {
const rows = eligibleItems.filter( const rows = eligibleItems.filter(
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)], (x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
); );
@ -898,18 +900,19 @@ export function CollectPage() {
}} }}
> >
{t("collect.create.addBatch", { count: batchPickCount })} {t("collect.create.addBatch", { count: batchPickCount })}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => eligibleQuery.refetch()} variant="secondary"
disabled={eligibleQuery.isFetching} isDisabled={eligibleQuery.isFetching}
onPress={() => void eligibleQuery.refetch()}
> >
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")} {eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button> </Button>
</div> </div>
</div> </div>
<div className="ops-detail-modal__toolbar filter-inline"> <div className="ops-detail-modal__toolbar filter-inline">
<input <Input
type="search" type="search"
value={neKeyword} value={neKeyword}
placeholder={t("collect.create.filterKeywordPh")} placeholder={t("collect.create.filterKeywordPh")}
@ -979,17 +982,18 @@ export function CollectPage() {
<span className="conn-pill conn-pill--up">{row.connect_status}</span> <span className="conn-pill conn-pill--up">{row.connect_status}</span>
</td> </td>
<td className="table-actions"> <td className="table-actions">
<button <Button
type="button" size="sm"
variant="ghost"
className="link-btn" className="link-btn"
disabled={picked} isDisabled={picked}
onClick={() => { onPress={() => {
addNe(row); addNe(row);
setPickSelectedIds((prev) => prev.filter((id) => id !== key)); setPickSelectedIds((prev) => prev.filter((id) => id !== key));
}} }}
> >
{picked ? t("collect.create.added") : t("collect.create.add")} {picked ? t("collect.create.added") : t("collect.create.add")}
</button> </Button>
</td> </td>
</tr> </tr>
); );
@ -1014,70 +1018,66 @@ export function CollectPage() {
})} })}
</span> </span>
<div className="btn-row"> <div className="btn-row">
<button <Button
type="button" size="sm"
disabled={nePage <= 1} variant="ghost"
onClick={() => { isDisabled={nePage <= 1}
onPress={() => {
setNePage(nePage - 1); setNePage(nePage - 1);
setPickSelectedIds([]); setPickSelectedIds([]);
}} }}
> >
{t("common.prevPage")} {t("common.prevPage")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={nePage >= nePages} variant="ghost"
onClick={() => { isDisabled={nePage >= nePages}
onPress={() => {
setNePage(nePage + 1); setNePage(nePage + 1);
setPickSelectedIds([]); setPickSelectedIds([]);
}} }}
> >
{t("common.nextPage")} {t("common.nextPage")}
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
</Modal.Body>
<div className="ops-detail-modal__foot"> <Modal.Footer>
<span className="muted"> <span className="muted">
{t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })} {t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
</span> </span>
<div className="btn-row"> <Button
<button type="button" onClick={() => setCreateOpen(false)}> variant="tertiary"
isDisabled={createMutation.isPending}
onPress={() => setCreateOpen(false)}
>
{t("networkConfigs.close")} {t("networkConfigs.close")}
</button> </Button>
<button <Button
type="button" variant="primary"
className="btn-primary" isDisabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
disabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending} onPress={() => createMutation.mutate()}
onClick={() => createMutation.mutate()}
> >
{createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")} {createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
</div>
) : null}
{expandedJobId ? ( <AppModalShell
<div open={Boolean(expandedJobId)}
className="modal-backdrop" onClose={() => setExpandedJobId("")}
role="presentation" size="lg"
onClick={() => setExpandedJobId("")} className="ops-detail-modal"
> >
<div <Modal.Header>
className="modal modal--wide ops-detail-modal ops-detail-modal--xl" <Modal.Heading>{t("collect.jobs.detailTitle")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger />
aria-modal="true" </Modal.Header>
aria-label={t("collect.jobs.detailTitle")} <Modal.Body className="flex flex-col gap-3">
onClick={(e) => e.stopPropagation()}
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("collect.jobs.detailTitle")}</h3>
<p className="muted"> <p className="muted">
{(() => { {(() => {
const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job; const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job;
@ -1086,13 +1086,6 @@ export function CollectPage() {
: expandedJobId.slice(0, 8); : expandedJobId.slice(0, 8);
})()} })()}
</p> </p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button type="button" onClick={() => setExpandedJobId("")}>
{t("networkConfigs.close")}
</button>
</div>
</div>
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<JobRunsPanel <JobRunsPanel
jobId={expandedJobId} jobId={expandedJobId}
@ -1110,9 +1103,13 @@ export function CollectPage() {
onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)} onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)}
retryPending={actionPending} retryPending={actionPending}
/> />
</div> </Modal.Body>
</div> <Modal.Footer>
) : null} <Button variant="tertiary" onPress={() => setExpandedJobId("")}>
{t("networkConfigs.close")}
</Button>
</Modal.Footer>
</AppModalShell>
</div> </div>
); );
} }
@ -1156,36 +1153,49 @@ function JobRow({
<td>{formatSystemTime(job.created_at)}</td> <td>{formatSystemTime(job.created_at)}</td>
<td>{job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")}</td> <td>{job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")}</td>
<td className="table-actions"> <td className="table-actions">
<button type="button" className="link-btn" onClick={onOpenDetail}> <Button size="sm" variant="ghost" className="link-btn" onPress={onOpenDetail}>
{t("collect.jobs.expand")} {t("collect.jobs.expand")}
</button> </Button>
{canPause ? ( {canPause ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onPause}> <Button size="sm" variant="ghost" className="link-btn" isDisabled={actionPending} onPress={onPause}>
{t("collect.jobs.pause")} {t("collect.jobs.pause")}
</button> </Button>
) : null} ) : null}
{canStart ? ( {canStart ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onStart}> <Button size="sm" variant="ghost" className="link-btn" isDisabled={actionPending} onPress={onStart}>
{startPending ? t("collect.jobs.starting") : t("collect.jobs.start")} {startPending ? t("collect.jobs.starting") : t("collect.jobs.start")}
</button> </Button>
) : null} ) : null}
{canRetryFailed ? ( {canRetryFailed ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onRetryFailed}> <Button
{t("collect.jobs.retryFailed")} size="sm"
</button> variant="ghost"
) : null}
<button
type="button"
className="link-btn" className="link-btn"
disabled={actionPending || !hasResults} isDisabled={actionPending}
onClick={downloadResults} onPress={onRetryFailed}
>
{t("collect.jobs.retryFailed")}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
className="link-btn"
isDisabled={actionPending || !hasResults}
onPress={downloadResults}
> >
{t("collect.jobs.downloadResults")} {t("collect.jobs.downloadResults")}
</button> </Button>
{canDelete ? ( {canDelete ? (
<button type="button" className="link-btn link-btn--danger" disabled={actionPending} onClick={onDelete}> <Button
size="sm"
variant="danger"
className="link-btn link-btn--danger"
isDisabled={actionPending}
onPress={onDelete}
>
{t("collect.jobs.delete")} {t("collect.jobs.delete")}
</button> </Button>
) : null} ) : null}
</td> </td>
</tr> </tr>
@ -1212,7 +1222,7 @@ function JobRunsPanel({
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
const [runPage, setRunPage] = useState(1); const [runPage, setRunPage] = useState(1);
const [runPageSize, setRunPageSize] = useState(20); const [runPageSize, setRunPageSize] = useState(10);
const [runStatus, setRunStatus] = useState(""); const [runStatus, setRunStatus] = useState("");
const [runKeyword, setRunKeyword] = useState(""); const [runKeyword, setRunKeyword] = useState("");
const [exportingRuns, setExportingRuns] = useState(false); const [exportingRuns, setExportingRuns] = useState(false);
@ -1322,17 +1332,22 @@ function JobRunsPanel({
))} ))}
</select> </select>
{hasRunFilters ? ( {hasRunFilters ? (
<button type="button" onClick={clearRunFilters}> <Button size="sm" variant="tertiary" onPress={clearRunFilters}>
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
) : null} ) : null}
<button type="button" disabled={exportingRuns || runTotal === 0} onClick={() => void exportRunsCsv()}> <Button
size="sm"
variant="secondary"
isDisabled={exportingRuns || runTotal === 0}
onPress={() => void exportRunsCsv()}
>
{exportingRuns ? t("common.exporting") : t("common.exportCsv")} {exportingRuns ? t("common.exporting") : t("common.exportCsv")}
</button> </Button>
{jobStatus !== "running" && failCount > 0 ? ( {jobStatus !== "running" && failCount > 0 ? (
<button type="button" disabled={retryPending} onClick={onRetryFailed}> <Button size="sm" variant="secondary" isDisabled={retryPending} onPress={onRetryFailed}>
{t("collect.jobs.retryFailed")} {t("collect.jobs.retryFailed")}
</button> </Button>
) : null} ) : null}
</div> </div>
{runsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {runsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}

View file

@ -1,7 +1,10 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { ListPager } from "../components/ListPager"; import { ListPager } from "../components/ListPager";
import { AppModalShell } from "../components/ui/AppModalShell";
import { FieldSelect } from "../components/ui/FieldSelect";
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
@ -24,7 +27,7 @@ import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
const POLL_MS = 2500; const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 20; const TARGET_PAGE_SIZE = 10;
export function ConfigSyncPage() { export function ConfigSyncPage() {
const { t } = useI18n(); const { t } = useI18n();
@ -33,13 +36,13 @@ export function ConfigSyncPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [cyclePage, setCyclePage] = useState(1); const [cyclePage, setCyclePage] = useState(1);
const [cyclePageSize, setCyclePageSize] = useState(20); const [cyclePageSize, setCyclePageSize] = useState(10);
const [cycleStatus, setCycleStatus] = useState(""); const [cycleStatus, setCycleStatus] = useState("");
const [cycleKeyword, setCycleKeyword] = useState(""); const [cycleKeyword, setCycleKeyword] = useState("");
const [exportingCycles, setExportingCycles] = useState(false); const [exportingCycles, setExportingCycles] = useState(false);
const [expandedCycleId, setExpandedCycleId] = useState(""); const [expandedCycleId, setExpandedCycleId] = useState("");
const [taskPage, setTaskPage] = useState(1); const [taskPage, setTaskPage] = useState(1);
const [taskPageSize, setTaskPageSize] = useState(20); const [taskPageSize, setTaskPageSize] = useState(10);
const [taskStatus, setTaskStatus] = useState(""); const [taskStatus, setTaskStatus] = useState("");
const [taskKeyword, setTaskKeyword] = useState(""); const [taskKeyword, setTaskKeyword] = useState("");
const [exportingTasks, setExportingTasks] = useState(false); const [exportingTasks, setExportingTasks] = useState(false);
@ -354,44 +357,61 @@ export function ConfigSyncPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("configSync.title")}</h2> <h2>{t("configSync.title")}</h2>
<div className="btn-row"> <div className="btn-row">
<button type="button" onClick={() => void refresh()} disabled={dashQuery.isFetching}> <Button
size="sm"
variant="secondary"
isDisabled={dashQuery.isFetching}
onPress={() => void refresh()}
>
{t("common.refresh")} {t("common.refresh")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn-primary" variant="primary"
disabled={Boolean(running) || startMut.isPending} isDisabled={Boolean(running) || startMut.isPending}
onClick={() => startMut.mutate("full")} onPress={() => startMut.mutate("full")}
> >
{t("configSync.syncNow")} {t("configSync.syncNow")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={Boolean(running) || startMut.isPending || !(last && last.fail_count > 0)} variant="secondary"
onClick={() => startMut.mutate("retry_failed")} isDisabled={Boolean(running) || startMut.isPending || !(last && last.fail_count > 0)}
onPress={() => startMut.mutate("retry_failed")}
> >
{t("configSync.retryFailed")} {t("configSync.retryFailed")}
</button> </Button>
{running?.status === "running" || running?.status === "pending" ? ( {running?.status === "running" || running?.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(running.id)} disabled={pauseMut.isPending}> <Button
size="sm"
variant="secondary"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(running.id)}
>
{t("configSync.pause")} {t("configSync.pause")}
</button> </Button>
) : null} ) : null}
{running?.status === "paused" ? ( {running?.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(running.id)} disabled={resumeMut.isPending}> <Button
size="sm"
variant="secondary"
isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(running.id)}
>
{t("configSync.resume")} {t("configSync.resume")}
</button> </Button>
) : null} ) : null}
{running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? ( {running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? (
<button <Button
type="button" size="sm"
onClick={() => { variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(running.id); if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(running.id);
}} }}
disabled={stopMut.isPending}
> >
{t("configSync.stop")} {t("configSync.stop")}
</button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>
@ -436,41 +456,41 @@ export function ConfigSyncPage() {
</label> </label>
<label className="config-sync-policy-field"> <label className="config-sync-policy-field">
<span>{t("configSync.intervalDays")}</span> <span>{t("configSync.intervalDays")}</span>
<input <Input
type="number" type="number"
min={1} min={1}
max={365} max={365}
value={intervalDays} value={String(intervalDays)}
onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))} onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
/> />
</label> </label>
<label className="config-sync-policy-field"> <label className="config-sync-policy-field">
<span>{t("configSync.concurrency")}</span> <span>{t("configSync.concurrency")}</span>
<input <Input
type="number" type="number"
min={1} min={1}
max={30} max={30}
value={concurrency} value={String(concurrency)}
onChange={(e) => setConcurrency(Math.max(1, Math.min(30, Number(e.target.value) || 1)))} onChange={(e) => setConcurrency(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
/> />
</label> </label>
<label className="config-sync-policy-field"> <label className="config-sync-policy-field">
<span>{t("configSync.historyKeep")}</span> <span>{t("configSync.historyKeep")}</span>
<input <Input
type="number" type="number"
min={0} min={0}
max={30} max={30}
value={historyKeep} value={String(historyKeep)}
onChange={(e) => setHistoryKeep(Math.max(0, Math.min(30, Number(e.target.value) || 0)))} onChange={(e) => setHistoryKeep(Math.max(0, Math.min(30, Number(e.target.value) || 0)))}
/> />
</label> </label>
<label className="config-sync-policy-field"> <label className="config-sync-policy-field">
<span>{t("configSync.cycleKeep")}</span> <span>{t("configSync.cycleKeep")}</span>
<input <Input
type="number" type="number"
min={0} min={0}
max={200} max={200}
value={cycleKeep} value={String(cycleKeep)}
onChange={(e) => setCycleKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))} onChange={(e) => setCycleKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))}
/> />
</label> </label>
@ -484,14 +504,13 @@ export function ConfigSyncPage() {
<option value="selected">{t("configSync.scopeSelected")}</option> <option value="selected">{t("configSync.scopeSelected")}</option>
</select> </select>
</label> </label>
<button <Button
type="button" variant="primary"
className="btn-primary" isDisabled={savePolicyMut.isPending}
disabled={savePolicyMut.isPending} onPress={() => savePolicyMut.mutate()}
onClick={() => savePolicyMut.mutate()}
> >
{t("configSync.savePolicy")} {t("configSync.savePolicy")}
</button> </Button>
</div> </div>
{scopeMode === "selected" ? ( {scopeMode === "selected" ? (
@ -540,9 +559,14 @@ export function ConfigSyncPage() {
</tbody> </tbody>
</table> </table>
<div className="pager"> <div className="pager">
<button type="button" disabled={targetPage <= 1} onClick={() => setTargetPage((p) => p - 1)}> <Button
size="sm"
variant="ghost"
isDisabled={targetPage <= 1}
onPress={() => setTargetPage((p) => p - 1)}
>
{t("common.prevPage")} {t("common.prevPage")}
</button> </Button>
<span className="muted"> <span className="muted">
{t("common.pagerMeta", { {t("common.pagerMeta", {
total: String(targetsQuery.data?.total ?? 0), total: String(targetsQuery.data?.total ?? 0),
@ -550,13 +574,14 @@ export function ConfigSyncPage() {
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)), pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
})} })}
</span> </span>
<button <Button
type="button" size="sm"
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)} variant="ghost"
onClick={() => setTargetPage((p) => p + 1)} isDisabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onPress={() => setTargetPage((p) => p + 1)}
> >
{t("common.nextPage")} {t("common.nextPage")}
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
@ -565,7 +590,7 @@ export function ConfigSyncPage() {
<h3>{t("configSync.cyclesTitle")}</h3> <h3>{t("configSync.cyclesTitle")}</h3>
<div className="pt-list"> <div className="pt-list">
<div className="filter-inline"> <div className="filter-inline">
<input <Input
value={cycleKeyword} value={cycleKeyword}
placeholder={t("configSync.keywordPh")} placeholder={t("configSync.keywordPh")}
onChange={(e) => { onChange={(e) => {
@ -573,7 +598,7 @@ export function ConfigSyncPage() {
setCyclePage(1); setCyclePage(1);
}} }}
/> />
<select <FieldSelect
value={cycleStatus} value={cycleStatus}
onChange={(e) => { onChange={(e) => {
setCycleStatus(e.target.value); setCycleStatus(e.target.value);
@ -588,25 +613,27 @@ export function ConfigSyncPage() {
<option value="completed">completed</option> <option value="completed">completed</option>
<option value="failed">failed</option> <option value="failed">failed</option>
<option value="stopped">stopped</option> <option value="stopped">stopped</option>
</select> </FieldSelect>
<button <Button
type="button" size="sm"
disabled={!hasCycleFilters} variant="tertiary"
onClick={() => { isDisabled={!hasCycleFilters}
onPress={() => {
setCycleKeyword(""); setCycleKeyword("");
setCycleStatus(""); setCycleStatus("");
setCyclePage(1); setCyclePage(1);
}} }}
> >
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={exportingCycles || cycleTotal === 0} variant="secondary"
onClick={() => void exportCyclesCsv()} isDisabled={exportingCycles || cycleTotal === 0}
onPress={() => void exportCyclesCsv()}
> >
{exportingCycles ? t("common.exporting") : t("common.exportCsv")} {exportingCycles ? t("common.exporting") : t("common.exportCsv")}
</button> </Button>
</div> </div>
{cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -630,13 +657,9 @@ export function ConfigSyncPage() {
{cycles.map((c) => ( {cycles.map((c) => (
<tr key={c.id}> <tr key={c.id}>
<td> <td>
<button <Button size="sm" variant="ghost" onPress={() => openCycleTasks(c.id)}>
type="button"
className="btn btn--sm btn--ghost"
onClick={() => openCycleTasks(c.id)}
>
{t("configSync.expand")} {t("configSync.expand")}
</button> </Button>
</td> </td>
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td> <td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
<td>{c.trigger_mode}</td> <td>{c.trigger_mode}</td>
@ -665,25 +688,36 @@ export function ConfigSyncPage() {
<td> <td>
<div className="btn-row"> <div className="btn-row">
{c.status === "running" || c.status === "pending" ? ( {c.status === "running" || c.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}> <Button
size="sm"
variant="secondary"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(c.id)}
>
{t("configSync.pause")} {t("configSync.pause")}
</button> </Button>
) : null} ) : null}
{c.status === "paused" ? ( {c.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}> <Button
size="sm"
variant="secondary"
isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(c.id)}
>
{t("configSync.resume")} {t("configSync.resume")}
</button> </Button>
) : null} ) : null}
{c.status === "running" || c.status === "paused" || c.status === "pending" ? ( {c.status === "running" || c.status === "paused" || c.status === "pending" ? (
<button <Button
type="button" size="sm"
onClick={() => { variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id); if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
}} }}
disabled={stopMut.isPending}
> >
{t("configSync.stop")} {t("configSync.stop")}
</button> </Button>
) : null} ) : null}
</div> </div>
</td> </td>
@ -704,7 +738,7 @@ export function ConfigSyncPage() {
pages={cyclePages} pages={cyclePages}
total={cycleTotal} total={cycleTotal}
pageSize={cyclePageSize} pageSize={cyclePageSize}
pageSizeOptions={[20, 50, 100]} pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setCyclePage} onPageChange={setCyclePage}
onPageSizeChange={(size) => { onPageSizeChange={(size) => {
setCyclePageSize(size); setCyclePageSize(size);
@ -714,23 +748,17 @@ export function ConfigSyncPage() {
/> />
</div> </div>
{expandedCycleId ? ( <AppModalShell
<div open={Boolean(expandedCycleId)}
className="modal-backdrop" onClose={() => setExpandedCycleId("")}
role="presentation" size="lg"
onClick={() => setExpandedCycleId("")}
> >
<div <Modal.Header>
className="modal modal--wide ops-detail-modal" <Modal.Heading>{t("configSync.tasksTitle")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger />
aria-modal="true" </Modal.Header>
aria-label={t("configSync.tasksTitle")} <Modal.Body className="flex flex-col gap-3">
onClick={(e) => e.stopPropagation()} <p className="text-sm text-muted">
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("configSync.tasksTitle")}</h3>
<p className="muted">
{expandedCycleId.slice(0, 8)} {expandedCycleId.slice(0, 8)}
{(() => { {(() => {
const c = cycles.find((x) => x.id === expandedCycleId); const c = cycles.find((x) => x.id === expandedCycleId);
@ -739,23 +767,8 @@ export function ConfigSyncPage() {
: ""; : "";
})()} })()}
</p> </p>
</div> <div className="filter-inline">
<div className="btn-row ops-detail-modal__actions"> <Input
<button
type="button"
disabled={exportingTasks || taskTotal === 0}
onClick={() => void exportTasksCsv()}
>
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" onClick={() => setExpandedCycleId("")}>
{t("networkConfigs.close")}
</button>
</div>
</div>
<div className="ops-detail-modal__toolbar filter-inline">
<input
value={taskKeyword} value={taskKeyword}
placeholder={t("configSync.taskKeywordPh")} placeholder={t("configSync.taskKeywordPh")}
onChange={(e) => { onChange={(e) => {
@ -763,7 +776,7 @@ export function ConfigSyncPage() {
setTaskPage(1); setTaskPage(1);
}} }}
/> />
<select <FieldSelect
value={taskStatus} value={taskStatus}
onChange={(e) => { onChange={(e) => {
setTaskStatus(e.target.value); setTaskStatus(e.target.value);
@ -776,7 +789,15 @@ export function ConfigSyncPage() {
<option value="success">success</option> <option value="success">success</option>
<option value="fail">fail</option> <option value="fail">fail</option>
<option value="skipped">skipped</option> <option value="skipped">skipped</option>
</select> </FieldSelect>
<Button
size="sm"
variant="secondary"
isDisabled={exportingTasks || taskTotal === 0}
onPress={() => void exportTasksCsv()}
>
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
</Button>
</div> </div>
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -784,7 +805,6 @@ export function ConfigSyncPage() {
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p> <p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
) : null} ) : null}
<div className="ops-detail-modal__scroll">
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
<thead> <thead>
@ -818,15 +838,13 @@ export function ConfigSyncPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div className="ops-detail-modal__foot">
<ListPager <ListPager
page={taskPage} page={taskPage}
pages={taskPages} pages={taskPages}
total={taskTotal} total={taskTotal}
pageSize={taskPageSize} pageSize={taskPageSize}
pageSizeOptions={[20, 50, 100, 200]} pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setTaskPage} onPageChange={setTaskPage}
onPageSizeChange={(size) => { onPageSizeChange={(size) => {
setTaskPageSize(size); setTaskPageSize(size);
@ -834,10 +852,13 @@ export function ConfigSyncPage() {
}} }}
disabled={tasksQuery.isLoading} disabled={tasksQuery.isLoading}
/> />
</div> </Modal.Body>
</div> <Modal.Footer>
</div> <Button variant="tertiary" onPress={() => setExpandedCycleId("")}>
) : null} {t("networkConfigs.close")}
</Button>
</Modal.Footer>
</AppModalShell>
</section> </section>
); );
} }

View file

@ -1,3 +1,4 @@
import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
@ -44,72 +45,79 @@ export function ForceChangePasswordPage() {
return ( return (
<LoginShell> <LoginShell>
<form className="login-card" onSubmit={(e) => void onSubmit(e)}> <Card className="login-card login-card--heroui" variant="transparent">
<div className="login-card__head"> <Card.Header className="login-card__head">
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1> <Card.Title className="login-card__title">{t("auth.forceChangeTitle")}</Card.Title>
<div className="login-card__brand" aria-label="NETX"> <div className="login-card__brand" aria-label="NETX">
NETX NETX
</div> </div>
</div> </Card.Header>
<Card.Content>
<p className="login-card__hint"> <p className="login-card__hint">
{t("auth.forceChangeHint", { user: user?.username || "admin" })} {t("auth.forceChangeHint", { user: user?.username || "admin" })}
</p> </p>
<label className="login-card__label"> <Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
<span className="login-card__sr">{t("auth.oldPassword")}</span> <TextField
<input fullWidth
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
autoFocus autoFocus
placeholder={t("auth.oldPassword")}
value={oldPassword} value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)} onChange={setOldPassword}
disabled={busy} isDisabled={busy}
required isRequired
/> >
</label> <Label className="login-card__sr">{t("auth.oldPassword")}</Label>
<label className="login-card__label"> <Input placeholder={t("auth.oldPassword")} />
<span className="login-card__sr">{t("auth.newPassword")}</span> </TextField>
<input <TextField
fullWidth
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder={t("auth.newPassword")}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={setNewPassword}
disabled={busy} isDisabled={busy}
required isRequired
minLength={8} minLength={8}
/> >
</label> <Label className="login-card__sr">{t("auth.newPassword")}</Label>
<label className="login-card__label"> <Input placeholder={t("auth.newPassword")} />
<span className="login-card__sr">{t("auth.confirmPassword")}</span> </TextField>
<input <TextField
fullWidth
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder={t("auth.confirmPassword")}
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={setConfirm}
disabled={busy} isDisabled={busy}
required isRequired
minLength={8} minLength={8}
/> >
</label> <Label className="login-card__sr">{t("auth.confirmPassword")}</Label>
<Input placeholder={t("auth.confirmPassword")} />
</TextField>
{error ? ( {error ? (
<div className="login-card__error" role="alert"> <Alert status="danger" className="login-card__error">
{error} <Alert.Content>
</div> <Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null} ) : null}
<button type="submit" className="login-card__submit" disabled={busy}> <Button type="submit" variant="primary" fullWidth isDisabled={busy}>
{busy ? t("auth.savingPassword") : t("auth.savePassword")} {busy ? t("auth.savingPassword") : t("auth.savePassword")}
</button> </Button>
<button <Button
type="button" type="button"
className="login-card__submit login-card__submit--ghost" variant="tertiary"
disabled={busy} fullWidth
onClick={() => void logout()} isDisabled={busy}
onPress={() => void logout()}
> >
{t("auth.logout")} {t("auth.logout")}
</button> </Button>
</form> </Form>
</Card.Content>
</Card>
</LoginShell> </LoginShell>
); );
} }

View file

@ -1,3 +1,4 @@
import { Alert, Button, Card, Form, Input, InputGroup, Label, TextField } from "@heroui/react";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { Navigate, useSearchParams } from "react-router-dom"; import { Navigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
@ -34,41 +35,49 @@ export function LoginPage() {
return ( return (
<LoginShell> <LoginShell>
<form className="login-card" onSubmit={(e) => void onSubmit(e)}> <Card className="login-card login-card--heroui" variant="transparent">
<div className="login-card__head"> <Card.Header className="login-card__head">
<h1 className="login-card__title">{t("auth.loginTitle")}</h1> <Card.Title className="login-card__title">{t("auth.loginTitle")}</Card.Title>
<div className="login-card__brand" aria-label="NETX"> <div className="login-card__brand" aria-label="NETX">
NETX NETX
</div> </div>
</div> </Card.Header>
<label className="login-card__label"> <Card.Content>
<span className="login-card__sr">{t("auth.username")}</span> <Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
<input <TextField
fullWidth
name="username"
value={username}
onChange={setUsername}
isDisabled={busy || !ready}
autoFocus autoFocus
autoComplete="username" autoComplete="username"
placeholder={t("auth.username")} >
value={username} <Label className="login-card__sr">{t("auth.username")}</Label>
onChange={(e) => setUsername(e.target.value)} <Input placeholder={t("auth.username")} />
disabled={busy || !ready} </TextField>
/>
</label> <TextField
<label className="login-card__label"> fullWidth
<span className="login-card__sr">{t("auth.password")}</span> name="password"
<span className="login-card__password">
<input
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder={t("auth.password")}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={setPassword}
disabled={busy || !ready} isDisabled={busy || !ready}
autoComplete="current-password"
>
<Label className="login-card__sr">{t("auth.password")}</Label>
<InputGroup fullWidth>
<InputGroup.Input
type={showPassword ? "text" : "password"}
placeholder={t("auth.password")}
/> />
<button <InputGroup.Suffix>
type="button" <Button
className="login-card__eye" isIconOnly
tabIndex={-1} size="sm"
variant="ghost"
aria-label={showPassword ? "Hide password" : "Show password"} aria-label={showPassword ? "Hide password" : "Show password"}
onClick={() => setShowPassword((v) => !v)} onPress={() => setShowPassword((v) => !v)}
> >
{showPassword ? ( {showPassword ? (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
@ -91,18 +100,31 @@ export function LoginPage() {
<circle cx="12" cy="12.8" r="3" stroke="currentColor" strokeWidth="1.75" /> <circle cx="12" cy="12.8" r="3" stroke="currentColor" strokeWidth="1.75" />
</svg> </svg>
)} )}
</button> </Button>
</span> </InputGroup.Suffix>
</label> </InputGroup>
</TextField>
{error ? ( {error ? (
<div className="login-card__error" role="alert"> <Alert status="danger" className="login-card__error">
{error} <Alert.Content>
</div> <Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null} ) : null}
<button type="submit" className="login-card__submit" disabled={busy || !ready || !password}>
<Button
type="submit"
variant="primary"
fullWidth
className="login-card__submit"
isDisabled={busy || !ready || !password}
>
{busy ? t("auth.loggingIn") : t("auth.login")} {busy ? t("auth.loggingIn") : t("auth.login")}
</button> </Button>
</form> </Form>
</Card.Content>
</Card>
</LoginShell> </LoginShell>
); );
} }

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react";
import { import {
batchApplyAccountManagedNe, batchApplyAccountManagedNe,
batchApplyHopManagedNe, batchApplyHopManagedNe,
@ -20,6 +21,8 @@ import {
} from "../services/api"; } from "../services/api";
import { HelpHint } from "../components/HelpHint"; import { HelpHint } from "../components/HelpHint";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields"; import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields";
import { AppModalShell } from "../components/ui/AppModalShell";
import { FieldSelect } from "../components/ui/FieldSelect";
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
@ -51,20 +54,6 @@ const emptyAccount = (): AccountState => ({
password: "", password: "",
}); });
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
export function NePage() { export function NePage() {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
@ -79,7 +68,7 @@ export function NePage() {
const [vendorFilter, setVendorFilter] = useState(""); const [vendorFilter, setVendorFilter] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(10);
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [batchHopOpen, setBatchHopOpen] = useState(false); const [batchHopOpen, setBatchHopOpen] = useState(false);
@ -438,38 +427,41 @@ export function NePage() {
<h2>{t("managedNe.stats.title")}</h2> <h2>{t("managedNe.stats.title")}</h2>
<div className="panel__toolbar-end"> <div className="panel__toolbar-end">
<div className="panel__actions"> <div className="panel__actions">
<button <Button
type="button" size="sm"
disabled={bulkByTagMutation.isPending} variant="secondary"
onClick={() => { isDisabled={bulkByTagMutation.isPending}
onPress={() => {
setBulkTagAction("proxy"); setBulkTagAction("proxy");
setBulkHop(emptyHopProxyFields()); setBulkHop(emptyHopProxyFields());
setBulkTagModalOpen(true); setBulkTagModalOpen(true);
}} }}
> >
{t("managedNe.stats.batchProxy")} {t("managedNe.stats.batchProxy")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={bulkByTagMutation.isPending} variant="secondary"
onClick={() => { isDisabled={bulkByTagMutation.isPending}
onPress={() => {
setBulkTagAction("account"); setBulkTagAction("account");
setBulkAccount(emptyAccount()); setBulkAccount(emptyAccount());
setBulkTagModalOpen(true); setBulkTagModalOpen(true);
}} }}
> >
{t("managedNe.account.batchByTag")} {t("managedNe.account.batchByTag")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={bulkByTagMutation.isPending} variant="secondary"
onClick={() => { isDisabled={bulkByTagMutation.isPending}
onPress={() => {
setBulkTagAction("test"); setBulkTagAction("test");
setBulkTagModalOpen(true); setBulkTagModalOpen(true);
}} }}
> >
{t("managedNe.stats.batchTest")} {t("managedNe.stats.batchTest")}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
@ -499,50 +491,53 @@ export function NePage() {
<h2>{t("managedNe.title")}</h2> <h2>{t("managedNe.title")}</h2>
<div className="panel__toolbar-end"> <div className="panel__toolbar-end">
<div className="panel__actions"> <div className="panel__actions">
<button type="button" onClick={openCreate} disabled={!credsOk || !canWriteNe}> <Button size="sm" variant="primary" onPress={openCreate} isDisabled={!credsOk || !canWriteNe}>
{t("managedNe.add")} {t("managedNe.add")}
</button> </Button>
{SHOW_UME_MANAGED_SYNC ? ( {SHOW_UME_MANAGED_SYNC ? (
<> <>
<button <Button
type="button" size="sm"
onClick={() => umeSyncMutation.mutate()} variant="secondary"
disabled={umeSyncMutation.isPending} onPress={() => umeSyncMutation.mutate()}
isDisabled={umeSyncMutation.isPending}
> >
{umeSyncMutation.isPending {umeSyncMutation.isPending
? t("managedNe.umeSync.syncing") ? t("managedNe.umeSync.syncing")
: t("managedNe.umeSync.sync")} : t("managedNe.umeSync.sync")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn btn--danger" variant="danger"
onClick={() => { onPress={() => {
if (!window.confirm(t("managedNe.umeSync.deleteConfirm"))) return; if (!window.confirm(t("managedNe.umeSync.deleteConfirm"))) return;
umeDeleteMutation.mutate(); umeDeleteMutation.mutate();
}} }}
disabled={umeDeleteMutation.isPending} isDisabled={umeDeleteMutation.isPending}
> >
{umeDeleteMutation.isPending {umeDeleteMutation.isPending
? t("managedNe.umeSync.deleting") ? t("managedNe.umeSync.deleting")
: t("managedNe.umeSync.delete")} : t("managedNe.umeSync.delete")}
</button> </Button>
</> </>
) : null} ) : null}
<button <Button
type="button" size="sm"
onClick={() => { variant="secondary"
onPress={() => {
void downloadManagedNeImportTemplate("xlsx").catch((err) => showError(String(err))); void downloadManagedNeImportTemplate("xlsx").catch((err) => showError(String(err)));
}} }}
> >
{t("managedNe.downloadTemplate")} {t("managedNe.downloadTemplate")}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => importRef.current?.click()} variant="secondary"
disabled={!credsOk || importMutation.isPending || !canWriteNe} onPress={() => importRef.current?.click()}
isDisabled={!credsOk || importMutation.isPending || !canWriteNe}
> >
{importMutation.isPending ? t("managedNe.importing") : t("managedNe.importBtn")} {importMutation.isPending ? t("managedNe.importing") : t("managedNe.importBtn")}
</button> </Button>
<input <input
ref={importRef} ref={importRef}
type="file" type="file"
@ -554,17 +549,19 @@ export function NePage() {
if (file) importMutation.mutate(file); if (file) importMutation.mutate(file);
}} }}
/> />
<button <Button
type="button" size="sm"
disabled={selected.length === 0 || connectMutation.isPending} variant="secondary"
onClick={() => connectMutation.mutate(selected)} isDisabled={selected.length === 0 || connectMutation.isPending}
onPress={() => connectMutation.mutate(selected)}
> >
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")} {connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={selected.length === 0 || batchHopMutation.isPending || !canWriteNe} variant="secondary"
onClick={() => { isDisabled={selected.length === 0 || batchHopMutation.isPending || !canWriteNe}
onPress={() => {
if (selected.length === 0) { if (selected.length === 0) {
showError(t("managedNe.hop.selectRequired")); showError(t("managedNe.hop.selectRequired"));
return; return;
@ -574,11 +571,12 @@ export function NePage() {
}} }}
> >
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")} {batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={selected.length === 0 || batchAccountMutation.isPending || !canWriteNe} variant="secondary"
onClick={() => { isDisabled={selected.length === 0 || batchAccountMutation.isPending || !canWriteNe}
onPress={() => {
if (selected.length === 0) { if (selected.length === 0) {
showError(t("managedNe.account.selectRequired")); showError(t("managedNe.account.selectRequired"));
return; return;
@ -588,12 +586,12 @@ export function NePage() {
}} }}
> >
{batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.batchAdd")} {batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.batchAdd")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn btn--danger" variant="danger"
disabled={selected.length === 0 || batchDeleteMutation.isPending || !canWriteNe} isDisabled={selected.length === 0 || batchDeleteMutation.isPending || !canWriteNe}
onClick={() => { onPress={() => {
if (selected.length === 0) { if (selected.length === 0) {
showError(t("managedNe.batchDeleteSelectRequired")); showError(t("managedNe.batchDeleteSelectRequired"));
return; return;
@ -603,10 +601,10 @@ export function NePage() {
}} }}
> >
{batchDeleteMutation.isPending ? t("managedNe.batchDeleting") : t("managedNe.batchDelete")} {batchDeleteMutation.isPending ? t("managedNe.batchDeleting") : t("managedNe.batchDelete")}
</button> </Button>
<button type="button" onClick={() => invalidateList()}> <Button size="sm" variant="tertiary" onPress={() => invalidateList()}>
{t("common.refresh")} {t("common.refresh")}
</button> </Button>
</div> </div>
<HelpHint text={t("managedNe.help")} ariaLabel={t("common.help")} align="end" /> <HelpHint text={t("managedNe.help")} ariaLabel={t("common.help")} align="end" />
</div> </div>
@ -614,7 +612,7 @@ export function NePage() {
<div className="pt-list"> <div className="pt-list">
<div className="filter-inline"> <div className="filter-inline">
<input <Input
value={keyword} value={keyword}
placeholder={t("managedNe.keywordPh")} placeholder={t("managedNe.keywordPh")}
onChange={(e) => { onChange={(e) => {
@ -622,7 +620,7 @@ export function NePage() {
setPage(1); setPage(1);
}} }}
/> />
<select <FieldSelect
value={vendorFilter} value={vendorFilter}
onChange={(e) => { onChange={(e) => {
setVendorFilter(e.target.value); setVendorFilter(e.target.value);
@ -635,8 +633,8 @@ export function NePage() {
{v} {v}
</option> </option>
))} ))}
</select> </FieldSelect>
<select <FieldSelect
value={statusFilter} value={statusFilter}
onChange={(e) => { onChange={(e) => {
setStatusFilter(e.target.value); setStatusFilter(e.target.value);
@ -648,11 +646,12 @@ export function NePage() {
<option value="testing">testing</option> <option value="testing">testing</option>
<option value="pass">pass</option> <option value="pass">pass</option>
<option value="fail">fail</option> <option value="fail">fail</option>
</select> </FieldSelect>
<button <Button
type="button" size="sm"
disabled={!hasFilters} variant="tertiary"
onClick={() => { isDisabled={!hasFilters}
onPress={() => {
setKeyword(""); setKeyword("");
setVendorFilter(""); setVendorFilter("");
setStatusFilter(""); setStatusFilter("");
@ -660,7 +659,7 @@ export function NePage() {
}} }}
> >
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
</div> </div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -675,12 +674,15 @@ export function NePage() {
<thead> <thead>
<tr> <tr>
<th> <th>
<input <Checkbox
type="checkbox" isSelected={allSelected}
checked={allSelected}
onChange={toggleSelectAll} onChange={toggleSelectAll}
aria-label="select all" aria-label="select all"
/> >
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
</Checkbox>
</th> </th>
<th>{t("managedNe.col.name")}</th> <th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.vendor")}</th> <th>{t("managedNe.col.vendor")}</th>
@ -753,38 +755,44 @@ export function NePage() {
</td> </td>
<td> <td>
<div className="btn-row pt-list-actions table-actions"> <div className="btn-row pt-list-actions table-actions">
<button <Button
type="button" size="sm"
onClick={() => variant="secondary"
onPress={() =>
openOrFocusModule({ openOrFocusModule({
moduleId: "webcrt", moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`, path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`,
}) })
} }
title={t("managedNe.openTerminal")}
> >
{t("managedNe.openTerminal")} {t("managedNe.openTerminal")}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => setConnectDetailRow(row)} variant="secondary"
disabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail} isDisabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail}
onPress={() => setConnectDetailRow(row)}
> >
{t("managedNe.connectDetail")} {t("managedNe.connectDetail")}
</button> </Button>
<button type="button" onClick={() => openEdit(row)} disabled={!canWriteNe}> <Button
size="sm"
variant="secondary"
isDisabled={!canWriteNe}
onPress={() => openEdit(row)}
>
{t("managedNe.edit")} {t("managedNe.edit")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn--danger" variant="danger"
disabled={!canWriteNe} isDisabled={!canWriteNe}
onClick={() => { onPress={() => {
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id); if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
}} }}
> >
{t("managedNe.delete")} {t("managedNe.delete")}
</button> </Button>
</div> </div>
</td> </td>
</tr> </tr>
@ -804,23 +812,34 @@ export function NePage() {
})} })}
</span> </span>
<div className="btn-row"> <div className="btn-row">
<button type="button" disabled={page <= 1} onClick={() => setPage(Math.max(1, page - 1))}> <Button
size="sm"
variant="secondary"
isDisabled={page <= 1}
onPress={() => setPage(Math.max(1, page - 1))}
>
{t("common.prevPage")} {t("common.prevPage")}
</button> </Button>
<button type="button" disabled={page >= pages} onClick={() => setPage(page + 1)}> <Button
size="sm"
variant="secondary"
isDisabled={page >= pages}
onPress={() => setPage(page + 1)}
>
{t("common.nextPage")} {t("common.nextPage")}
</button> </Button>
<select <FieldSelect
value={String(pageSize)} value={String(pageSize)}
onChange={(e) => { onChange={(e) => {
setPageSize(Number(e.target.value) || 50); setPageSize(Number(e.target.value) || 10);
setPage(1); setPage(1);
}} }}
> >
<option value="10">{perPage(10)}</option>
<option value="20">{perPage(20)}</option> <option value="20">{perPage(20)}</option>
<option value="50">{perPage(50)}</option> <option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option> <option value="100">{perPage(100)}</option>
</select> </FieldSelect>
</div> </div>
</div> </div>
</div> </div>
@ -845,20 +864,28 @@ export function NePage() {
}} }}
/> />
{batchHopOpen ? ( <AppModalShell
<div className="modal-backdrop" role="presentation" onClick={() => setBatchHopOpen(false)}> open={batchHopOpen}
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}> onClose={() => setBatchHopOpen(false)}
<h3>{t("managedNe.hop.batchTitle")}</h3> dismissible={!batchHopMutation.isPending}
<p className="form-hint">{t("managedNe.hop.batchHint", { n: selected.length })}</p> size="lg"
>
<Modal.Header>
<Modal.Heading>{t("managedNe.hop.batchTitle")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={batchHopMutation.isPending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("managedNe.hop.batchHint", { n: selected.length })}</p>
<HopProxyFields value={batchHop} onChange={(patch) => setBatchHop((prev) => ({ ...prev, ...patch }))} /> <HopProxyFields value={batchHop} onChange={(patch) => setBatchHop((prev) => ({ ...prev, ...patch }))} />
<div className="modal__actions"> </Modal.Body>
<button type="button" onClick={() => setBatchHopOpen(false)}> <Modal.Footer>
<Button variant="tertiary" onPress={() => setBatchHopOpen(false)}>
{t("managedNe.form.cancel")} {t("managedNe.form.cancel")}
</button> </Button>
<button <Button
type="button" variant="primary"
disabled={batchHopMutation.isPending} isDisabled={batchHopMutation.isPending}
onClick={() => { onPress={() => {
if (!batchHop.hop_host.trim()) { if (!batchHop.hop_host.trim()) {
showError(t("managedNe.hop.hostRequired")); showError(t("managedNe.hop.hostRequired"));
return; return;
@ -875,45 +902,53 @@ export function NePage() {
}} }}
> >
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.apply")} {batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.apply")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null}
{batchAccountOpen ? ( <AppModalShell
<div className="modal-backdrop" role="presentation" onClick={() => setBatchAccountOpen(false)}> open={batchAccountOpen}
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}> onClose={() => setBatchAccountOpen(false)}
<h3>{t("managedNe.account.batchTitle")}</h3> dismissible={!batchAccountMutation.isPending}
<p className="form-hint">{t("managedNe.account.batchHint", { n: selected.length })}</p> size="md"
>
<Modal.Header>
<Modal.Heading>{t("managedNe.account.batchTitle")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={batchAccountMutation.isPending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("managedNe.account.batchHint", { n: selected.length })}</p>
<div className="form-grid"> <div className="form-grid">
<label> <TextField
<FormLabel>{t("managedNe.col.user")}</FormLabel> fullWidth
<input
value={batchAccount.username} value={batchAccount.username}
onChange={(e) => setBatchAccount((prev) => ({ ...prev, username: e.target.value }))} onChange={(username) => setBatchAccount((prev) => ({ ...prev, username }))}
/> >
</label> <Label>{t("managedNe.col.user")}</Label>
<label> <Input />
<FormLabel> </TextField>
{t("managedNe.col.password")} <TextField
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span> fullWidth
</FormLabel>
<input
type="password" type="password"
value={batchAccount.password} value={batchAccount.password}
onChange={(e) => setBatchAccount((prev) => ({ ...prev, password: e.target.value }))} onChange={(password) => setBatchAccount((prev) => ({ ...prev, password }))}
/> >
</label> <Label>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
</Label>
<Input />
</TextField>
</div> </div>
<div className="modal__actions"> </Modal.Body>
<button type="button" onClick={() => setBatchAccountOpen(false)}> <Modal.Footer>
<Button variant="tertiary" onPress={() => setBatchAccountOpen(false)}>
{t("managedNe.form.cancel")} {t("managedNe.form.cancel")}
</button> </Button>
<button <Button
type="button" variant="primary"
disabled={batchAccountMutation.isPending} isDisabled={batchAccountMutation.isPending}
onClick={() => { onPress={() => {
if (!batchAccount.username.trim() && !batchAccount.password) { if (!batchAccount.username.trim() && !batchAccount.password) {
showError(t("managedNe.account.usernameOrPasswordRequired")); showError(t("managedNe.account.usernameOrPasswordRequired"));
return; return;
@ -922,16 +957,18 @@ export function NePage() {
}} }}
> >
{batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.apply")} {batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.apply")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null}
{bulkTagModalOpen ? ( <AppModalShell
<div className="modal-backdrop" role="presentation" onClick={() => setBulkTagModalOpen(false)}> open={bulkTagModalOpen}
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}> onClose={() => setBulkTagModalOpen(false)}
<h3> dismissible={!bulkByTagMutation.isPending}
size="lg"
>
<Modal.Header>
<Modal.Heading>
{bulkTagAction === "proxy" {bulkTagAction === "proxy"
? t("managedNe.stats.batchProxy") ? t("managedNe.stats.batchProxy")
: bulkTagAction === "account" : bulkTagAction === "account"
@ -940,11 +977,13 @@ export function NePage() {
{bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""} {bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""}
{bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""} {bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""}
{bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""} {bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}
</h3> </Modal.Heading>
<label> <Modal.CloseTrigger isDisabled={bulkByTagMutation.isPending} />
<FormLabel>{t("managedNe.stats.tagFilter")}</FormLabel> </Modal.Header>
<select <Modal.Body className="flex flex-col gap-3">
className="ne-stats-card__tag-select" <FieldSelect
label={t("managedNe.stats.tagFilter")}
fullWidth
value={bulkTagSelected} value={bulkTagSelected}
onChange={(e) => setBulkTagSelected(e.target.value)} onChange={(e) => setBulkTagSelected(e.target.value)}
> >
@ -955,58 +994,68 @@ export function NePage() {
{tag} {tag}
</option> </option>
))} ))}
</select> </FieldSelect>
</label>
{bulkTagAction === "proxy" ? ( {bulkTagAction === "proxy" ? (
<> <>
<p className="form-hint">{t("managedNe.hop.batchHint", { n: "?" })}</p> <p className="text-sm text-muted">{t("managedNe.hop.batchHint", { n: "?" })}</p>
<HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} /> <HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} />
</> </>
) : bulkTagAction === "account" ? ( ) : bulkTagAction === "account" ? (
<> <>
<p className="form-hint">{t("managedNe.account.batchByTagHint")}</p> <p className="text-sm text-muted">{t("managedNe.account.batchByTagHint")}</p>
<div className="form-grid"> <div className="form-grid">
<label> <TextField
<FormLabel>{t("managedNe.col.user")}</FormLabel> fullWidth
<input
value={bulkAccount.username} value={bulkAccount.username}
onChange={(e) => setBulkAccount((prev) => ({ ...prev, username: e.target.value }))} onChange={(username) => setBulkAccount((prev) => ({ ...prev, username }))}
/> >
</label> <Label>{t("managedNe.col.user")}</Label>
<label> <Input />
<FormLabel> </TextField>
{t("managedNe.col.password")} <TextField
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span> fullWidth
</FormLabel>
<input
type="password" type="password"
value={bulkAccount.password} value={bulkAccount.password}
onChange={(e) => setBulkAccount((prev) => ({ ...prev, password: e.target.value }))} onChange={(password) => setBulkAccount((prev) => ({ ...prev, password }))}
/> >
</label> <Label>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
</Label>
<Input />
</TextField>
</div> </div>
</> </>
) : ( ) : (
<p className="form-hint">{t("managedNe.stats.confirm", { n: "?" })}</p> <p className="text-sm text-muted">{t("managedNe.stats.confirm", { n: "?" })}</p>
)} )}
<div className="modal__actions"> </Modal.Body>
<button type="button" onClick={() => setBulkTagModalOpen(false)}> <Modal.Footer>
<Button variant="tertiary" onPress={() => setBulkTagModalOpen(false)}>
{t("managedNe.form.cancel")} {t("managedNe.form.cancel")}
</button> </Button>
<button <Button
type="button" variant="primary"
disabled={bulkByTagMutation.isPending} isDisabled={bulkByTagMutation.isPending}
onClick={() => { onPress={() => {
if (bulkTagAction === "proxy") { if (bulkTagAction === "proxy") {
if (!bulkHop.hop_host.trim()) { showError(t("managedNe.hop.hostRequired")); return; } if (!bulkHop.hop_host.trim()) {
if (!bulkHop.hop_username.trim()) { showError(t("managedNe.hop.userRequired")); return; } showError(t("managedNe.hop.hostRequired"));
return;
}
if (!bulkHop.hop_username.trim()) {
showError(t("managedNe.hop.userRequired"));
return;
}
if (!bulkHop.hop_password && bulkHop.hop_target_auth_mode !== "bastion_managed") { if (!bulkHop.hop_password && bulkHop.hop_target_auth_mode !== "bastion_managed") {
showError(t("managedNe.hop.passwordRequired")); return; showError(t("managedNe.hop.passwordRequired"));
return;
} }
} }
if (bulkTagAction === "account") { if (bulkTagAction === "account") {
if (!bulkAccount.username.trim() && !bulkAccount.password) { if (!bulkAccount.username.trim() && !bulkAccount.password) {
showError(t("managedNe.account.usernameOrPasswordRequired")); return; showError(t("managedNe.account.usernameOrPasswordRequired"));
return;
} }
} }
bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected }); bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected });
@ -1019,11 +1068,9 @@ export function NePage() {
: bulkTagAction === "account" : bulkTagAction === "account"
? t("managedNe.account.apply") ? t("managedNe.account.apply")
: t("managedNe.connect.run")} : t("managedNe.connect.run")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null}
<ManagedNeConnectDetailDialog <ManagedNeConnectDetailDialog
row={connectDetailRow} row={connectDetailRow}

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
@ -15,6 +16,37 @@ type SessionRow = {
current: boolean; current: boolean;
}; };
/** Short browser/OS label so same NAT IP can still be told apart. */
function summarizeUa(ua: string): string {
const s = String(ua || "").trim();
if (!s) return "—";
const browser = /Edg\//.test(s)
? "Edge"
: /Chrome\//.test(s)
? "Chrome"
: /Firefox\//.test(s)
? "Firefox"
: /Safari\//.test(s) && !/Chrome\//.test(s)
? "Safari"
: /curl\//i.test(s)
? "curl"
: /python/i.test(s)
? "python"
: "Client";
const os = /Windows NT/i.test(s)
? "Windows"
: /Android/i.test(s)
? "Android"
: /iPhone|iPad/i.test(s)
? "iOS"
: /Mac OS X|Macintosh/i.test(s)
? "macOS"
: /Linux/i.test(s)
? "Linux"
: "";
return os ? `${browser} · ${os}` : browser;
}
export function SessionsPage() { export function SessionsPage() {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
@ -54,16 +86,22 @@ export function SessionsPage() {
<div className="panel"> <div className="panel">
<div className="filter-inline" style={{ marginBottom: 12 }}> <div className="filter-inline" style={{ marginBottom: 12 }}>
<button <Button
type="button" size="sm"
disabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0} variant="secondary"
onClick={() => revokeOthersMut.mutate()} isDisabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
onPress={() => revokeOthersMut.mutate()}
> >
{t("auth.revokeOtherSessions")} {t("auth.revokeOtherSessions")}
</button> </Button>
<button type="button" onClick={() => void sessionsQuery.refetch()} disabled={sessionsQuery.isFetching}> <Button
size="sm"
variant="secondary"
onPress={() => void sessionsQuery.refetch()}
isDisabled={sessionsQuery.isFetching}
>
{t("common.refresh")} {t("common.refresh")}
</button> </Button>
</div> </div>
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -76,6 +114,7 @@ export function SessionsPage() {
<tr> <tr>
<th>{t("auth.colSession")}</th> <th>{t("auth.colSession")}</th>
<th>{t("auth.colIp")}</th> <th>{t("auth.colIp")}</th>
<th>{t("auth.colClient")}</th>
<th>{t("auth.colLastSeen")}</th> <th>{t("auth.colLastSeen")}</th>
<th>{t("auth.colCreated")}</th> <th>{t("auth.colCreated")}</th>
<th>{t("auth.actions")}</th> <th>{t("auth.actions")}</th>
@ -85,27 +124,31 @@ export function SessionsPage() {
{items.map((row) => ( {items.map((row) => (
<tr key={row.id}> <tr key={row.id}>
<td> <td>
<code title={row.user_agent}>{row.id.slice(0, 10)}…</code> <code title={row.id}>{row.id.slice(0, 10)}…</code>
{row.current ? ( {row.current ? (
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}> <span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
{t("auth.sessionCurrent")} {t("auth.sessionCurrent")}
</span> </span>
) : null} ) : null}
</td> </td>
<td>{row.client_ip || "—"}</td> <td>
<code>{row.client_ip || "—"}</code>
</td>
<td title={row.user_agent || undefined}>{summarizeUa(row.user_agent)}</td>
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td> <td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td> <td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
<td> <td>
<button <Button
type="button" size="sm"
disabled={revokeMut.isPending} variant="secondary"
onClick={() => { isDisabled={revokeMut.isPending}
onPress={() => {
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return; if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
revokeMut.mutate(row.id); revokeMut.mutate(row.id);
}} }}
> >
{t("auth.revokeSession")} {t("auth.revokeSession")}
</button> </Button>
</td> </td>
</tr> </tr>
))} ))}

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { import {
DISCOVER_AUTO_ADD_KEY, DISCOVER_AUTO_ADD_KEY,
DISCOVER_PROJECT_NEIGHBORS_KEY, DISCOVER_PROJECT_NEIGHBORS_KEY,
@ -189,7 +190,6 @@ export function TopologyPage() {
const searchHitTimerRef = useRef<number | null>(null); const searchHitTimerRef = useRef<number | null>(null);
const findBoxRef = useRef<HTMLDivElement | null>(null); const findBoxRef = useRef<HTMLDivElement | null>(null);
const displayMenuRef = useRef<HTMLDetailsElement | null>(null); const displayMenuRef = useRef<HTMLDetailsElement | null>(null);
const exportMenuRef = useRef<HTMLDetailsElement | null>(null);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState<HTMLDivElement | null>(null); const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState<HTMLDivElement | null>(null);
const findJustLocatedRef = useRef(false); const findJustLocatedRef = useRef(false);
@ -1780,7 +1780,6 @@ export function TopologyPage() {
return; return;
} }
setExporting(true); setExporting(true);
exportMenuRef.current?.removeAttribute("open");
const filenameBase = activeLeafName || view.name || "topology"; const filenameBase = activeLeafName || view.name || "topology";
try { try {
if (format === "svg") { if (format === "svg") {
@ -2027,7 +2026,6 @@ export function TopologyPage() {
savePending={saveMut.isPending} savePending={saveMut.isPending}
onSave={() => saveMut.mutate()} onSave={() => saveMut.mutate()}
onFit={fitCanvas} onFit={fitCanvas}
exportMenuRef={exportMenuRef}
exporting={exporting} exporting={exporting}
onExport={runTopologyExport} onExport={runTopologyExport}
mapId={mapId} mapId={mapId}
@ -2304,34 +2302,34 @@ export function TopologyPage() {
<div className="topo-canvas__empty"> <div className="topo-canvas__empty">
<p className="muted">{t("topology.treeLoadFailed")}</p> <p className="muted">{t("topology.treeLoadFailed")}</p>
<div className="topo-browser__empty-actions"> <div className="topo-browser__empty-actions">
<button <Button
type="button" size="sm"
className="btn btn--sm btn--ghost" variant="ghost"
onClick={() => void treeQuery.refetch()} onPress={() => void treeQuery.refetch()}
> >
{t("topology.treeRetry")} {t("topology.treeRetry")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
onClick={promptNewRegion} onPress={promptNewRegion}
disabled={createRegionMut.isPending} isDisabled={createRegionMut.isPending}
> >
{t("topology.newRegion")} {t("topology.newRegion")}
</button> </Button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="topo-canvas__empty"> <div className="topo-canvas__empty">
<p className="muted">{t("topology.emptyMaps")}</p> <p className="muted">{t("topology.emptyMaps")}</p>
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
onClick={promptNewRegion} onPress={promptNewRegion}
disabled={createRegionMut.isPending} isDisabled={createRegionMut.isPending}
> >
{t("topology.newRegion")} {t("topology.newRegion")}
</button> </Button>
</div> </div>
)} )}
{treeRoot && canvasGraphError ? ( {treeRoot && canvasGraphError ? (
@ -2341,13 +2339,13 @@ export function TopologyPage() {
<p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p> <p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p>
) : null} ) : null}
<div className="topo-browser__empty-actions"> <div className="topo-browser__empty-actions">
<button <Button
type="button" size="sm"
className="btn btn--sm btn--ghost" variant="ghost"
onClick={() => void graphQuery.refetch()} onPress={() => void graphQuery.refetch()}
> >
{t("topology.treeRetry")} {t("topology.treeRetry")}
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
@ -2374,16 +2372,16 @@ export function TopologyPage() {
</p> </p>
{worldNeedsApply ? ( {worldNeedsApply ? (
<div className="btn-row" style={{ marginTop: 10, justifyContent: "center" }}> <div className="btn-row" style={{ marginTop: 10, justifyContent: "center" }}>
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
disabled={applyWorldMut.isPending} isDisabled={applyWorldMut.isPending}
onClick={() => applyWorldMut.mutate()} onPress={() => applyWorldMut.mutate()}
> >
{applyWorldMut.isPending {applyWorldMut.isPending
? t("topology.worldApplying") ? t("topology.worldApplying")
: t("topology.worldApply")} : t("topology.worldApply")}
</button> </Button>
</div> </div>
) : null} ) : null}
</div> </div>

View file

@ -1,5 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Modal, Input } from "@heroui/react";
import { import {
apiPost, apiPost,
applyUmeTopologyToFabric, applyUmeTopologyToFabric,
@ -24,6 +25,7 @@ import {
} from "../services/api"; } from "../services/api";
import { HelpHint } from "../components/HelpHint"; import { HelpHint } from "../components/HelpHint";
import { UmeCliConnectPanel } from "../components/UmeCliConnectPanel"; import { UmeCliConnectPanel } from "../components/UmeCliConnectPanel";
import { AppModalShell } from "../components/ui/AppModalShell";
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
@ -41,10 +43,10 @@ export function UmePage() {
const [runtimeTaskError, setRuntimeTaskError] = useState(""); const [runtimeTaskError, setRuntimeTaskError] = useState("");
const [subscriptionOpError, setSubscriptionOpError] = useState(""); const [subscriptionOpError, setSubscriptionOpError] = useState("");
const [syncPage, setSyncPage] = useState(1); const [syncPage, setSyncPage] = useState(1);
const [syncPageSize, setSyncPageSize] = useState(20); const [syncPageSize, setSyncPageSize] = useState(10);
const [neKeyword, setNeKeyword] = useState(""); const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1); const [nePage, setNePage] = useState(1);
const [nePageSize, setNePageSize] = useState(50); const [nePageSize, setNePageSize] = useState(10);
const [expandedNeId, setExpandedNeId] = useState(""); const [expandedNeId, setExpandedNeId] = useState("");
const [nePanelOpen, setNePanelOpen] = useState(false); const [nePanelOpen, setNePanelOpen] = useState(false);
const [syncStatusPanelOpen, setSyncStatusPanelOpen] = useState(false); const [syncStatusPanelOpen, setSyncStatusPanelOpen] = useState(false);
@ -58,7 +60,7 @@ export function UmePage() {
Array<{ notification_id: string; native_probable_cause_sample: string }> Array<{ notification_id: string; native_probable_cause_sample: string }>
>([]); >([]);
const [keyAlertPage, setKeyAlertPage] = useState(1); const [keyAlertPage, setKeyAlertPage] = useState(1);
const [keyAlertPageSize, setKeyAlertPageSize] = useState(20); const [keyAlertPageSize, setKeyAlertPageSize] = useState(10);
const [keyAlertFilterKeyword, setKeyAlertFilterKeyword] = useState(""); const [keyAlertFilterKeyword, setKeyAlertFilterKeyword] = useState("");
const [keyAlertFilterEnabled, setKeyAlertFilterEnabled] = useState<"" | "true" | "false">(""); const [keyAlertFilterEnabled, setKeyAlertFilterEnabled] = useState<"" | "true" | "false">("");
const [keyAlertFilterMatchType, setKeyAlertFilterMatchType] = useState<"" | "notification_id" | "keyword">(""); const [keyAlertFilterMatchType, setKeyAlertFilterMatchType] = useState<"" | "notification_id" | "keyword">("");
@ -494,9 +496,11 @@ export function UmePage() {
) : null} ) : null}
</div> </div>
<div className="actions-row actions-row--inline"> <div className="actions-row actions-row--inline">
<button <Button
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus })} size="sm"
disabled={tokenStatusQuery.isFetching} variant="secondary"
isDisabled={tokenStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus })}
> >
{tokenStatusQuery.isFetching ? ( {tokenStatusQuery.isFetching ? (
<> <>
@ -506,11 +510,12 @@ export function UmePage() {
) : ( ) : (
t("ume.token.refreshStatus") t("ume.token.refreshStatus")
)} )}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => tokenRefreshMutation.mutate()} variant="secondary"
disabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending} isDisabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
onPress={() => tokenRefreshMutation.mutate()}
> >
{tokenRefreshMutation.isPending ? ( {tokenRefreshMutation.isPending ? (
<> <>
@ -520,11 +525,12 @@ export function UmePage() {
) : ( ) : (
t("ume.token.renewLogin") t("ume.token.renewLogin")
)} )}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => tokenDisconnectMutation.mutate()} variant="danger"
disabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending} isDisabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
onPress={() => tokenDisconnectMutation.mutate()}
> >
{tokenDisconnectMutation.isPending ? ( {tokenDisconnectMutation.isPending ? (
<> <>
@ -534,7 +540,7 @@ export function UmePage() {
) : ( ) : (
t("ume.token.disconnect") t("ume.token.disconnect")
)} )}
</button> </Button>
</div> </div>
{(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && ( {(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && (
<div className="pill pill--high"> <div className="pill pill--high">
@ -550,13 +556,14 @@ export function UmePage() {
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}> <span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
{t("ume.tasks.running")}: {runningTasks.length} {t("ume.tasks.running")}: {runningTasks.length}
</span> </span>
<button <Button
type="button" size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} variant="secondary"
disabled={syncStatusQuery.isFetching} isDisabled={syncStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
> >
{t("common.refresh")} {t("common.refresh")}
</button> </Button>
</div> </div>
</div> </div>
{needsFabricApply ? ( {needsFabricApply ? (
@ -572,16 +579,16 @@ export function UmePage() {
)} )}
</div> </div>
<div className="btn-row" style={{ marginTop: 8 }}> <div className="btn-row" style={{ marginTop: 8 }}>
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
disabled={applyFabricMut.isPending} isDisabled={applyFabricMut.isPending}
onClick={() => applyFabricMut.mutate()} onPress={() => applyFabricMut.mutate()}
> >
{applyFabricMut.isPending {applyFabricMut.isPending
? t("ume.syncStatus.applyingFabric") ? t("ume.syncStatus.applyingFabric")
: t("ume.syncStatus.applyFabric")} : t("ume.syncStatus.applyFabric")}
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
@ -648,23 +655,25 @@ export function UmePage() {
<td>{runtimeLastError(x.last_error, t) || t("common.empty")}</td> <td>{runtimeLastError(x.last_error, t) || t("common.empty")}</td>
<td> <td>
{Boolean(x.paused) ? ( {Boolean(x.paused) ? (
<button <Button
type="button" size="sm"
variant="ghost"
className="link-btn" className="link-btn"
disabled={runtimeTaskMutation.isPending} isDisabled={runtimeTaskMutation.isPending}
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })} onPress={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
> >
resume resume
</button> </Button>
) : ( ) : (
<button <Button
type="button" size="sm"
variant="ghost"
className="link-btn" className="link-btn"
disabled={runtimeTaskMutation.isPending} isDisabled={runtimeTaskMutation.isPending}
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "pause" })} onPress={() => runtimeTaskMutation.mutate({ task: x.task, action: "pause" })}
> >
pause pause
</button> </Button>
)} )}
</td> </td>
</tr> </tr>
@ -721,9 +730,11 @@ export function UmePage() {
</div> </div>
) : null} ) : null}
<div className="actions-row actions-row--inline"> <div className="actions-row actions-row--inline">
<button <Button
type="button" size="sm"
onClick={() => { variant="secondary"
isDisabled={subPending || (!serverSubLost && subscriptionActive) || !hasToken}
onPress={() => {
if (serverSubLost && subscriptionActive) { if (serverSubLost && subscriptionActive) {
if (window.confirm(t("ume.subscription.confirmReestablish"))) { if (window.confirm(t("ume.subscription.confirmReestablish"))) {
subscriptionEstablishMutation.mutate({ forceReestablish: true }); subscriptionEstablishMutation.mutate({ forceReestablish: true });
@ -732,14 +743,6 @@ export function UmePage() {
} }
subscriptionEstablishMutation.mutate(undefined); subscriptionEstablishMutation.mutate(undefined);
}} }}
disabled={subPending || (!serverSubLost && subscriptionActive) || !hasToken}
title={
!hasToken
? t("ume.subscription.titleLoginFirst")
: serverSubLost
? t("ume.subscription.titleReestablish")
: undefined
}
> >
{subscriptionEstablishMutation.isPending ? ( {subscriptionEstablishMutation.isPending ? (
<> <>
@ -751,14 +754,15 @@ export function UmePage() {
) : ( ) : (
t("ume.subscription.establish") t("ume.subscription.establish")
)} )}
</button> </Button>
{serverSubLost && subscriptionActive ? ( {serverSubLost && subscriptionActive ? (
<button <Button
type="button" size="sm"
onClick={() => { variant="secondary"
isDisabled={subPending}
onPress={() => {
if (confirmClearLocalSubscription()) subscriptionClearLocalMutation.mutate(); if (confirmClearLocalSubscription()) subscriptionClearLocalMutation.mutate();
}} }}
disabled={subPending}
> >
{subscriptionClearLocalMutation.isPending ? ( {subscriptionClearLocalMutation.isPending ? (
<> <>
@ -768,12 +772,13 @@ export function UmePage() {
) : ( ) : (
t("ume.subscription.clearLocalOnly") t("ume.subscription.clearLocalOnly")
)} )}
</button> </Button>
) : null} ) : null}
<button <Button
type="button" size="sm"
onClick={() => subscriptionCancelMutation.mutate(undefined)} variant="danger"
disabled={subPending || !subscriptionActive} isDisabled={subPending || !subscriptionActive}
onPress={() => subscriptionCancelMutation.mutate(undefined)}
> >
{subscriptionCancelMutation.isPending ? ( {subscriptionCancelMutation.isPending ? (
<> <>
@ -783,14 +788,15 @@ export function UmePage() {
) : ( ) : (
t("ume.subscription.cancel") t("ume.subscription.cancel")
)} )}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription })} variant="secondary"
disabled={subscriptionStatusQuery.isFetching} isDisabled={subscriptionStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription })}
> >
{t("ume.subscription.refreshStatus")} {t("ume.subscription.refreshStatus")}
</button> </Button>
</div> </div>
{subscriptionOpError ? ( {subscriptionOpError ? (
<div className="pill pill--high"> <div className="pill pill--high">
@ -839,9 +845,9 @@ export function UmePage() {
{t("ume.cli.title")} {t("ume.cli.title")}
<HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} /> <HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} />
</h2> </h2>
<button type="button" className="btn btn--sm" onClick={() => setCliPanelOpen(true)}> <Button size="sm" variant="secondary" onPress={() => setCliPanelOpen(true)}>
{t("ume.cli.showPanel")} {t("ume.cli.showPanel")}
</button> </Button>
</div> </div>
<p className="ume-entry__summary muted">{t("ume.cli.hint")}</p> <p className="ume-entry__summary muted">{t("ume.cli.hint")}</p>
</article> </article>
@ -852,9 +858,9 @@ export function UmePage() {
{t("ume.keyAlert.title")} {t("ume.keyAlert.title")}
<HelpHint text={t("ume.keyAlert.help")} ariaLabel={t("common.help")} /> <HelpHint text={t("ume.keyAlert.help")} ariaLabel={t("common.help")} />
</h2> </h2>
<button type="button" className="btn btn--sm" onClick={() => setKeyAlertPanelOpen(true)}> <Button size="sm" variant="secondary" onPress={() => setKeyAlertPanelOpen(true)}>
{t("ume.keyAlert.showPanel")} {t("ume.keyAlert.showPanel")}
</button> </Button>
</div> </div>
<div className="ume-entry__pills actions-row actions-row--inline"> <div className="ume-entry__pills actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${hubWsPill}`}> <span className={`conn-pill conn-pill--${hubWsPill}`}>
@ -888,9 +894,9 @@ export function UmePage() {
<article className="panel ume-entry"> <article className="panel ume-entry">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("ume.syncStatus.title")}</h2> <h2>{t("ume.syncStatus.title")}</h2>
<button type="button" className="btn btn--sm" onClick={() => setSyncStatusPanelOpen(true)}> <Button size="sm" variant="secondary" onPress={() => setSyncStatusPanelOpen(true)}>
{t("ume.syncStatus.showPanel")} {t("ume.syncStatus.showPanel")}
</button> </Button>
</div> </div>
<p className="ume-entry__summary muted"> <p className="ume-entry__summary muted">
{syncTotal {syncTotal
@ -905,9 +911,9 @@ export function UmePage() {
<article className="panel ume-entry"> <article className="panel ume-entry">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("ume.ne.title")}</h2> <h2>{t("ume.ne.title")}</h2>
<button type="button" className="btn btn--sm" onClick={() => setNePanelOpen(true)}> <Button size="sm" variant="secondary" onPress={() => setNePanelOpen(true)}>
{t("ume.ne.showPanel")} {t("ume.ne.showPanel")}
</button> </Button>
</div> </div>
<p className="ume-entry__summary muted"> <p className="ume-entry__summary muted">
{t("ume.ne.summary", { total: String(neTotal) })} {t("ume.ne.summary", { total: String(neTotal) })}
@ -934,45 +940,41 @@ export function UmePage() {
</section> </section>
</div> </div>
{cliPanelOpen ? ( <AppModalShell
<div className="modal-backdrop" role="presentation" onClick={() => setCliPanelOpen(false)}> open={cliPanelOpen}
<div onClose={() => setCliPanelOpen(false)}
className="modal modal--wide ops-detail-modal ops-detail-modal--xl" size="lg"
role="dialog" className="app-heroui-modal--xl"
aria-modal="true"
aria-label={t("ume.cli.title")}
onClick={(e) => e.stopPropagation()}
> >
<div className="ops-detail-modal__head"> <Modal.Header>
<div className="ops-detail-modal__title"> <Modal.Heading>{t("ume.cli.title")}</Modal.Heading>
<h3>{t("ume.cli.title")}</h3> <Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">{t("ume.cli.hint")}</p> <p className="muted">{t("ume.cli.hint")}</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button type="button" onClick={() => setCliPanelOpen(false)}>
{t("networkConfigs.close")}
</button>
</div>
</div>
<div className="ops-detail-modal__scroll ops-detail-modal__scroll--pad">
<UmeCliConnectPanel embedded enabled /> <UmeCliConnectPanel embedded enabled />
</div> </Modal.Body>
</div> </AppModalShell>
</div>
) : null}
{keyAlertPanelOpen ? ( <AppModalShell
<div className="modal-backdrop" role="presentation" onClick={() => setKeyAlertPanelOpen(false)}> open={keyAlertPanelOpen}
<div onClose={() => setKeyAlertPanelOpen(false)}
className="modal modal--wide ops-detail-modal ops-detail-modal--xl" size="lg"
role="dialog" className="app-heroui-modal--xl"
aria-modal="true"
aria-label={t("ume.keyAlert.title")}
onClick={(e) => e.stopPropagation()}
> >
<div className="ops-detail-modal__head"> <Modal.Header>
<div className="ops-detail-modal__title"> <Modal.Heading>{t("ume.keyAlert.title")}</Modal.Heading>
<h3>{t("ume.keyAlert.title")}</h3> <Button
size="sm"
variant="secondary"
isDisabled={keyAlertMonitorQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll })}
>
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</Button>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted"> <p className="muted">
{t("ume.keyAlert.hub")}:{" "} {t("ume.keyAlert.hub")}:{" "}
{keyAlertHubSubscribers > 0 {keyAlertHubSubscribers > 0
@ -992,22 +994,6 @@ export function UmePage() {
}` }`
: ""} : ""}
</p> </p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll })}
disabled={keyAlertMonitorQuery.isFetching}
>
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
<button type="button" onClick={() => setKeyAlertPanelOpen(false)}>
{t("networkConfigs.close")}
</button>
</div>
</div>
<div className="ops-detail-modal__scroll ops-detail-modal__scroll--flow">
<div className="ume-modal-form"> <div className="ume-modal-form">
<div className="muted ume-modal-form__label">{t("ume.keyAlert.hubConnections")}</div> <div className="muted ume-modal-form__label">{t("ume.keyAlert.hubConnections")}</div>
{keyAlertHubConnections.length === 0 ? ( {keyAlertHubConnections.length === 0 ? (
@ -1105,9 +1091,10 @@ export function UmePage() {
</option> </option>
))} ))}
</datalist> </datalist>
<button <Button
type="button" size="sm"
onClick={async () => { variant="secondary"
onPress={async () => {
try { try {
if (keyAlertMatchType === "keyword") { if (keyAlertMatchType === "keyword") {
const resp = await fetchUmeAlarmKeywords(100); const resp = await fetchUmeAlarmKeywords(100);
@ -1134,16 +1121,17 @@ export function UmePage() {
}} }}
> >
{t("ume.keyAlert.pickFromAlarms")} {t("ume.keyAlert.pickFromAlarms")}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => keyAlertAddMutation.mutate()} variant="secondary"
disabled={ isDisabled={
keyAlertAddMutation.isPending || !keyAlertMatchValue.trim() || !keyAlertLabel.trim() keyAlertAddMutation.isPending || !keyAlertMatchValue.trim() || !keyAlertLabel.trim()
} }
onPress={() => keyAlertAddMutation.mutate()}
> >
{keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")} {keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")}
</button> </Button>
</div> </div>
<div className="muted ume-modal-form__label">{t("ume.keyAlert.neTypesLabel")}</div> <div className="muted ume-modal-form__label">{t("ume.keyAlert.neTypesLabel")}</div>
@ -1284,29 +1272,29 @@ export function UmePage() {
</td> </td>
<td> <td>
<div className="btn-row"> <div className="btn-row">
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
onClick={() => { isDisabled={keyAlertEditMutation.isPending}
onPress={() => {
setKeyAlertEditRule(rule); setKeyAlertEditRule(rule);
setKeyAlertEditNeTypes(rule.ne_types || []); setKeyAlertEditNeTypes(rule.ne_types || []);
}} }}
disabled={keyAlertEditMutation.isPending}
> >
{t("ume.keyAlert.edit")} {t("ume.keyAlert.edit")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="danger"
onClick={() => { isDisabled={keyAlertDeleteMutation.isPending}
onPress={() => {
if (window.confirm(t("ume.keyAlert.confirmDelete"))) { if (window.confirm(t("ume.keyAlert.confirmDelete"))) {
keyAlertDeleteMutation.mutate(rule.notification_id); keyAlertDeleteMutation.mutate(rule.notification_id);
} }
}} }}
disabled={keyAlertDeleteMutation.isPending}
> >
{t("ume.keyAlert.delete")} {t("ume.keyAlert.delete")}
</button> </Button>
</div> </div>
</td> </td>
</tr> </tr>
@ -1331,25 +1319,27 @@ export function UmePage() {
})} })}
</span> </span>
<div className="btn-row"> <div className="btn-row">
<button <Button
type="button" size="sm"
disabled={keyAlertPage <= 1} variant="secondary"
onClick={() => setKeyAlertPage(Math.max(1, keyAlertPage - 1))} isDisabled={keyAlertPage <= 1}
onPress={() => setKeyAlertPage(Math.max(1, keyAlertPage - 1))}
> >
{t("common.prevPage")} {t("common.prevPage")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={keyAlertPage >= keyAlertPages} variant="secondary"
onClick={() => setKeyAlertPage(keyAlertPage + 1)} isDisabled={keyAlertPage >= keyAlertPages}
onPress={() => setKeyAlertPage(keyAlertPage + 1)}
> >
{t("common.nextPage")} {t("common.nextPage")}
</button> </Button>
<select <select
className="pager__size" className="pager__size"
value={String(keyAlertPageSize)} value={String(keyAlertPageSize)}
onChange={(e) => { onChange={(e) => {
setKeyAlertPageSize(Number(e.target.value) || 20); setKeyAlertPageSize(Number(e.target.value) || 10);
setKeyAlertPage(1); setKeyAlertPage(1);
}} }}
> >
@ -1359,49 +1349,34 @@ export function UmePage() {
</select> </select>
</div> </div>
</div> </div>
</div> </Modal.Body>
</div> </AppModalShell>
</div>
) : null}
{syncStatusPanelOpen ? ( <AppModalShell
<div open={syncStatusPanelOpen}
className="modal-backdrop" onClose={() => setSyncStatusPanelOpen(false)}
role="presentation" size="lg"
onClick={() => setSyncStatusPanelOpen(false)} className="app-heroui-modal--xl"
> >
<div <Modal.Header>
className="modal modal--wide ops-detail-modal ops-detail-modal--xl" <Modal.Heading>{t("ume.syncStatus.title")}</Modal.Heading>
role="dialog" <Button
aria-modal="true" size="sm"
aria-label={t("ume.syncStatus.title")} variant="secondary"
onClick={(e) => e.stopPropagation()} isDisabled={syncStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
> >
<div className="ops-detail-modal__head"> {syncStatusQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
<div className="ops-detail-modal__title"> </Button>
<h3>{t("ume.syncStatus.title")}</h3> <Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted"> <p className="muted">
{t("ume.syncStatus.summary", { {t("ume.syncStatus.summary", {
total: String(syncTotal), total: String(syncTotal),
running: String(runningTasks.length), running: String(runningTasks.length),
})} })}
</p> </p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
disabled={syncStatusQuery.isFetching}
>
{syncStatusQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
<button type="button" onClick={() => setSyncStatusPanelOpen(false)}>
{t("networkConfigs.close")}
</button>
</div>
</div>
<div className="ops-detail-modal__scroll">
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
<thead> <thead>
@ -1415,7 +1390,7 @@ export function UmePage() {
<th title={t("ume.tasks.deletedTitle")}>deleted</th> <th title={t("ume.tasks.deletedTitle")}>deleted</th>
<th>started_at</th> <th>started_at</th>
<th>ended_at</th> <th>ended_at</th>
<th>error</th> <th className="ume-col-error">error</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -1432,7 +1407,9 @@ export function UmePage() {
<td>{Number(x.deleted ?? 0)}</td> <td>{Number(x.deleted ?? 0)}</td>
<td>{formatSystemTime(x.started_at)}</td> <td>{formatSystemTime(x.started_at)}</td>
<td>{x.ended_at ? formatSystemTime(x.ended_at) : t("common.empty")}</td> <td>{x.ended_at ? formatSystemTime(x.ended_at) : t("common.empty")}</td>
<td title={x.error_message || ""}>{x.error_message || t("common.empty")}</td> <td className="ume-col-error" title={x.error_message || ""}>
{x.error_message || t("common.empty")}
</td>
</tr> </tr>
))} ))}
{!syncStatusQuery.isLoading && (syncStatusQuery.data?.items || []).length === 0 ? ( {!syncStatusQuery.isLoading && (syncStatusQuery.data?.items || []).length === 0 ? (
@ -1445,7 +1422,6 @@ export function UmePage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div className="ops-detail-modal__foot"> <div className="ops-detail-modal__foot">
<span className="muted"> <span className="muted">
@ -1456,28 +1432,31 @@ export function UmePage() {
})} })}
</span> </span>
<div className="btn-row"> <div className="btn-row">
<button <Button
type="button" size="sm"
disabled={syncPage <= 1} variant="secondary"
onClick={() => setSyncPage(Math.max(1, syncPage - 1))} isDisabled={syncPage <= 1}
onPress={() => setSyncPage(Math.max(1, syncPage - 1))}
> >
{t("common.prevPage")} {t("common.prevPage")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={syncPage >= syncPages} variant="secondary"
onClick={() => setSyncPage(syncPage + 1)} isDisabled={syncPage >= syncPages}
onPress={() => setSyncPage(syncPage + 1)}
> >
{t("common.nextPage")} {t("common.nextPage")}
</button> </Button>
<select <select
className="pager__size" className="pager__size"
value={String(syncPageSize)} value={String(syncPageSize)}
onChange={(e) => { onChange={(e) => {
setSyncPageSize(Number(e.target.value) || 20); setSyncPageSize(Number(e.target.value) || 10);
setSyncPage(1); setSyncPage(1);
}} }}
> >
<option value="10">{perPage(10)}</option>
<option value="20">{perPage(20)}</option> <option value="20">{perPage(20)}</option>
<option value="50">{perPage(50)}</option> <option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option> <option value="100">{perPage(100)}</option>
@ -1485,46 +1464,27 @@ export function UmePage() {
</select> </select>
</div> </div>
</div> </div>
</div> </Modal.Body>
</div> </AppModalShell>
) : null}
{nePanelOpen ? ( <AppModalShell
<div open={nePanelOpen}
className="modal-backdrop" onClose={() => {
role="presentation"
onClick={() => {
setNePanelOpen(false); setNePanelOpen(false);
setExpandedNeId(""); setExpandedNeId("");
}} }}
size="lg"
className="app-heroui-modal--xl"
> >
<div <Modal.Header>
className="modal modal--wide ops-detail-modal ops-detail-modal--xl" <Modal.Heading>{t("ume.ne.title")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger />
aria-modal="true" </Modal.Header>
aria-label={t("ume.ne.title")} <Modal.Body className="flex flex-col gap-3">
onClick={(e) => e.stopPropagation()}
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("ume.ne.title")}</h3>
<p className="muted">{t("ume.ne.summary", { total: String(neTotal) })}</p> <p className="muted">{t("ume.ne.summary", { total: String(neTotal) })}</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
onClick={() => {
setNePanelOpen(false);
setExpandedNeId("");
}}
>
{t("networkConfigs.close")}
</button>
</div>
</div>
<div className="ops-detail-modal__toolbar filter-inline"> <div className="ops-detail-modal__toolbar filter-inline">
<input <Input
value={neKeyword} value={neKeyword}
placeholder={t("ume.ne.keywordPh")} placeholder={t("ume.ne.keywordPh")}
onChange={(e) => { onChange={(e) => {
@ -1532,23 +1492,26 @@ export function UmePage() {
setNePage(1); setNePage(1);
}} }}
/> />
<button type="button" onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll })}> <Button
size="sm"
variant="secondary"
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll })}
>
{t("common.query")} {t("common.query")}
</button> </Button>
<button <Button
type="button" size="sm"
title={t("ume.ne.clearTitle")} variant="tertiary"
onClick={() => { isDisabled={!neKeyword.trim()}
onPress={() => {
setNeKeyword(""); setNeKeyword("");
setNePage(1); setNePage(1);
}} }}
disabled={!neKeyword.trim()}
> >
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
</div> </div>
<div className="ops-detail-modal__scroll">
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
<thead> <thead>
@ -1580,13 +1543,9 @@ export function UmePage() {
<td>{x.hardware_version || t("common.empty")}</td> <td>{x.hardware_version || t("common.empty")}</td>
<td>{x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")}</td> <td>{x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")}</td>
<td> <td>
<button <Button size="sm" variant="ghost" onPress={() => setExpandedNeId(x.ne_id)}>
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setExpandedNeId(x.ne_id)}
>
{t("ume.ne.expand")} {t("ume.ne.expand")}
</button> </Button>
</td> </td>
</tr> </tr>
))} ))}
@ -1600,7 +1559,6 @@ export function UmePage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div className="ops-detail-modal__foot"> <div className="ops-detail-modal__foot">
<span className="muted"> <span className="muted">
@ -1611,28 +1569,31 @@ export function UmePage() {
})} })}
</span> </span>
<div className="btn-row"> <div className="btn-row">
<button <Button
type="button" size="sm"
disabled={nePage <= 1} variant="secondary"
onClick={() => setNePage(Math.max(1, nePage - 1))} isDisabled={nePage <= 1}
onPress={() => setNePage(Math.max(1, nePage - 1))}
> >
{t("common.prevPage")} {t("common.prevPage")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={nePage >= nePages} variant="secondary"
onClick={() => setNePage(nePage + 1)} isDisabled={nePage >= nePages}
onPress={() => setNePage(nePage + 1)}
> >
{t("common.nextPage")} {t("common.nextPage")}
</button> </Button>
<select <select
className="pager__size" className="pager__size"
value={String(nePageSize)} value={String(nePageSize)}
onChange={(e) => { onChange={(e) => {
setNePageSize(Number(e.target.value) || 50); setNePageSize(Number(e.target.value) || 10);
setNePage(1); setNePage(1);
}} }}
> >
<option value="10">{perPage(10)}</option>
<option value="20">{perPage(20)}</option> <option value="20">{perPage(20)}</option>
<option value="50">{perPage(50)}</option> <option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option> <option value="100">{perPage(100)}</option>
@ -1641,35 +1602,26 @@ export function UmePage() {
</select> </select>
</div> </div>
</div> </div>
</div> </Modal.Body>
</div> </AppModalShell>
) : null}
{expandedNe ? ( <AppModalShell
<div className="modal-backdrop" role="presentation" onClick={() => setExpandedNeId("")}> open={Boolean(expandedNe)}
<div onClose={() => setExpandedNeId("")}
className="modal modal--wide ops-detail-modal" size="lg"
role="dialog"
aria-modal="true"
aria-label={t("ume.ne.expand")}
onClick={(e) => e.stopPropagation()}
> >
<div className="ops-detail-modal__head"> <Modal.Header>
<div className="ops-detail-modal__title"> <Modal.Heading>{expandedNe?.user_label || expandedNe?.ne_id || t("ume.ne.expand")}</Modal.Heading>
<h3>{expandedNe.user_label || expandedNe.ne_id}</h3> <Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{expandedNe ? (
<>
<p className="muted"> <p className="muted">
{expandedNe.ne_id} {expandedNe.ne_id}
{expandedNe.ip_address ? ` · ${expandedNe.ip_address}` : ""} {expandedNe.ip_address ? ` · ${expandedNe.ip_address}` : ""}
{expandedNe.ne_type ? ` · ${expandedNe.ne_type}` : ""} {expandedNe.ne_type ? ` · ${expandedNe.ne_type}` : ""}
</p> </p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button type="button" onClick={() => setExpandedNeId("")}>
{t("networkConfigs.close")}
</button>
</div>
</div>
<div className="ops-detail-modal__scroll ops-detail-modal__scroll--pad">
<div className="ume-ne-detail-grid"> <div className="ume-ne-detail-grid">
<div> <div>
<span className="muted">consistent_state</span> <span className="muted">consistent_state</span>
@ -1740,23 +1692,26 @@ export function UmePage() {
</div> </div>
</div> </div>
</div> </div>
</div> </>
</div>
</div>
) : null} ) : null}
</Modal.Body>
</AppModalShell>
{keyAlertEditRule ? ( <AppModalShell
<div className="modal-backdrop" role="presentation" onClick={() => setKeyAlertEditRule(null)}> open={Boolean(keyAlertEditRule)}
<div onClose={() => {
className="modal modal--wide ops-detail-modal" if (!keyAlertEditMutation.isPending) setKeyAlertEditRule(null);
role="dialog" }}
aria-modal="true" dismissible={!keyAlertEditMutation.isPending}
aria-label={t("ume.keyAlert.editTitle")} size="lg"
onClick={(e) => e.stopPropagation()}
> >
<div className="ops-detail-modal__head"> <Modal.Header>
<div className="ops-detail-modal__title"> <Modal.Heading>{t("ume.keyAlert.editTitle")}</Modal.Heading>
<h3>{t("ume.keyAlert.editTitle")}</h3> <Modal.CloseTrigger isDisabled={keyAlertEditMutation.isPending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{keyAlertEditRule ? (
<>
<p className="muted"> <p className="muted">
{keyAlertEditRule.match_type === "keyword" {keyAlertEditRule.match_type === "keyword"
? t("ume.keyAlert.matchKeyword") ? t("ume.keyAlert.matchKeyword")
@ -1764,13 +1719,6 @@ export function UmePage() {
: {keyAlertEditRule.match_value || keyAlertEditRule.notification_id} : {keyAlertEditRule.match_value || keyAlertEditRule.notification_id}
{keyAlertEditRule.label ? ` · ${keyAlertEditRule.label}` : ""} {keyAlertEditRule.label ? ` · ${keyAlertEditRule.label}` : ""}
</p> </p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button type="button" onClick={() => setKeyAlertEditRule(null)} disabled={keyAlertEditMutation.isPending}>
{t("ume.keyAlert.cancel")}
</button>
</div>
</div>
<div className="muted ume-modal-form__label">{t("ume.keyAlert.neTypesLabel")}</div> <div className="muted ume-modal-form__label">{t("ume.keyAlert.neTypesLabel")}</div>
{keyAlertNeTypesQuery.isLoading ? ( {keyAlertNeTypesQuery.isLoading ? (
<span className="muted">{t("common.refreshing")}</span> <span className="muted">{t("common.refreshing")}</span>
@ -1798,29 +1746,32 @@ export function UmePage() {
</div> </div>
)} )}
<p className="muted ume-modal-form__hint">{t("ume.keyAlert.neTypesHint")}</p> <p className="muted ume-modal-form__hint">{t("ume.keyAlert.neTypesHint")}</p>
<div className="ops-detail-modal__foot"> </>
<span /> ) : null}
<div className="btn-row"> </Modal.Body>
<button <Modal.Footer>
type="button" <Button
onClick={() => variant="tertiary"
isDisabled={keyAlertEditMutation.isPending}
onPress={() => setKeyAlertEditRule(null)}
>
{t("ume.keyAlert.cancel")}
</Button>
<Button
variant="primary"
isDisabled={keyAlertEditMutation.isPending || !keyAlertEditRule}
onPress={() => {
if (!keyAlertEditRule) return;
keyAlertEditMutation.mutate({ keyAlertEditMutation.mutate({
ruleKey: keyAlertEditRule.notification_id, ruleKey: keyAlertEditRule.notification_id,
ne_types: keyAlertEditNeTypes, ne_types: keyAlertEditNeTypes,
}) });
} }}
disabled={keyAlertEditMutation.isPending}
> >
{keyAlertEditMutation.isPending ? t("ume.keyAlert.saving") : t("ume.keyAlert.save")} {keyAlertEditMutation.isPending ? t("ume.keyAlert.saving") : t("ume.keyAlert.save")}
</button> </Button>
<button type="button" onClick={() => setKeyAlertEditRule(null)} disabled={keyAlertEditMutation.isPending}> </Modal.Footer>
{t("ume.keyAlert.cancel")} </AppModalShell>
</button>
</div>
</div>
</div>
</div>
) : null}
</> </>
); );
} }

View file

@ -1,7 +1,9 @@
import { Button, Input } from "@heroui/react";
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { FieldSelect } from "../components/ui/FieldSelect";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import { apiGet, apiPatch, apiPost } from "../services/api"; import { apiGet, apiPatch, apiPost } from "../services/api";
@ -71,13 +73,13 @@ export function UsersPage() {
<div className="pt-list"> <div className="pt-list">
<form className="filter-inline" onSubmit={onCreate}> <form className="filter-inline" onSubmit={onCreate}>
<input <Input
placeholder={t("auth.username")} placeholder={t("auth.username")}
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
/> />
<input <Input
type="password" type="password"
placeholder={t("auth.password")} placeholder={t("auth.password")}
value={password} value={password}
@ -85,13 +87,13 @@ export function UsersPage() {
required required
minLength={8} minLength={8}
/> />
<select value={role} onChange={(e) => setRole(e.target.value)}> <FieldSelect value={role} onChange={(e) => setRole(e.target.value)}>
<option value="user">{t("auth.roleUser")}</option> <option value="user">{t("auth.roleUser")}</option>
<option value="admin">{t("auth.roleAdmin")}</option> <option value="admin">{t("auth.roleAdmin")}</option>
</select> </FieldSelect>
<button type="submit" disabled={createMut.isPending}> <Button type="submit" size="sm" variant="primary" isDisabled={createMut.isPending}>
{t("auth.addUser")} {t("auth.addUser")}
</button> </Button>
</form> </form>
{usersQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {usersQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -127,15 +129,16 @@ export function UsersPage() {
</td> </td>
<td> <td>
<div className="btn-row pt-list-actions table-actions"> <div className="btn-row pt-list-actions table-actions">
<button <Button
type="button" size="sm"
onClick={() => variant="secondary"
onPress={() =>
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } }) patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
} }
> >
{u.is_active ? t("auth.disable") : t("auth.enable")} {u.is_active ? t("auth.disable") : t("auth.enable")}
</button> </Button>
<select <FieldSelect
value={u.role} value={u.role}
onChange={(e) => onChange={(e) =>
patchMut.mutate({ id: u.id, body: { role: e.target.value } }) patchMut.mutate({ id: u.id, body: { role: e.target.value } })
@ -143,8 +146,8 @@ export function UsersPage() {
> >
<option value="user">{t("auth.roleUser")}</option> <option value="user">{t("auth.roleUser")}</option>
<option value="admin">{t("auth.roleAdmin")}</option> <option value="admin">{t("auth.roleAdmin")}</option>
</select> </FieldSelect>
<input <Input
type="password" type="password"
placeholder={t("auth.newPassword")} placeholder={t("auth.newPassword")}
value={resetPwd[u.id] || ""} value={resetPwd[u.id] || ""}
@ -153,17 +156,18 @@ export function UsersPage() {
} }
style={{ width: 140, minWidth: 140, flex: "0 0 auto" }} style={{ width: 140, minWidth: 140, flex: "0 0 auto" }}
/> />
<button <Button
type="button" size="sm"
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 8} variant="primary"
onClick={() => { isDisabled={!resetPwd[u.id] || resetPwd[u.id].length < 8}
onPress={() => {
const pwd = resetPwd[u.id]; const pwd = resetPwd[u.id];
patchMut.mutate({ id: u.id, body: { password: pwd } }); patchMut.mutate({ id: u.id, body: { password: pwd } });
setResetPwd((m) => ({ ...m, [u.id]: "" })); setResetPwd((m) => ({ ...m, [u.id]: "" }));
}} }}
> >
{t("auth.resetPassword")} {t("auth.resetPassword")}
</button> </Button>
</div> </div>
</td> </td>
</tr> </tr>

View file

@ -12,7 +12,9 @@ import {
} from "react"; } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { Button, Input, Modal } from "@heroui/react";
import type { WebTerminalHandle } from "../components/WebTerminal"; import type { WebTerminalHandle } from "../components/WebTerminal";
import { AppModalShell } from "../components/ui/AppModalShell";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import { import {
@ -50,7 +52,7 @@ import {
const WebTerminal = lazy(() => const WebTerminal = lazy(() =>
import("../components/WebTerminal").then((m) => ({ default: m.WebTerminal })), import("../components/WebTerminal").then((m) => ({ default: m.WebTerminal })),
); );
const PAGE_SIZE = 50; const PAGE_SIZE = 10;
const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions"; const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions";
const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs"; const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs";
const SFTP_WIDTH_KEY = "netx.webcrt.sftpWidth"; const SFTP_WIDTH_KEY = "netx.webcrt.sftpWidth";
@ -2222,12 +2224,26 @@ export function WebcrtPage() {
<div className="webcrt-sidebar__pager"> <div className="webcrt-sidebar__pager">
<span>{t("common.pagerMeta", { total, page, pages })}</span> <span>{t("common.pagerMeta", { total, page, pages })}</span>
<div className="webcrt-sidebar__pager-btns"> <div className="webcrt-sidebar__pager-btns">
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))} aria-label="prev"> <Button
size="sm"
variant="ghost"
isIconOnly
isDisabled={page <= 1}
aria-label="prev"
onPress={() => setPage((p) => Math.max(1, p - 1))}
>
‹ ‹
</button> </Button>
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)} aria-label="next"> <Button
size="sm"
variant="ghost"
isIconOnly
isDisabled={page >= pages}
aria-label="next"
onPress={() => setPage((p) => p + 1)}
>
› ›
</button> </Button>
</div> </div>
</div> </div>
</aside> </aside>
@ -2546,15 +2562,16 @@ export function WebcrtPage() {
onDrop={onSftpDrop} onDrop={onSftpDrop}
> >
<div className="webcrt-sftp__bar"> <div className="webcrt-sftp__bar">
<button <Button
type="button" size="sm"
title={t("webcrt.sftp.up")} variant="secondary"
disabled={sftpBusy} isDisabled={sftpBusy}
onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))} aria-label={t("webcrt.sftp.up")}
onPress={() => navigateSftp(sftpParentPath(sftpPathRef.current))}
> >
{t("webcrt.sftp.up")} {t("webcrt.sftp.up")}
</button> </Button>
<input <Input
value={sftpPath} value={sftpPath}
disabled={sftpBusy} disabled={sftpBusy}
onChange={(e) => { onChange={(e) => {
@ -2569,40 +2586,44 @@ export function WebcrtPage() {
} }
}} }}
/> />
<button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}> <Button size="sm" variant="secondary" isDisabled={sftpBusy} onPress={() => void refreshSftp()}>
{t("webcrt.sftp.refresh")} {t("webcrt.sftp.refresh")}
</button> </Button>
<button type="button" disabled={sftpBusy} onClick={() => void mkdirSftp()}> <Button size="sm" variant="secondary" isDisabled={sftpBusy} onPress={() => void mkdirSftp()}>
{t("webcrt.sftp.mkdir")} {t("webcrt.sftp.mkdir")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={sftpBusy || sftpSelected.length !== 1} variant="secondary"
onClick={() => void renameSftpSelected()} isDisabled={sftpBusy || sftpSelected.length !== 1}
onPress={() => void renameSftpSelected()}
> >
{t("webcrt.sftp.rename")} {t("webcrt.sftp.rename")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={sftpBusy || sftpSelectedFiles.length === 0} variant="secondary"
onClick={() => void downloadSftpItems(sftpSelectedFiles)} isDisabled={sftpBusy || sftpSelectedFiles.length === 0}
onPress={() => void downloadSftpItems(sftpSelectedFiles)}
> >
{t("webcrt.sftp.download")} {t("webcrt.sftp.download")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={sftpBusy || sftpSelected.length === 0} variant="secondary"
onClick={() => void chmodSftpSelected()} isDisabled={sftpBusy || sftpSelected.length === 0}
onPress={() => void chmodSftpSelected()}
> >
{t("webcrt.sftp.chmod")} {t("webcrt.sftp.chmod")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={sftpBusy || sftpSelected.length === 0} variant="danger"
onClick={() => void removeSftpSelected()} isDisabled={sftpBusy || sftpSelected.length === 0}
onPress={() => void removeSftpSelected()}
> >
{t("webcrt.sftp.delete")} {t("webcrt.sftp.delete")}
</button> </Button>
<label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}> <label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
{t("webcrt.sftp.upload")} {t("webcrt.sftp.upload")}
<input <input
@ -2623,13 +2644,14 @@ export function WebcrtPage() {
<div className="webcrt-sftp__status" role="status"> <div className="webcrt-sftp__status" role="status">
<span>{sftpStatus || t("webcrt.sftp.loading")}</span> <span>{sftpStatus || t("webcrt.sftp.loading")}</span>
{sftpTransferring ? ( {sftpTransferring ? (
<button <Button
type="button" size="sm"
variant="tertiary"
className="webcrt-sftp__cancel" className="webcrt-sftp__cancel"
onClick={cancelSftpTransfer} onPress={cancelSftpTransfer}
> >
{t("webcrt.sftp.cancel")} {t("webcrt.sftp.cancel")}
</button> </Button>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@ -2792,27 +2814,29 @@ export function WebcrtPage() {
</div> </div>
) : null} ) : null}
<AppModalShell
open={Boolean(renameDialog)}
onClose={() => setRenameDialog(null)}
size="md"
className="webcrt-auth-modal"
>
<Modal.Header>
<Modal.Heading>{t("webcrt.treeMenu.rename")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{renameDialog ? ( {renameDialog ? (
<div <>
className="modal-backdrop"
role="presentation"
onClick={() => setRenameDialog(null)}
>
<div
className="modal webcrt-auth-modal"
role="dialog"
aria-labelledby="webcrt-rename-title"
onClick={(e) => e.stopPropagation()}
>
<h3 id="webcrt-rename-title">{t("webcrt.treeMenu.rename")}</h3>
<p className="form-hint"> <p className="form-hint">
{renameDialog.target.ip_address} {renameDialog.target.ip_address}
{renameDialog.target.protocol ? ` · ${String(renameDialog.target.protocol).toUpperCase()}` : ""} {renameDialog.target.protocol
? ` · ${String(renameDialog.target.protocol).toUpperCase()}`
: ""}
</p> </p>
<div className="form-grid"> <div className="form-grid">
<label> <label>
<FormLabel required>{t("webcrt.treeMenu.renameLabel")}</FormLabel> <FormLabel required>{t("webcrt.treeMenu.renameLabel")}</FormLabel>
<input <Input
autoFocus autoFocus
value={renameDialog.name} value={renameDialog.name}
onChange={(e) => setRenameDialog({ ...renameDialog, name: e.target.value })} onChange={(e) => setRenameDialog({ ...renameDialog, name: e.target.value })}
@ -2822,20 +2846,23 @@ export function WebcrtPage() {
/> />
</label> </label>
</div> </div>
<div className="modal__actions"> </>
<button type="button" onClick={() => setRenameDialog(null)}> ) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={() => setRenameDialog(null)}>
{t("webcrt.sessionOptionsCancel")} {t("webcrt.sessionOptionsCancel")}
</button> </Button>
<button <Button
type="button" variant="primary"
onClick={() => void renameWebcrtSession(renameDialog.target, renameDialog.name)} onPress={() => {
if (renameDialog) void renameWebcrtSession(renameDialog.target, renameDialog.name);
}}
> >
{t("webcrt.treeMenu.renameSave")} {t("webcrt.treeMenu.renameSave")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null}
{tabMenu ? ( {tabMenu ? (
<div <div
@ -2891,20 +2918,17 @@ export function WebcrtPage() {
</div> </div>
) : null} ) : null}
{keywordHlModalOpen ? ( <AppModalShell
<div open={keywordHlModalOpen}
className="modal-backdrop" onClose={() => setKeywordHlModalOpen(false)}
role="presentation" size="md"
onClick={() => setKeywordHlModalOpen(false)} className="webcrt-keyword-modal"
> >
<div <Modal.Header>
className="modal webcrt-keyword-modal" <Modal.Heading>{t("webcrt.keywordHl.title")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger />
aria-modal="true" </Modal.Header>
aria-labelledby="webcrt-keyword-hl-title" <Modal.Body className="flex flex-col gap-3">
onClick={(e) => e.stopPropagation()}
>
<h3 id="webcrt-keyword-hl-title">{t("webcrt.keywordHl.title")}</h3>
<label className="webcrt-session-opts__field webcrt-session-opts__field--check"> <label className="webcrt-session-opts__field webcrt-session-opts__field--check">
<span>{t("webcrt.keywordHl.enabled")}</span> <span>{t("webcrt.keywordHl.enabled")}</span>
<input <input
@ -2918,8 +2942,7 @@ export function WebcrtPage() {
<div className="webcrt-keyword-add"> <div className="webcrt-keyword-add">
<label className="webcrt-session-opts__field"> <label className="webcrt-session-opts__field">
<span>{t("webcrt.keywordHl.newKeyword")}</span> <span>{t("webcrt.keywordHl.newKeyword")}</span>
<input <Input
type="text"
value={keywordDraftText} value={keywordDraftText}
placeholder={t("webcrt.keywordHl.newKeywordPh")} placeholder={t("webcrt.keywordHl.newKeywordPh")}
onChange={(e) => setKeywordDraftText(e.target.value)} onChange={(e) => setKeywordDraftText(e.target.value)}
@ -2951,9 +2974,10 @@ export function WebcrtPage() {
/> />
<span>{t("webcrt.keywordHl.regex")}</span> <span>{t("webcrt.keywordHl.regex")}</span>
</label> </label>
<button <Button
type="button" size="sm"
onClick={() => { variant="secondary"
onPress={() => {
const pattern = keywordDraftText.trim(); const pattern = keywordDraftText.trim();
if (!pattern) return; if (!pattern) return;
const row: KeywordRule = { const row: KeywordRule = {
@ -2971,7 +2995,7 @@ export function WebcrtPage() {
}} }}
> >
{t("webcrt.keywordHl.add")} {t("webcrt.keywordHl.add")}
</button> </Button>
</div> </div>
<div className="webcrt-keyword-list-wrap"> <div className="webcrt-keyword-list-wrap">
<div className="webcrt-keyword-list-head"> <div className="webcrt-keyword-list-head">
@ -3001,11 +3025,13 @@ export function WebcrtPage() {
)} )}
</ul> </ul>
<div className="webcrt-keyword-list-actions"> <div className="webcrt-keyword-list-actions">
<button <Button
type="button" size="sm"
disabled={!keywordSelectedId} variant="ghost"
title={t("webcrt.keywordHl.moveUp")} isIconOnly
onClick={() => { isDisabled={!keywordSelectedId}
aria-label={t("webcrt.keywordHl.moveUp")}
onPress={() => {
setKeywordHlDraft((prev) => { setKeywordHlDraft((prev) => {
const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId); const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId);
if (idx <= 0) return prev; if (idx <= 0) return prev;
@ -3016,12 +3042,14 @@ export function WebcrtPage() {
}} }}
> >
↑ ↑
</button> </Button>
<button <Button
type="button" size="sm"
disabled={!keywordSelectedId} variant="ghost"
title={t("webcrt.keywordHl.moveDown")} isIconOnly
onClick={() => { isDisabled={!keywordSelectedId}
aria-label={t("webcrt.keywordHl.moveDown")}
onPress={() => {
setKeywordHlDraft((prev) => { setKeywordHlDraft((prev) => {
const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId); const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId);
if (idx < 0 || idx >= prev.keywords.length - 1) return prev; if (idx < 0 || idx >= prev.keywords.length - 1) return prev;
@ -3032,12 +3060,14 @@ export function WebcrtPage() {
}} }}
> >
↓ ↓
</button> </Button>
<button <Button
type="button" size="sm"
disabled={!keywordSelectedId} variant="danger"
title={t("webcrt.keywordHl.remove")} isIconOnly
onClick={() => { isDisabled={!keywordSelectedId}
aria-label={t("webcrt.keywordHl.remove")}
onPress={() => {
setKeywordHlDraft((prev) => { setKeywordHlDraft((prev) => {
const next = prev.keywords.filter((k) => k.id !== keywordSelectedId); const next = prev.keywords.filter((k) => k.id !== keywordSelectedId);
setKeywordSelectedId(next[0]?.id || ""); setKeywordSelectedId(next[0]?.id || "");
@ -3046,7 +3076,7 @@ export function WebcrtPage() {
}} }}
> >
× ×
</button> </Button>
</div> </div>
</div> </div>
<div className="webcrt-keyword-footer"> <div className="webcrt-keyword-footer">
@ -3071,13 +3101,14 @@ export function WebcrtPage() {
/> />
</label> </label>
</div> </div>
<div className="modal__actions"> </Modal.Body>
<button type="button" onClick={() => setKeywordHlModalOpen(false)}> <Modal.Footer>
<Button variant="tertiary" onPress={() => setKeywordHlModalOpen(false)}>
{t("webcrt.sessionOptionsCancel")} {t("webcrt.sessionOptionsCancel")}
</button> </Button>
<button <Button
type="button" variant="primary"
onClick={() => { onPress={() => {
const keywords = keywordHlDraft.keywords const keywords = keywordHlDraft.keywords
.map((k) => ({ .map((k) => ({
id: k.id || newKeywordId(), id: k.id || newKeywordId(),
@ -3100,26 +3131,21 @@ export function WebcrtPage() {
}} }}
> >
{t("webcrt.sessionOptionsSave")} {t("webcrt.sessionOptionsSave")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null}
{sessionOptsModalOpen ? ( <AppModalShell
<div open={sessionOptsModalOpen}
className="modal-backdrop" onClose={() => setSessionOptsModalOpen(false)}
role="presentation" size="md"
onClick={() => setSessionOptsModalOpen(false)} className="webcrt-session-opts-modal"
> >
<div <Modal.Header>
className="modal webcrt-session-opts-modal" <Modal.Heading>{t("webcrt.globalSessionOptions")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger />
aria-modal="true" </Modal.Header>
aria-labelledby="webcrt-session-opts-title" <Modal.Body className="flex flex-col gap-3">
onClick={(e) => e.stopPropagation()}
>
<h3 id="webcrt-session-opts-title">{t("webcrt.globalSessionOptions")}</h3>
<div className="webcrt-session-opts"> <div className="webcrt-session-opts">
<label className="webcrt-session-opts__field"> <label className="webcrt-session-opts__field">
<span>{t("webcrt.encoding")}</span> <span>{t("webcrt.encoding")}</span>
@ -3268,13 +3294,14 @@ export function WebcrtPage() {
</select> </select>
</label> </label>
</div> </div>
<div className="modal__actions"> </Modal.Body>
<button type="button" onClick={() => setSessionOptsModalOpen(false)}> <Modal.Footer>
<Button variant="tertiary" onPress={() => setSessionOptsModalOpen(false)}>
{t("webcrt.sessionOptionsCancel")} {t("webcrt.sessionOptionsCancel")}
</button> </Button>
<button <Button
type="button" variant="primary"
onClick={() => { onPress={() => {
const next = normalizeSessionOptions(sessionOptsDraft); const next = normalizeSessionOptions(sessionOptsDraft);
saveSessionOptions(next); saveSessionOptions(next);
setSessionOpts(next); setSessionOpts(next);
@ -3283,27 +3310,26 @@ export function WebcrtPage() {
}} }}
> >
{t("webcrt.sessionOptionsSave")} {t("webcrt.sessionOptionsSave")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null}
{authDialog ? ( <AppModalShell
<div open={Boolean(authDialog)}
className="modal-backdrop" onClose={() => {
role="presentation"
onClick={() => {
if (!sessionBusy) setAuthDialog(null); if (!sessionBusy) setAuthDialog(null);
}} }}
dismissible={!sessionBusy}
size="md"
className="webcrt-auth-modal"
> >
<div <Modal.Header>
className="modal webcrt-auth-modal" <Modal.Heading>{t("webcrt.newSession.authTitle")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger isDisabled={sessionBusy} />
aria-labelledby="webcrt-auth-title" </Modal.Header>
onClick={(e) => e.stopPropagation()} <Modal.Body className="flex flex-col gap-3">
> {authDialog ? (
<h3 id="webcrt-auth-title">{t("webcrt.newSession.authTitle")}</h3> <>
<p className="form-hint"> <p className="form-hint">
{authDialog.host.ip_address} {authDialog.host.ip_address}
{authDialog.host.port ? `:${authDialog.host.port}` : ""} {authDialog.host.port ? `:${authDialog.host.port}` : ""}
@ -3315,7 +3341,7 @@ export function WebcrtPage() {
<div className="form-grid"> <div className="form-grid">
<label> <label>
<FormLabel required>{t("managedNe.col.user")}</FormLabel> <FormLabel required>{t("managedNe.col.user")}</FormLabel>
<input <Input
required required
autoFocus autoFocus
value={authForm.username} value={authForm.username}
@ -3324,7 +3350,7 @@ export function WebcrtPage() {
</label> </label>
<label> <label>
<FormLabel required>{t("managedNe.col.password")}</FormLabel> <FormLabel required>{t("managedNe.col.password")}</FormLabel>
<input <Input
type="password" type="password"
required required
value={authForm.password} value={authForm.password}
@ -3343,40 +3369,40 @@ export function WebcrtPage() {
<span className="form-check__text">{t("webcrt.newSession.savePassword")}</span> <span className="form-check__text">{t("webcrt.newSession.savePassword")}</span>
</label> </label>
</div> </div>
<div className="modal__actions"> </>
<button type="button" disabled={sessionBusy} onClick={() => setAuthDialog(null)}>
{t("webcrt.sessionOptionsCancel")}
</button>
<button type="button" disabled={sessionBusy} onClick={() => void submitAuthDialog()}>
{sessionBusy ? t("webcrt.newSession.connecting") : t("webcrt.newSession.connect")}
</button>
</div>
</div>
</div>
) : null} ) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" isDisabled={sessionBusy} onPress={() => setAuthDialog(null)}>
{t("webcrt.sessionOptionsCancel")}
</Button>
<Button variant="primary" isDisabled={sessionBusy} onPress={() => void submitAuthDialog()}>
{sessionBusy ? t("webcrt.newSession.connecting") : t("webcrt.newSession.connect")}
</Button>
</Modal.Footer>
</AppModalShell>
{hostDialogOpen ? ( <AppModalShell
<div open={hostDialogOpen}
className="modal-backdrop" onClose={() => {
role="presentation"
onClick={() => {
if (!sessionBusy) { if (!sessionBusy) {
setHostDialogOpen(false); setHostDialogOpen(false);
setHostDialogClaimNeId(null); setHostDialogClaimNeId(null);
} }
}} }}
dismissible={!sessionBusy}
size="md"
className="webcrt-new-session-modal"
> >
<div <Modal.Header>
className="modal webcrt-new-session-modal" <Modal.Heading>
role="dialog"
aria-labelledby="webcrt-new-session-title"
onClick={(e) => e.stopPropagation()}
>
<h3 id="webcrt-new-session-title">
{hostDialogClaimNeId {hostDialogClaimNeId
? t("webcrt.newSession.claimTitle") ? t("webcrt.newSession.claimTitle")
: t("webcrt.newSession.title")} : t("webcrt.newSession.title")}
</h3> </Modal.Heading>
<Modal.CloseTrigger isDisabled={sessionBusy} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{hostDialogClaimNeId ? ( {hostDialogClaimNeId ? (
<p className="form-hint">{t("webcrt.newSession.claimHint")}</p> <p className="form-hint">{t("webcrt.newSession.claimHint")}</p>
) : null} ) : null}
@ -3400,7 +3426,7 @@ export function WebcrtPage() {
</label> </label>
<label> <label>
<FormLabel required>{t("webcrt.newSession.host")}</FormLabel> <FormLabel required>{t("webcrt.newSession.host")}</FormLabel>
<input <Input
required required
autoFocus autoFocus
value={hostForm.ip_address} value={hostForm.ip_address}
@ -3410,9 +3436,9 @@ export function WebcrtPage() {
</label> </label>
<label> <label>
<FormLabel>{t("webcrt.newSession.port")}</FormLabel> <FormLabel>{t("webcrt.newSession.port")}</FormLabel>
<input <Input
type="number" type="number"
value={hostForm.port} value={String(hostForm.port)}
onChange={(e) => onChange={(e) =>
setHostForm({ setHostForm({
...hostForm, ...hostForm,
@ -3423,31 +3449,30 @@ export function WebcrtPage() {
</label> </label>
<label> <label>
<FormLabel>{t("webcrt.newSession.sessionName")}</FormLabel> <FormLabel>{t("webcrt.newSession.sessionName")}</FormLabel>
<input <Input
value={hostForm.name} value={hostForm.name}
placeholder={t("webcrt.newSession.sessionNamePh")} placeholder={t("webcrt.newSession.sessionNamePh")}
onChange={(e) => setHostForm({ ...hostForm, name: e.target.value })} onChange={(e) => setHostForm({ ...hostForm, name: e.target.value })}
/> />
</label> </label>
</div> </div>
<div className="modal__actions"> </Modal.Body>
<button <Modal.Footer>
type="button" <Button
disabled={sessionBusy} variant="tertiary"
onClick={() => { isDisabled={sessionBusy}
onPress={() => {
setHostDialogOpen(false); setHostDialogOpen(false);
setHostDialogClaimNeId(null); setHostDialogClaimNeId(null);
}} }}
> >
{t("webcrt.sessionOptionsCancel")} {t("webcrt.sessionOptionsCancel")}
</button> </Button>
<button type="button" disabled={sessionBusy} onClick={() => submitHostDialog()}> <Button variant="primary" isDisabled={sessionBusy} onPress={() => submitHostDialog()}>
{sessionBusy ? t("webcrt.newSession.connecting") : t("webcrt.newSession.connect")} {sessionBusy ? t("webcrt.newSession.connecting") : t("webcrt.newSession.connect")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null}
</div> </div>
); );

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@heroui/react";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon"; import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules"; import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules";
@ -250,16 +251,16 @@ export function WorkbenchPage() {
</div> </div>
<div className="wb-grid"> <div className="wb-grid">
{mods.map((mod) => ( {mods.map((mod) => (
<button <Button
key={mod.moduleId} key={mod.moduleId}
type="button" variant="ghost"
className={`wb-card wb-card--${mod.iconTone}`} className={`wb-card wb-card--${mod.iconTone}`}
title={t("workbench.openModule")} aria-label={t("workbench.openModule")}
onClick={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })} onPress={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
> >
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} /> <WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
<span className="wb-card__label">{t(mod.labelKey)}</span> <span className="wb-card__label">{t(mod.labelKey)}</span>
</button> </Button>
))} ))}
</div> </div>
</section> </section>

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useMemo } from "react"; import { useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@ -87,9 +88,14 @@ export function TaskOverviewPage() {
<section className="panel"> <section className="panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("audit.tasks.title")}</h2> <h2>{t("audit.tasks.title")}</h2>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}> <Button
size="sm"
variant="secondary"
onPress={() => void query.refetch()}
isDisabled={query.isFetching}
>
{query.isFetching ? t("common.refreshing") : t("common.refresh")} {query.isFetching ? t("common.refreshing") : t("common.refresh")}
</button> </Button>
</div> </div>
<p className="panel__hint">{t("audit.tasks.hint")}</p> <p className="panel__hint">{t("audit.tasks.hint")}</p>

View file

@ -1,5 +1,7 @@
import { Alert, Button, Chip, Modal } from "@heroui/react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { connectTestManagedNe } from "../../services/api"; import { connectTestManagedNe } from "../../services/api";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
import type { ManagedNeItem } from "../../types"; import type { ManagedNeItem } from "../../types";
@ -12,6 +14,13 @@ export type ManagedNeConnectDetailDialogProps = {
onRetestSubmitted?: (rowId: string) => void; onRetestSubmitted?: (rowId: string) => void;
}; };
function chipColor(level: string): "success" | "danger" | "warning" | "default" {
if (level === "up" || level === "pass") return "success";
if (level === "down" || level === "fail") return "danger";
if (level === "testing") return "warning";
return "default";
}
export function ManagedNeConnectDetailDialog({ export function ManagedNeConnectDetailDialog({
row, row,
onClose, onClose,
@ -25,47 +34,61 @@ export function ManagedNeConnectDetailDialog({
}, },
}); });
if (!row) return null; const open = Boolean(row);
const level = row ? connectPillLevel(row.connect_status) : "unknown";
return ( return (
<div className="modal-backdrop" role="presentation" onClick={onClose}> <AppModalShell open={open} onClose={onClose} size="lg">
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}> <Modal.Header>
<h3>{t("managedNe.connectDetailTitle")}</h3> <Modal.Heading>{t("managedNe.connectDetailTitle")}</Modal.Heading>
<p className="form-hint"> <Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{row ? (
<>
<p className="text-sm text-muted">
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol} {row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
{row.connect_tested_at {row.connect_tested_at
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}` ? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
: ""} : ""}
</p> </p>
<p> <div className="flex flex-wrap items-center gap-2">
<span className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`}> <Chip size="sm" color={chipColor(level)} variant="soft">
{row.connect_status} <Chip.Label>{row.connect_status}</Chip.Label>
</span> </Chip>
{row.connect_message ? ( {row.connect_message ? (
<span className="connect-detail-summary"> — {row.connect_message}</span> <span className="connect-detail-summary text-sm">— {row.connect_message}</span>
) : null} ) : null}
</p> </div>
<pre className="connect-log"> <pre className="connect-log">
{row.connect_detail?.trim() || row.connect_message?.trim() || t("managedNe.connectDetailEmpty")} {row.connect_detail?.trim() ||
row.connect_message?.trim() ||
t("managedNe.connectDetailEmpty")}
</pre> </pre>
<div className="modal__actions"> </>
<button ) : null}
type="button" {connectMutation.isError ? (
disabled={connectMutation.isPending} <Alert status="danger">
onClick={() => connectMutation.mutate([row.id])} <Alert.Content>
<Alert.Description>{String(connectMutation.error)}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</Modal.Body>
<Modal.Footer>
<Button
variant="primary"
isDisabled={!row || connectMutation.isPending}
onPress={() => {
if (row) connectMutation.mutate([row.id]);
}}
> >
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")} {connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
</button> </Button>
<button type="button" onClick={onClose}> <Button variant="tertiary" onPress={onClose}>
{t("managedNe.form.cancel")} {t("managedNe.form.cancel")}
</button> </Button>
</div> </Modal.Footer>
{connectMutation.isError ? ( </AppModalShell>
<p className="form-hint" role="alert">
{String(connectMutation.error)}
</p>
) : null}
</div>
</div>
); );
} }

View file

@ -1,7 +1,10 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Alert, Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../services/api"; import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../services/api";
import { HopProxyFields } from "../../components/HopProxyFields"; import { HopProxyFields } from "../../components/HopProxyFields";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { patchHopVendorChange } from "../../utils/hopProxy"; import { patchHopVendorChange } from "../../utils/hopProxy";
@ -14,20 +17,6 @@ import {
type ManagedNeFormState, type ManagedNeFormState,
} from "./formState"; } from "./formState";
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
export type ManagedNeFormDialogProps = { export type ManagedNeFormDialogProps = {
open: boolean; open: boolean;
/** null = create mode */ /** null = create mode */
@ -55,8 +44,6 @@ export function ManagedNeFormDialog({
enabled: open, enabled: open,
}); });
// Reset only when the dialog opens or the edited row changes — avoid clobbering
// in-progress edits if the parent re-renders with a new initialValues object identity.
const editingId = editing?.id ?? ""; const editingId = editing?.id ?? "";
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -94,29 +81,28 @@ export function ManagedNeFormDialog({
}, },
}); });
if (!open) return null;
return ( return (
<div <AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">
className="modal-backdrop" <Modal.Header>
role="presentation" <Modal.Heading>
onClick={() => { {editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}
if (saveMutation.isPending) return; </Modal.Heading>
onClose(); <Modal.CloseTrigger isDisabled={saveMutation.isPending} />
}} </Modal.Header>
> <Modal.Body className="flex flex-col gap-3">
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}> <p className="text-sm text-muted">{t("managedNe.form.requiredHint")}</p>
<h3>{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}</h3>
<p className="form-hint">{t("managedNe.form.requiredHint")}</p>
<div className="form-grid"> <div className="form-grid">
<label> <TextField
<FormLabel>{t("managedNe.col.name")}</FormLabel> fullWidth
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> value={form.name}
onChange={(name) => setForm({ ...form, name })}
>
<Label>{t("managedNe.col.name")}</Label>
<Input />
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span> <span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
</label> </TextField>
<label> <FieldSelect
<FormLabel required>{t("managedNe.col.vendor")}</FormLabel> label={t("managedNe.col.vendor")}
<select
required required
value={form.vendor} value={form.vendor}
onChange={(e) => { onChange={(e) => {
@ -140,11 +126,9 @@ export function ManagedNeFormDialog({
{v} {v}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> <FieldSelect
<label> label={t("managedNe.col.deviceType")}
<FormLabel required>{t("managedNe.col.deviceType")}</FormLabel>
<select
required required
value={form.device_type} value={form.device_type}
onChange={(e) => setForm({ ...form, device_type: e.target.value })} onChange={(e) => setForm({ ...form, device_type: e.target.value })}
@ -154,68 +138,74 @@ export function ManagedNeFormDialog({
{dt} {dt}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> <TextField
<label> fullWidth
<FormLabel required>{t("managedNe.col.ip")}</FormLabel> isRequired
<input
required
value={form.ip_address} value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })} onChange={(ip_address) => setForm({ ...form, ip_address })}
/> >
</label> <Label>{t("managedNe.col.ip")}</Label>
<label> <Input />
<FormLabel>{t("managedNe.col.port")}</FormLabel> </TextField>
<input <TextField
fullWidth
type="number" type="number"
value={form.port} value={String(form.port)}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 22 })} onChange={(port) => setForm({ ...form, port: Number(port) || 22 })}
/> >
</label> <Label>{t("managedNe.col.port")}</Label>
<label> <Input />
<FormLabel>{t("managedNe.col.protocol")}</FormLabel> </TextField>
<select value={form.protocol} onChange={(e) => setForm({ ...form, protocol: e.target.value })}> <FieldSelect
label={t("managedNe.col.protocol")}
value={form.protocol}
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
>
<option value="ssh">ssh</option> <option value="ssh">ssh</option>
<option value="telnet">telnet</option> <option value="telnet">telnet</option>
</select> </FieldSelect>
</label> <TextField
<label> fullWidth
<FormLabel required>{t("managedNe.col.user")}</FormLabel> isRequired
<input
required
value={form.username} value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })} onChange={(username) => setForm({ ...form, username })}
/> >
</label> <Label>{t("managedNe.col.user")}</Label>
<label> <Input />
<FormLabel> </TextField>
{t("managedNe.col.password")} <TextField
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span> fullWidth
</FormLabel>
<input
type="password" type="password"
value={form.password} value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} onChange={(password) => setForm({ ...form, password })}
/> >
</label> <Label>
<label> {t("managedNe.col.password")}
<FormLabel>{t("managedNe.col.tags")}</FormLabel> <span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
<input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} /> </Label>
</label> <Input />
<label className="form-grid__full"> </TextField>
<FormLabel>{t("managedNe.col.remark")}</FormLabel> <TextField fullWidth value={form.tags} onChange={(tags) => setForm({ ...form, tags })}>
<input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} /> <Label>{t("managedNe.col.tags")}</Label>
</label> <Input />
</TextField>
<TextField
fullWidth
className="form-grid__full"
value={form.remark}
onChange={(remark) => setForm({ ...form, remark })}
>
<Label>{t("managedNe.col.remark")}</Label>
<Input />
</TextField>
</div> </div>
<fieldset className="form-fieldset form-grid__full"> <fieldset className="form-fieldset form-grid__full">
<legend>{t("managedNe.hop.sectionTitle")}</legend> <legend>{t("managedNe.hop.sectionTitle")}</legend>
<label className="form-check"> <Checkbox
<input isSelected={form.hop_enabled}
type="checkbox" onChange={(hop_enabled) => {
checked={form.hop_enabled}
onChange={(e) => {
const hop_enabled = e.target.checked;
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
hop_enabled, hop_enabled,
@ -227,9 +217,12 @@ export function ManagedNeFormDialog({
: {}), : {}),
})); }));
}} }}
/> >
<span className="form-check__text">{t("managedNe.hop.enable")}</span> <Checkbox.Control>
</label> <Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
</Checkbox>
{form.hop_enabled ? ( {form.hop_enabled ? (
<HopProxyFields <HopProxyFields
value={{ value={{
@ -250,24 +243,26 @@ export function ManagedNeFormDialog({
/> />
) : null} ) : null}
</fieldset> </fieldset>
<div className="modal__actions"> {saveMutation.isError ? (
<button type="button" disabled={saveMutation.isPending} onClick={onClose}> <Alert status="danger">
<Alert.Content>
<Alert.Description>{String(saveMutation.error)}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" isDisabled={saveMutation.isPending} onPress={onClose}>
{t("managedNe.form.cancel")} {t("managedNe.form.cancel")}
</button> </Button>
<button <Button
type="button" variant="primary"
disabled={saveMutation.isPending} isDisabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()} onPress={() => saveMutation.mutate()}
> >
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")} {saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button> </Button>
</div> </Modal.Footer>
{saveMutation.isError ? ( </AppModalShell>
<p className="form-hint" role="alert">
{String(saveMutation.error)}
</p>
) : null}
</div>
</div>
); );
} }

View file

@ -1,8 +1,11 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager"; import { ListPager } from "../../components/ListPager";
import { HelpHint } from "../../components/HelpHint"; import { HelpHint } from "../../components/HelpHint";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue"; import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast"; import { useToast } from "../../hooks/useToast";
@ -32,7 +35,7 @@ import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
const POLL_MS = 2500; const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 20; const TARGET_PAGE_SIZE = 10;
const SEP = " · "; const SEP = " · ";
function lldpJobStatusLabel(t: (k: string) => string, status: string): string { function lldpJobStatusLabel(t: (k: string) => string, status: string): string {
@ -99,14 +102,14 @@ export function LldpLinksPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [jobPage, setJobPage] = useState(1); const [jobPage, setJobPage] = useState(1);
const [jobPageSize, setJobPageSize] = useState(20); const [jobPageSize, setJobPageSize] = useState(10);
const [jobStatus, setJobStatus] = useState(""); const [jobStatus, setJobStatus] = useState("");
const [jobKeyword, setJobKeyword] = useState(""); const [jobKeyword, setJobKeyword] = useState("");
const [exportingJobs, setExportingJobs] = useState(false); const [exportingJobs, setExportingJobs] = useState(false);
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300); const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
const [expandedJobId, setExpandedJobId] = useState(""); const [expandedJobId, setExpandedJobId] = useState("");
const [itemPage, setItemPage] = useState(1); const [itemPage, setItemPage] = useState(1);
const [itemPageSize, setItemPageSize] = useState(20); const [itemPageSize, setItemPageSize] = useState(10);
const [itemStatus, setItemStatus] = useState(""); const [itemStatus, setItemStatus] = useState("");
const [itemKeyword, setItemKeyword] = useState(""); const [itemKeyword, setItemKeyword] = useState("");
const [exportingItems, setExportingItems] = useState(false); const [exportingItems, setExportingItems] = useState(false);
@ -117,7 +120,7 @@ export function LldpLinksPage() {
const [edgeSource, setEdgeSource] = useState(""); const [edgeSource, setEdgeSource] = useState("");
const [edgeKeyword, setEdgeKeyword] = useState(""); const [edgeKeyword, setEdgeKeyword] = useState("");
const [edgePage, setEdgePage] = useState(1); const [edgePage, setEdgePage] = useState(1);
const [edgePageSize, setEdgePageSize] = useState(50); const [edgePageSize, setEdgePageSize] = useState(10);
const [exportingEdges, setExportingEdges] = useState(false); const [exportingEdges, setExportingEdges] = useState(false);
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300); const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
@ -554,45 +557,62 @@ export function LldpLinksPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("lldpLinks.title")}</h2> <h2>{t("lldpLinks.title")}</h2>
<div className="btn-row"> <div className="btn-row">
<button type="button" onClick={() => void refresh()} disabled={dashQuery.isFetching}> <Button
size="sm"
variant="secondary"
isDisabled={dashQuery.isFetching}
onPress={() => void refresh()}
>
{t("common.refresh")} {t("common.refresh")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn-primary" variant="primary"
disabled={Boolean(running) || startMut.isPending} isDisabled={Boolean(running) || startMut.isPending}
onClick={() => startMut.mutate("full")} onPress={() => startMut.mutate("full")}
> >
{t("lldpLinks.collectNow")} {t("lldpLinks.collectNow")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={Boolean(running) || startMut.isPending || !(last && (last.fail_count || 0) > 0)} variant="secondary"
onClick={() => startMut.mutate("retry_failed")} isDisabled={Boolean(running) || startMut.isPending || !(last && (last.fail_count || 0) > 0)}
onPress={() => startMut.mutate("retry_failed")}
> >
{t("lldpLinks.retryFailed")} {t("lldpLinks.retryFailed")}
</button> </Button>
{running?.status === "running" || running?.status === "pending" ? ( {running?.status === "running" || running?.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(running.id)} disabled={pauseMut.isPending}> <Button
size="sm"
variant="secondary"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(running.id)}
>
{t("lldpLinks.pause")} {t("lldpLinks.pause")}
</button> </Button>
) : null} ) : null}
{running?.status === "paused" ? ( {running?.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(running.id)} disabled={resumeMut.isPending}> <Button
size="sm"
variant="secondary"
isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(running.id)}
>
{t("lldpLinks.resume")} {t("lldpLinks.resume")}
</button> </Button>
) : null} ) : null}
{running && {running &&
(running.status === "running" || running.status === "paused" || running.status === "pending") ? ( (running.status === "running" || running.status === "paused" || running.status === "pending") ? (
<button <Button
type="button" size="sm"
onClick={() => { variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(running.id); if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(running.id);
}} }}
disabled={stopMut.isPending}
> >
{t("lldpLinks.stop")} {t("lldpLinks.stop")}
</button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>
@ -654,11 +674,11 @@ export function LldpLinksPage() {
</label> </label>
<label className="config-sync-policy-field"> <label className="config-sync-policy-field">
<span>{t("lldpLinks.interval")}</span> <span>{t("lldpLinks.interval")}</span>
<input <Input
type="number" type="number"
min={1} min={1}
max={intervalUnit === "days" ? 365 : 8760} max={intervalUnit === "days" ? 365 : 8760}
value={intervalValue} value={String(intervalValue)}
onChange={(e) => { onChange={(e) => {
const max = intervalUnit === "days" ? 365 : 8760; const max = intervalUnit === "days" ? 365 : 8760;
setIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1))); setIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
@ -711,14 +731,13 @@ export function LldpLinksPage() {
<option value="selected">{t("lldpLinks.scopeSelected")}</option> <option value="selected">{t("lldpLinks.scopeSelected")}</option>
</select> </select>
</label> </label>
<button <Button
type="button" variant="primary"
className="btn-primary" isDisabled={savePolicyMut.isPending}
disabled={savePolicyMut.isPending} onPress={() => savePolicyMut.mutate()}
onClick={() => savePolicyMut.mutate()}
> >
{t("lldpLinks.savePolicy")} {t("lldpLinks.savePolicy")}
</button> </Button>
</div> </div>
{scopeMode === "selected" ? ( {scopeMode === "selected" ? (
@ -767,9 +786,14 @@ export function LldpLinksPage() {
</tbody> </tbody>
</table> </table>
<div className="pager"> <div className="pager">
<button type="button" disabled={targetPage <= 1} onClick={() => setTargetPage((p) => p - 1)}> <Button
size="sm"
variant="ghost"
isDisabled={targetPage <= 1}
onPress={() => setTargetPage((p) => p - 1)}
>
{t("common.prevPage")} {t("common.prevPage")}
</button> </Button>
<span className="muted"> <span className="muted">
{t("common.pagerMeta", { {t("common.pagerMeta", {
total: String(targetsQuery.data?.total ?? 0), total: String(targetsQuery.data?.total ?? 0),
@ -777,13 +801,14 @@ export function LldpLinksPage() {
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)), pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
})} })}
</span> </span>
<button <Button
type="button" size="sm"
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)} variant="ghost"
onClick={() => setTargetPage((p) => p + 1)} isDisabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onPress={() => setTargetPage((p) => p + 1)}
> >
{t("common.nextPage")} {t("common.nextPage")}
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
@ -791,7 +816,7 @@ export function LldpLinksPage() {
<h3>{t("lldpLinks.edgesTitle")}</h3> <h3>{t("lldpLinks.edgesTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}> <div className="filter-inline" style={{ marginBottom: 8 }}>
<select <FieldSelect
value={edgeStatus} value={edgeStatus}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
@ -802,8 +827,8 @@ export function LldpLinksPage() {
<option value="all">{t("lldpLinks.edgeStatusAll")}</option> <option value="all">{t("lldpLinks.edgeStatusAll")}</option>
<option value="active">{t("lldpLinks.edgeStatusActive")}</option> <option value="active">{t("lldpLinks.edgeStatusActive")}</option>
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option> <option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
</select> </FieldSelect>
<select <FieldSelect
value={edgeSource} value={edgeSource}
onChange={(e) => { onChange={(e) => {
setEdgeSource(e.target.value); setEdgeSource(e.target.value);
@ -813,8 +838,8 @@ export function LldpLinksPage() {
<option value="">{t("lldpLinks.edgeSourceAll")}</option> <option value="">{t("lldpLinks.edgeSourceAll")}</option>
<option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option> <option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option>
<option value="manual">{t("lldpLinks.edgeSource.manual")}</option> <option value="manual">{t("lldpLinks.edgeSource.manual")}</option>
</select> </FieldSelect>
<input <Input
value={edgeKeyword} value={edgeKeyword}
placeholder={t("lldpLinks.edgeKeywordPh")} placeholder={t("lldpLinks.edgeKeywordPh")}
onChange={(e) => { onChange={(e) => {
@ -822,13 +847,14 @@ export function LldpLinksPage() {
setEdgePage(1); setEdgePage(1);
}} }}
/> />
<button <Button
type="button" size="sm"
disabled={exportingEdges || edgeTotal === 0} variant="secondary"
onClick={() => void exportEdgesCsv()} isDisabled={exportingEdges || edgeTotal === 0}
onPress={() => void exportEdgesCsv()}
> >
{exportingEdges ? t("common.exporting") : t("common.exportCsv")} {exportingEdges ? t("common.exporting") : t("common.exportCsv")}
</button> </Button>
</div> </div>
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
@ -893,7 +919,7 @@ export function LldpLinksPage() {
pages={edgePages} pages={edgePages}
total={edgeTotal} total={edgeTotal}
pageSize={edgePageSize} pageSize={edgePageSize}
pageSizeOptions={[20, 50, 100, 200]} pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setEdgePage} onPageChange={setEdgePage}
onPageSizeChange={(size) => { onPageSizeChange={(size) => {
setEdgePageSize(size); setEdgePageSize(size);
@ -904,7 +930,7 @@ export function LldpLinksPage() {
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3> <h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}> <div className="filter-inline" style={{ marginBottom: 8 }}>
<select <FieldSelect
value={jobStatus} value={jobStatus}
onChange={(e) => { onChange={(e) => {
setJobStatus(e.target.value); setJobStatus(e.target.value);
@ -919,8 +945,8 @@ export function LldpLinksPage() {
<option value="failed">{t("lldpLinks.jobStatus.failed")}</option> <option value="failed">{t("lldpLinks.jobStatus.failed")}</option>
<option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option> <option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option>
<option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option> <option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option>
</select> </FieldSelect>
<input <Input
value={jobKeyword} value={jobKeyword}
placeholder={t("lldpLinks.jobKeywordPh")} placeholder={t("lldpLinks.jobKeywordPh")}
onChange={(e) => { onChange={(e) => {
@ -928,13 +954,14 @@ export function LldpLinksPage() {
setJobPage(1); setJobPage(1);
}} }}
/> />
<button <Button
type="button" size="sm"
disabled={exportingJobs || jobTotal === 0} variant="secondary"
onClick={() => void exportJobsCsv()} isDisabled={exportingJobs || jobTotal === 0}
onPress={() => void exportJobsCsv()}
> >
{exportingJobs ? t("common.exporting") : t("common.exportCsv")} {exportingJobs ? t("common.exporting") : t("common.exportCsv")}
</button> </Button>
</div> </div>
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
@ -958,10 +985,10 @@ export function LldpLinksPage() {
return ( return (
<tr key={job.id}> <tr key={job.id}>
<td> <td>
<button <Button
type="button" size="sm"
className="btn btn--sm btn--ghost" variant="ghost"
onClick={() => { onPress={() => {
setItemDetail(null); setItemDetail(null);
setItemStatus(""); setItemStatus("");
setItemKeyword(""); setItemKeyword("");
@ -970,7 +997,7 @@ export function LldpLinksPage() {
}} }}
> >
{t("lldpLinks.expand")} {t("lldpLinks.expand")}
</button> </Button>
</td> </td>
<td className="mono">{job.id.slice(0, 8)}</td> <td className="mono">{job.id.slice(0, 8)}</td>
<td>{lldpTriggerLabel(t, job.trigger_mode)}</td> <td>{lldpTriggerLabel(t, job.trigger_mode)}</td>
@ -1005,33 +1032,36 @@ export function LldpLinksPage() {
<td> <td>
<div className="btn-row"> <div className="btn-row">
{job.status === "running" || job.status === "pending" ? ( {job.status === "running" || job.status === "pending" ? (
<button <Button
type="button" size="sm"
onClick={() => pauseMut.mutate(job.id)} variant="secondary"
disabled={pauseMut.isPending} isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(job.id)}
> >
{t("lldpLinks.pause")} {t("lldpLinks.pause")}
</button> </Button>
) : null} ) : null}
{job.status === "paused" ? ( {job.status === "paused" ? (
<button <Button
type="button" size="sm"
onClick={() => resumeMut.mutate(job.id)} variant="secondary"
disabled={resumeMut.isPending} isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(job.id)}
> >
{t("lldpLinks.resume")} {t("lldpLinks.resume")}
</button> </Button>
) : null} ) : null}
{job.status === "running" || job.status === "paused" || job.status === "pending" ? ( {job.status === "running" || job.status === "paused" || job.status === "pending" ? (
<button <Button
type="button" size="sm"
onClick={() => { variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(job.id); if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(job.id);
}} }}
disabled={stopMut.isPending}
> >
{t("lldpLinks.stop")} {t("lldpLinks.stop")}
</button> </Button>
) : null} ) : null}
</div> </div>
</td> </td>
@ -1062,25 +1092,22 @@ export function LldpLinksPage() {
disabled={jobsQuery.isLoading} disabled={jobsQuery.isLoading}
/> />
{expandedJobId ? ( <AppModalShell
<div open={Boolean(expandedJobId)}
className="modal-backdrop" onClose={() => {
role="presentation"
onClick={() => {
setExpandedJobId(""); setExpandedJobId("");
setItemDetail(null); setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}} }}
size="lg"
> >
<div <Modal.Header>
className="modal modal--wide ops-detail-modal" <Modal.Heading>{t("lldpLinks.jobDetailTitle")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger />
aria-modal="true" </Modal.Header>
aria-label={t("lldpLinks.jobDetailTitle")} <Modal.Body className="flex flex-col gap-3">
onClick={(e) => e.stopPropagation()}
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
<p className="muted"> <p className="muted">
{(() => { {(() => {
const job = jobDetailQuery.data; const job = jobDetailQuery.data;
@ -1093,32 +1120,9 @@ export function LldpLinksPage() {
return expandedJobId.slice(0, 8); return expandedJobId.slice(0, 8);
})()} })()}
</p> </p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
disabled={exportingItems || itemTotal === 0}
onClick={() => void exportItemsCsv()}
>
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
</button>
<button
type="button"
onClick={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}}
>
{t("networkConfigs.close")}
</button>
</div>
</div>
<div className="filter-inline" style={{ marginBottom: 8 }}> <div className="filter-inline">
<select <FieldSelect
value={itemStatus} value={itemStatus}
onChange={(e) => { onChange={(e) => {
setItemStatus(e.target.value); setItemStatus(e.target.value);
@ -1129,8 +1133,8 @@ export function LldpLinksPage() {
<option value="ok">{t("lldpLinks.itemResult.ok")}</option> <option value="ok">{t("lldpLinks.itemResult.ok")}</option>
<option value="warn">{t("lldpLinks.itemResult.warn")}</option> <option value="warn">{t("lldpLinks.itemResult.warn")}</option>
<option value="fail">{t("lldpLinks.itemResult.fail")}</option> <option value="fail">{t("lldpLinks.itemResult.fail")}</option>
</select> </FieldSelect>
<input <Input
value={itemKeyword} value={itemKeyword}
placeholder={t("lldpLinks.itemKeywordPh")} placeholder={t("lldpLinks.itemKeywordPh")}
onChange={(e) => { onChange={(e) => {
@ -1166,9 +1170,7 @@ export function LldpLinksPage() {
<tr key={it.id}> <tr key={it.id}>
<td>{it.ne_name || it.ne_id || "—"}</td> <td>{it.ne_name || it.ne_id || "—"}</td>
<td>{it.ne_ip || "—"}</td> <td>{it.ne_ip || "—"}</td>
<td> <td>{lldpItemResultLabel(t, it)}</td>
{lldpItemResultLabel(t, it)}
</td>
<td>{it.neighbors}</td> <td>{it.neighbors}</td>
<td> <td>
+{it.edges_added} / ~{it.edges_updated} +{it.edges_added} / ~{it.edges_updated}
@ -1178,13 +1180,9 @@ export function LldpLinksPage() {
</td> </td>
<td className="muted">{it.error || "—"}</td> <td className="muted">{it.error || "—"}</td>
<td> <td>
<button <Button size="sm" variant="tertiary" onPress={() => setItemDetail(it)}>
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setItemDetail(it)}
>
{t("topology.discoverViewDetail")} {t("topology.discoverViewDetail")}
</button> </Button>
</td> </td>
</tr> </tr>
); );
@ -1201,13 +1199,12 @@ export function LldpLinksPage() {
</div> </div>
</div> </div>
<div className="ops-detail-modal__foot">
<ListPager <ListPager
page={itemPage} page={itemPage}
pages={itemPages} pages={itemPages}
total={itemTotal} total={itemTotal}
pageSize={itemPageSize} pageSize={itemPageSize}
pageSizeOptions={[20, 50, 100]} pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setItemPage} onPageChange={setItemPage}
onPageSizeChange={(size) => { onPageSizeChange={(size) => {
setItemPageSize(size); setItemPageSize(size);
@ -1215,37 +1212,43 @@ export function LldpLinksPage() {
}} }}
disabled={jobDetailQuery.isLoading} disabled={jobDetailQuery.isLoading}
/> />
</div> </Modal.Body>
</div> <Modal.Footer>
</div> <Button
) : null} variant="secondary"
isDisabled={exportingItems || itemTotal === 0}
{itemDetail ? ( onPress={() => void exportItemsCsv()}
<div className="modal-backdrop" role="presentation" onClick={() => setItemDetail(null)}>
<div
className="modal modal--wide topo-discover-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lldp-item-detail-title"
onClick={(e) => e.stopPropagation()}
> >
<div className="topo-discover-modal__head"> {exportingItems ? t("common.exporting") : t("common.exportCsv")}
<div> </Button>
<h3 id="lldp-item-detail-title"> <Button
{itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id} variant="tertiary"
</h3> onPress={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}}
>
{t("networkConfigs.close")}
</Button>
</Modal.Footer>
</AppModalShell>
<AppModalShell open={Boolean(itemDetail)} onClose={() => setItemDetail(null)} size="lg">
<Modal.Header>
<Modal.Heading>
{itemDetail?.ne_name || itemDetail?.ne_ip || itemDetail?.ne_id}
</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{itemDetail ? (
<>
<p className="topo-discover-modal__sub"> <p className="topo-discover-modal__sub">
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)} {[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
</p> </p>
</div>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setItemDetail(null)}
>
{t("topology.discoverClose")}
</button>
</div>
{!itemDetail.ok ? ( {!itemDetail.ok ? (
<p className="topo-discover__error"> <p className="topo-discover__error">
@ -1312,15 +1315,15 @@ export function LldpLinksPage() {
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre> <pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre>
</> </>
) : null} ) : null}
</>
<div className="modal__actions">
<button type="button" className="btn btn--sm" onClick={() => setItemDetail(null)}>
{t("topology.discoverClose")}
</button>
</div>
</div>
</div>
) : null} ) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={() => setItemDetail(null)}>
{t("topology.discoverClose")}
</Button>
</Modal.Footer>
</AppModalShell>
</section> </section>
); );
} }

View file

@ -1,7 +1,9 @@
import { Button, Input } from "@heroui/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ListPager } from "../../components/ListPager"; import { ListPager } from "../../components/ListPager";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue"; import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast"; import { useToast } from "../../hooks/useToast";
@ -32,7 +34,7 @@ export function NetworkAlarmsPage() {
const [curHostName, setCurHostName] = useState(""); const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState(""); const [curKeyword, setCurKeyword] = useState("");
const [curPage, setCurPage] = useState(1); const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(50); const [curPageSize, setCurPageSize] = useState(10);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
// Deep-link from topology (and similar): ?host=&cleared=&severity=&keyword= // Deep-link from topology (and similar): ?host=&cleared=&severity=&keyword=
@ -135,15 +137,20 @@ export function NetworkAlarmsPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2> <h2>{t("ume.alarms.title")}</h2>
<div className="btn-row"> <div className="btn-row">
<button type="button" disabled={exporting || curTotal === 0} onClick={() => void exportCsv()}> <Button
size="sm"
variant="secondary"
isDisabled={exporting || curTotal === 0}
onPress={() => void exportCsv()}
>
{exporting ? t("common.exporting") : t("common.exportCsv")} {exporting ? t("common.exporting") : t("common.exportCsv")}
</button> </Button>
</div> </div>
</div> </div>
<div className="pt-list"> <div className="pt-list">
<div className="filter-inline"> <div className="filter-inline">
<input <Input
value={curKeyword} value={curKeyword}
placeholder={t("ume.alarms.keywordPh")} placeholder={t("ume.alarms.keywordPh")}
onChange={(e) => { onChange={(e) => {
@ -151,7 +158,7 @@ export function NetworkAlarmsPage() {
setCurPage(1); setCurPage(1);
}} }}
/> />
<input <Input
value={curHostName} value={curHostName}
placeholder={t("ume.alarms.hostNamePh")} placeholder={t("ume.alarms.hostNamePh")}
onChange={(e) => { onChange={(e) => {
@ -159,7 +166,7 @@ export function NetworkAlarmsPage() {
setCurPage(1); setCurPage(1);
}} }}
/> />
<select <FieldSelect
value={curSeverity} value={curSeverity}
onChange={(e) => { onChange={(e) => {
setCurSeverity(e.target.value); setCurSeverity(e.target.value);
@ -172,8 +179,8 @@ export function NetworkAlarmsPage() {
<option value="minor">minor</option> <option value="minor">minor</option>
<option value="warning">warning</option> <option value="warning">warning</option>
<option value="info">info</option> <option value="info">info</option>
</select> </FieldSelect>
<select <FieldSelect
value={curCleared} value={curCleared}
onChange={(e) => { onChange={(e) => {
setCurCleared(e.target.value); setCurCleared(e.target.value);
@ -183,21 +190,22 @@ export function NetworkAlarmsPage() {
<option value="">{t("ume.alarms.clearedAll")}</option> <option value="">{t("ume.alarms.clearedAll")}</option>
<option value="false">{t("ume.alarms.clearedNo")}</option> <option value="false">{t("ume.alarms.clearedNo")}</option>
<option value="true">{t("ume.alarms.clearedYes")}</option> <option value="true">{t("ume.alarms.clearedYes")}</option>
</select> </FieldSelect>
<button <Button
type="button" size="sm"
title={t("ume.alarms.clearTitle")} variant="tertiary"
onClick={() => { aria-label={t("ume.alarms.clearTitle")}
onPress={() => {
setCurKeyword(""); setCurKeyword("");
setCurHostName(""); setCurHostName("");
setCurSeverity(""); setCurSeverity("");
setCurCleared(""); setCurCleared("");
setCurPage(1); setCurPage(1);
}} }}
disabled={!hasFilters} isDisabled={!hasFilters}
> >
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
</div> </div>
{currentQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {currentQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -232,18 +240,19 @@ export function NetworkAlarmsPage() {
<td className="pt-list-num">{x.ne_id}</td> <td className="pt-list-num">{x.ne_id}</td>
<td> <td>
{(x.host_name || "").trim() ? ( {(x.host_name || "").trim() ? (
<button <Button
size="sm"
variant="ghost"
className="link-btn pt-list-task-name" className="link-btn pt-list-task-name"
type="button" onPress={() => {
onClick={() => {
setCurHostName(x.host_name || ""); setCurHostName(x.host_name || "");
setCurKeyword(""); setCurKeyword("");
setCurPage(1); setCurPage(1);
}} }}
title={t("ume.alarms.filterByHost")} aria-label={t("ume.alarms.filterByHost")}
> >
{x.host_name} {x.host_name}
</button> </Button>
) : ( ) : (
<span className="muted" title={t("ume.alarms.noHostName")}> <span className="muted" title={t("ume.alarms.noHostName")}>
{t("common.empty")} {t("common.empty")}
@ -264,7 +273,7 @@ export function NetworkAlarmsPage() {
pages={curPages} pages={curPages}
total={curTotal} total={curTotal}
pageSize={curPageSize} pageSize={curPageSize}
pageSizeOptions={[50, 100, 200, 500]} pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setCurPage} onPageChange={setCurPage}
onPageSizeChange={(size) => { onPageSizeChange={(size) => {
setCurPageSize(size); setCurPageSize(size);

View file

@ -1,7 +1,10 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager"; import { ListPager } from "../../components/ListPager";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue"; import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast"; import { useToast } from "../../hooks/useToast";
@ -30,7 +33,7 @@ export function NetworkConfigsPage() {
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || "")); const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
const [source, setSource] = useState(""); const [source, setSource] = useState("");
const [vendor, setVendor] = useState(""); const [vendor, setVendor] = useState("");
@ -150,19 +153,20 @@ export function NetworkConfigsPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("networkConfigs.title")}</h2> <h2>{t("networkConfigs.title")}</h2>
<div className="btn-row"> <div className="btn-row">
<button <Button
type="button" size="sm"
disabled={exportingList || total === 0} variant="secondary"
onClick={() => void exportListCsv()} isDisabled={exportingList || total === 0}
onPress={() => void exportListCsv()}
> >
{exportingList ? t("common.exporting") : t("common.exportCsv")} {exportingList ? t("common.exporting") : t("common.exportCsv")}
</button> </Button>
</div> </div>
</div> </div>
<div className="pt-list"> <div className="pt-list">
<div className="filter-inline"> <div className="filter-inline">
<input <Input
value={keyword} value={keyword}
placeholder={t("networkConfigs.keywordPh")} placeholder={t("networkConfigs.keywordPh")}
onChange={(e) => { onChange={(e) => {
@ -170,7 +174,7 @@ export function NetworkConfigsPage() {
setPage(1); setPage(1);
}} }}
/> />
<select <FieldSelect
value={source} value={source}
onChange={(e) => { onChange={(e) => {
setSource(e.target.value); setSource(e.target.value);
@ -180,8 +184,8 @@ export function NetworkConfigsPage() {
<option value="">{t("networkConfigs.allSource")}</option> <option value="">{t("networkConfigs.allSource")}</option>
<option value="managed">managed</option> <option value="managed">managed</option>
<option value="ume">ume</option> <option value="ume">ume</option>
</select> </FieldSelect>
<input <Input
value={vendor} value={vendor}
placeholder={t("networkConfigs.vendorPh")} placeholder={t("networkConfigs.vendorPh")}
onChange={(e) => { onChange={(e) => {
@ -189,10 +193,11 @@ export function NetworkConfigsPage() {
setPage(1); setPage(1);
}} }}
/> />
<button <Button
type="button" size="sm"
disabled={!hasFilters} variant="tertiary"
onClick={() => { isDisabled={!hasFilters}
onPress={() => {
setKeyword(""); setKeyword("");
setSource(""); setSource("");
setVendor(""); setVendor("");
@ -200,7 +205,7 @@ export function NetworkConfigsPage() {
}} }}
> >
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
</div> </div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -239,22 +244,24 @@ export function NetworkConfigsPage() {
</td> </td>
<td> <td>
<div className="btn-row pt-list-actions table-actions"> <div className="btn-row pt-list-actions table-actions">
<button <Button
type="button" size="sm"
onClick={() => { variant="secondary"
onPress={() => {
setSelected({ source: row.source, id: row.target_id }); setSelected({ source: row.source, id: row.target_id });
setTab("primary"); setTab("primary");
}} }}
> >
{t("networkConfigs.view")} {t("networkConfigs.view")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={ variant="secondary"
isDisabled={
exporting === exportKey || exporting === exportKey ||
exporting.startsWith(`${row.source}:${row.target_id}:`) exporting.startsWith(`${row.source}:${row.target_id}:`)
} }
onClick={() => onPress={() =>
void exportConfig( void exportConfig(
row.source, row.source,
row.target_id, row.target_id,
@ -263,10 +270,11 @@ export function NetworkConfigsPage() {
} }
> >
{t("networkConfigs.export")} {t("networkConfigs.export")}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => { variant="secondary"
onPress={() => {
const path = const path =
row.source === "ume" row.source === "ume"
? `/webcrt?ne_id=${encodeURIComponent(row.target_id)}&source=ume` ? `/webcrt?ne_id=${encodeURIComponent(row.target_id)}&source=ume`
@ -275,7 +283,7 @@ export function NetworkConfigsPage() {
}} }}
> >
WebCRT WebCRT
</button> </Button>
</div> </div>
</td> </td>
</tr> </tr>
@ -291,7 +299,7 @@ export function NetworkConfigsPage() {
pages={pages} pages={pages}
total={total} total={total}
pageSize={pageSize} pageSize={pageSize}
pageSizeOptions={[20, 50, 100]} pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setPage} onPageChange={setPage}
onPageSizeChange={(size) => { onPageSizeChange={(size) => {
setPageSize(size); setPageSize(size);
@ -301,76 +309,41 @@ export function NetworkConfigsPage() {
/> />
</div> </div>
<AppModalShell
open={Boolean(selected)}
onClose={() => setSelected(null)}
size="lg"
className="nm-config-modal"
>
<Modal.Header>
<Modal.Heading>{detail?.ne_name || selected?.id || t("networkConfigs.view")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{selected ? ( {selected ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setSelected(null)}
>
<div
className="modal modal--wide nm-config-modal"
role="dialog"
aria-modal="true"
aria-label={t("networkConfigs.view")}
onClick={(e) => e.stopPropagation()}
>
<div className="nm-config-modal__head">
<div className="nm-config-modal__title">
<h3>{detail?.ne_name || selected.id}</h3>
<p className="muted"> <p className="muted">
{selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"} {selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
</p> </p>
</div>
<div className="btn-row nm-config-modal__actions">
<button
type="button"
disabled={Boolean(exporting)}
onClick={() =>
void exportConfig(
selected.source,
selected.id,
showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
)
}
>
{t("networkConfigs.export")}
</button>
{showAlt ? (
<button
type="button"
disabled={Boolean(exporting)}
onClick={() => void exportConfig(selected.source, selected.id, "both")}
>
{t("networkConfigs.exportBoth")}
</button>
) : null} ) : null}
<button type="button" onClick={() => setSelected(null)}>
{t("networkConfigs.close")}
</button>
</div>
</div>
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{showAlt ? ( {showAlt ? (
<div className="btn-row nm-config-modal__tabs"> <div className="btn-row nm-config-modal__tabs">
<button <Button
type="button" size="sm"
className={tab === "primary" ? "btn-primary" : undefined} variant={tab === "primary" ? "primary" : "secondary"}
onClick={() => setTab("primary")} onPress={() => setTab("primary")}
> >
{t("networkConfigs.tabSet")} {t("networkConfigs.tabSet")}
</button> </Button>
<button <Button
type="button" size="sm"
className={tab === "alt" ? "btn-primary" : undefined} variant={tab === "alt" ? "primary" : "secondary"}
onClick={() => setTab("alt")} onPress={() => setTab("alt")}
> >
{t("networkConfigs.tabHier")} {t("networkConfigs.tabHier")}
</button> </Button>
</div> </div>
) : null} ) : null}
<textarea <textarea
className="nm-config-modal__body" className="nm-config-modal__body"
readOnly readOnly
@ -381,9 +354,39 @@ export function NetworkConfigsPage() {
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "} SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)} {fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
</p> </p>
</div> </Modal.Body>
</div> <Modal.Footer>
{selected ? (
<>
<Button
variant="secondary"
isDisabled={Boolean(exporting)}
onPress={() =>
void exportConfig(
selected.source,
selected.id,
showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
)
}
>
{t("networkConfigs.export")}
</Button>
{showAlt ? (
<Button
variant="secondary"
isDisabled={Boolean(exporting)}
onPress={() => void exportConfig(selected.source, selected.id, "both")}
>
{t("networkConfigs.exportBoth")}
</Button>
) : null} ) : null}
</>
) : null}
<Button variant="tertiary" onPress={() => setSelected(null)}>
{t("networkConfigs.close")}
</Button>
</Modal.Footer>
</AppModalShell>
</section> </section>
); );
} }

View file

@ -1,6 +1,8 @@
import { Button, Input } from "@heroui/react";
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ListPager } from "../../components/ListPager"; import { ListPager } from "../../components/ListPager";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue"; import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast"; import { useToast } from "../../hooks/useToast";
@ -36,7 +38,7 @@ export function NetworkDevicesPage() {
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [source, setSource] = useState<"all" | "managed" | "ume">("all"); const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(10);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300); const debouncedKeyword = useDebouncedValue(keyword, 300);
@ -91,15 +93,20 @@ export function NetworkDevicesPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("networkDevices.title")}</h2> <h2>{t("networkDevices.title")}</h2>
<div className="btn-row"> <div className="btn-row">
<button type="button" disabled={exporting || total === 0} onClick={() => void exportCsv()}> <Button
size="sm"
variant="secondary"
isDisabled={exporting || total === 0}
onPress={() => void exportCsv()}
>
{exporting ? t("common.exporting") : t("common.exportCsv")} {exporting ? t("common.exporting") : t("common.exportCsv")}
</button> </Button>
</div> </div>
</div> </div>
<div className="pt-list"> <div className="pt-list">
<div className="filter-inline"> <div className="filter-inline">
<input <Input
value={keyword} value={keyword}
placeholder={t("networkDevices.keywordPh")} placeholder={t("networkDevices.keywordPh")}
onChange={(e) => { onChange={(e) => {
@ -107,7 +114,7 @@ export function NetworkDevicesPage() {
setPage(1); setPage(1);
}} }}
/> />
<select <FieldSelect
value={source} value={source}
onChange={(e) => { onChange={(e) => {
setSource(e.target.value as "all" | "managed" | "ume"); setSource(e.target.value as "all" | "managed" | "ume");
@ -117,18 +124,19 @@ export function NetworkDevicesPage() {
<option value="all">{t("networkDevices.allSource")}</option> <option value="all">{t("networkDevices.allSource")}</option>
<option value="managed">managed</option> <option value="managed">managed</option>
<option value="ume">ume</option> <option value="ume">ume</option>
</select> </FieldSelect>
<button <Button
type="button" size="sm"
disabled={!hasFilters} variant="tertiary"
onClick={() => { isDisabled={!hasFilters}
onPress={() => {
setKeyword(""); setKeyword("");
setSource("all"); setSource("all");
setPage(1); setPage(1);
}} }}
> >
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
</div> </div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -167,12 +175,13 @@ export function NetworkDevicesPage() {
</td> </td>
<td> <td>
<div className="btn-row pt-list-actions table-actions"> <div className="btn-row pt-list-actions table-actions">
<button type="button" onClick={() => openWebcrt(row)}> <Button size="sm" variant="secondary" onPress={() => openWebcrt(row)}>
WebCRT WebCRT
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => variant="secondary"
onPress={() =>
openNewModuleWindow({ openNewModuleWindow({
moduleId: "network", moduleId: "network",
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`, path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
@ -180,7 +189,7 @@ export function NetworkDevicesPage() {
} }
> >
{t("network.nav.configs")} {t("network.nav.configs")}
</button> </Button>
</div> </div>
</td> </td>
</tr> </tr>
@ -195,7 +204,7 @@ export function NetworkDevicesPage() {
pages={pages} pages={pages}
total={total} total={total}
pageSize={pageSize} pageSize={pageSize}
pageSizeOptions={[20, 50, 100, 200]} pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setPage} onPageChange={setPage}
onPageSizeChange={(size) => { onPageSizeChange={(size) => {
setPageSize(size); setPageSize(size);

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
createPortTrafficBoard, createPortTrafficBoard,
@ -52,14 +53,14 @@ export function PortTrafficBoardListPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("portTraffic.wallTitle")}</h2> <h2>{t("portTraffic.wallTitle")}</h2>
<div className="btn-row"> <div className="btn-row">
<button <Button
type="button" size="sm"
className="btn-primary" variant="primary"
disabled={createMut.isPending} isDisabled={createMut.isPending}
onClick={() => createMut.mutate()} onPress={() => createMut.mutate()}
> >
{t("portTraffic.boardCreate")} {t("portTraffic.boardCreate")}
</button> </Button>
</div> </div>
</div> </div>
@ -71,14 +72,14 @@ export function PortTrafficBoardListPage() {
) : !boards.length ? ( ) : !boards.length ? (
<div className="pt-list-empty"> <div className="pt-list-empty">
<p>{t("portTraffic.boardEmpty")}</p> <p>{t("portTraffic.boardEmpty")}</p>
<button <Button
type="button" size="sm"
className="btn-primary" variant="primary"
disabled={createMut.isPending} isDisabled={createMut.isPending}
onClick={() => createMut.mutate()} onPress={() => createMut.mutate()}
> >
{t("portTraffic.boardCreate")} {t("portTraffic.boardCreate")}
</button> </Button>
</div> </div>
) : ( ) : (
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
@ -96,38 +97,39 @@ export function PortTrafficBoardListPage() {
{boards.map((b) => ( {boards.map((b) => (
<tr key={b.id}> <tr key={b.id}>
<td> <td>
<button <Button
type="button" size="sm"
variant="ghost"
className="link-btn pt-list-task-name" className="link-btn pt-list-task-name"
onClick={() => openPortTrafficBoardWindow(b.id)} onPress={() => openPortTrafficBoardWindow(b.id)}
> >
{b.name || t("portTraffic.boardDefaultName")} {b.name || t("portTraffic.boardDefaultName")}
</button> </Button>
</td> </td>
<td>{b.panel_count}</td> <td>{b.panel_count}</td>
<td>{b.cols}</td> <td>{b.cols}</td>
<td>{b.updated_at ? formatSystemTime(b.updated_at) : "—"}</td> <td>{b.updated_at ? formatSystemTime(b.updated_at) : "—"}</td>
<td> <td>
<div className="btn-row table-actions"> <div className="btn-row table-actions">
<button <Button
type="button" size="sm"
className="btn-primary" variant="primary"
onClick={() => openPortTrafficBoardWindow(b.id)} onPress={() => openPortTrafficBoardWindow(b.id)}
> >
{t("portTraffic.boardOpen")} {t("portTraffic.boardOpen")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn--danger" variant="danger"
disabled={deleteMut.isPending} isDisabled={deleteMut.isPending}
onClick={() => { onPress={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) { if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
deleteMut.mutate(b.id); deleteMut.mutate(b.id);
} }
}} }}
> >
{t("portTraffic.delete")} {t("portTraffic.delete")}
</button> </Button>
</div> </div>
</td> </td>
</tr> </tr>

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { fetchPortTrafficCompare } from "../../services/api"; import { fetchPortTrafficCompare } from "../../services/api";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
@ -96,18 +97,18 @@ export function PortTrafficBoardPanelCell({
{title} {title}
</span> </span>
<div className="btn-row"> <div className="btn-row">
<button type="button" onClick={() => onMove?.(-1)}> <Button size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(-1)}>
↑ ↑
</button> </Button>
<button type="button" onClick={() => onMove?.(1)}> <Button size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(1)}>
↓ ↓
</button> </Button>
<button type="button" onClick={() => onEdit?.()}> <Button size="sm" variant="secondary" onPress={() => onEdit?.()}>
{t("portTraffic.edit")} {t("portTraffic.edit")}
</button> </Button>
<button type="button" className="btn--danger" onClick={() => onRemove?.()}> <Button size="sm" variant="danger" onPress={() => onRemove?.()}>
{t("portTraffic.delete")} {t("portTraffic.delete")}
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
@ -115,9 +116,9 @@ export function PortTrafficBoardPanelCell({
<div className="pt-board-panel__stale"> <div className="pt-board-panel__stale">
<p>{t("portTraffic.boardPanelStale")}</p> <p>{t("portTraffic.boardPanelStale")}</p>
{editing ? ( {editing ? (
<button type="button" className="btn-primary" onClick={() => onEdit?.()}> <Button variant="primary" onPress={() => onEdit?.()}>
{t("portTraffic.boardRetarget")} {t("portTraffic.boardRetarget")}
</button> </Button>
) : null} ) : null}
</div> </div>
) : ( ) : (

View file

@ -1,7 +1,10 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager"; import { ListPager } from "../../components/ListPager";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { import {
createPortTrafficDevice, createPortTrafficDevice,
deletePortTrafficDevice, deletePortTrafficDevice,
@ -35,7 +38,7 @@ import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
const POLL_MS = 5000; const POLL_MS = 5000;
const TARGET_PAGE_SIZE = 20; const TARGET_PAGE_SIZE = 10;
type ViewMode = "list" | "wizard" | "edit"; type ViewMode = "list" | "wizard" | "edit";
@ -128,7 +131,7 @@ export function PortTrafficPage() {
const [view, setView] = useState<ViewMode>("list"); const [view, setView] = useState<ViewMode>("list");
const [listPage, setListPage] = useState(1); const [listPage, setListPage] = useState(1);
const [listPageSize, setListPageSize] = useState(50); const [listPageSize, setListPageSize] = useState(10);
const [listKeyword, setListKeyword] = useState(""); const [listKeyword, setListKeyword] = useState("");
const [listStatus, setListStatus] = useState(""); const [listStatus, setListStatus] = useState("");
const [exportingDevices, setExportingDevices] = useState(false); const [exportingDevices, setExportingDevices] = useState(false);
@ -697,30 +700,32 @@ export function PortTrafficPage() {
<h2>{t("portTraffic.title")}</h2> <h2>{t("portTraffic.title")}</h2>
<div className="btn-row"> <div className="btn-row">
{view !== "list" ? ( {view !== "list" ? (
<button <Button
type="button" size="sm"
onClick={() => { variant="secondary"
onPress={() => {
resetWizard(); resetWizard();
setView("list"); setView("list");
}} }}
> >
{t("portTraffic.backList")} {t("portTraffic.backList")}
</button> </Button>
) : ( ) : (
<> <>
<button type="button" onClick={() => navigate("/network/tasks/port-traffic/wall")}> <Button size="sm" variant="secondary" onPress={() => navigate("/network/tasks/port-traffic/wall")}>
{t("portTraffic.wall")} {t("portTraffic.wall")}
</button> </Button>
<button <Button
type="button" size="sm"
disabled={exportingDevices || listTotal === 0} variant="secondary"
onClick={() => void exportDevicesCsv()} isDisabled={exportingDevices || listTotal === 0}
onPress={() => void exportDevicesCsv()}
> >
{exportingDevices ? t("common.exporting") : t("common.exportCsv")} {exportingDevices ? t("common.exporting") : t("common.exportCsv")}
</button> </Button>
<button type="button" className="btn-primary" onClick={openWizard}> <Button size="sm" variant="primary" onPress={openWizard}>
{t("portTraffic.create")} {t("portTraffic.create")}
</button> </Button>
</> </>
)} )}
</div> </div>
@ -752,7 +757,7 @@ export function PortTrafficPage() {
</div> </div>
<div className="filter-inline"> <div className="filter-inline">
<input <Input
value={listKeyword} value={listKeyword}
placeholder={t("portTraffic.keywordPh")} placeholder={t("portTraffic.keywordPh")}
onChange={(e) => { onChange={(e) => {
@ -760,7 +765,7 @@ export function PortTrafficPage() {
setListPage(1); setListPage(1);
}} }}
/> />
<select <FieldSelect
value={listStatus} value={listStatus}
onChange={(e) => { onChange={(e) => {
setListStatus(e.target.value); setListStatus(e.target.value);
@ -772,18 +777,19 @@ export function PortTrafficPage() {
<option value="paused">{t("portTraffic.statusPaused")}</option> <option value="paused">{t("portTraffic.statusPaused")}</option>
<option value="stopped">{t("portTraffic.statusStopped")}</option> <option value="stopped">{t("portTraffic.statusStopped")}</option>
<option value="draft">{t("portTraffic.statusDraft")}</option> <option value="draft">{t("portTraffic.statusDraft")}</option>
</select> </FieldSelect>
<button <Button
type="button" size="sm"
disabled={!hasListFilters} variant="tertiary"
onClick={() => { isDisabled={!hasListFilters}
onPress={() => {
setListKeyword(""); setListKeyword("");
setListStatus(""); setListStatus("");
setListPage(1); setListPage(1);
}} }}
> >
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </Button>
</div> </div>
{devicesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {devicesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -793,9 +799,9 @@ export function PortTrafficPage() {
<div className="pt-list-empty"> <div className="pt-list-empty">
<p>{t("portTraffic.empty")}</p> <p>{t("portTraffic.empty")}</p>
{!hasListFilters ? ( {!hasListFilters ? (
<button type="button" className="btn-primary" onClick={openWizard}> <Button variant="primary" onPress={openWizard}>
{t("portTraffic.create")} {t("portTraffic.create")}
</button> </Button>
) : null} ) : null}
</div> </div>
) : devices.length ? ( ) : devices.length ? (
@ -843,74 +849,70 @@ export function PortTrafficPage() {
</td> </td>
<td> <td>
<div className="pt-list-actions"> <div className="pt-list-actions">
<button <Button size="sm" variant="primary" onPress={() => openWallList()}>
type="button"
className="btn-primary"
onClick={() => openWallList()}
>
{t("portTraffic.wall")} {t("portTraffic.wall")}
</button> </Button>
<button type="button" onClick={() => openEdit(row)}> <Button size="sm" variant="secondary" onPress={() => openEdit(row)}>
{t("portTraffic.edit")} {t("portTraffic.edit")}
</button> </Button>
{needsNeRebind(row) ? ( {needsNeRebind(row) ? (
<button <Button size="sm" variant="secondary" onPress={() => openRebind(row)}>
type="button"
title={t("portTraffic.rebindHint")}
onClick={() => openRebind(row)}
>
{t("portTraffic.rebind")} {t("portTraffic.rebind")}
</button> </Button>
) : null} ) : null}
{row.status !== "running" ? ( {row.status !== "running" ? (
<button <Button
type="button" size="sm"
disabled={startMut.isPending} variant="secondary"
onClick={() => startMut.mutate(row.id)} isDisabled={startMut.isPending}
onPress={() => startMut.mutate(row.id)}
> >
{t("portTraffic.start")} {t("portTraffic.start")}
</button> </Button>
) : ( ) : (
<button <Button
type="button" size="sm"
disabled={pauseMut.isPending} variant="secondary"
onClick={() => pauseMut.mutate(row.id)} isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(row.id)}
> >
{t("portTraffic.pause")} {t("portTraffic.pause")}
</button> </Button>
)} )}
{row.status !== "stopped" ? ( {row.status !== "stopped" ? (
<button <Button
type="button" size="sm"
disabled={stopMut.isPending} variant="secondary"
onClick={() => stopMut.mutate(row.id)} isDisabled={stopMut.isPending}
onPress={() => stopMut.mutate(row.id)}
> >
{t("portTraffic.stop")} {t("portTraffic.stop")}
</button> </Button>
) : null} ) : null}
<button <Button
type="button" size="sm"
variant="secondary"
className={row.last_error ? "pt-list-log-btn--error" : undefined} className={row.last_error ? "pt-list-log-btn--error" : undefined}
onClick={() => { onPress={() => {
setLogDeviceId(row.id); setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row)); setLogDeviceLabel(deviceLabel(row));
setLogKeyword(""); setLogKeyword("");
}} }}
> >
{t("portTraffic.log")} {t("portTraffic.log")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn--danger" variant="danger"
disabled={deleteMut.isPending} isDisabled={deleteMut.isPending}
onClick={() => { onPress={() => {
if (window.confirm(t("portTraffic.confirmDelete"))) { if (window.confirm(t("portTraffic.confirmDelete"))) {
deleteMut.mutate(row.id); deleteMut.mutate(row.id);
} }
}} }}
> >
{t("portTraffic.delete")} {t("portTraffic.delete")}
</button> </Button>
</div> </div>
</td> </td>
</tr> </tr>
@ -926,7 +928,7 @@ export function PortTrafficPage() {
pages={pages} pages={pages}
total={listTotal} total={listTotal}
pageSize={listPageSize} pageSize={listPageSize}
pageSizeOptions={[20, 50, 100]} pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setListPage} onPageChange={setListPage}
onPageSizeChange={(size) => { onPageSizeChange={(size) => {
setListPageSize(size); setListPageSize(size);
@ -1021,9 +1023,14 @@ export function PortTrafficPage() {
<strong> <strong>
{selectedNe.name} ({selectedNe.ip_address}) {selectedNe.name} ({selectedNe.ip_address})
</strong> </strong>
<button type="button" disabled={portsLoading} onClick={() => void discoverSelected()}> <Button
size="sm"
variant="secondary"
isDisabled={portsLoading}
onPress={() => void discoverSelected()}
>
{portsLoading ? "…" : t("portTraffic.discover")} {portsLoading ? "…" : t("portTraffic.discover")}
</button> </Button>
{portsError ? <span className="muted">{portsError}</span> : null} {portsError ? <span className="muted">{portsError}</span> : null}
</div> </div>
{discoverMeta ? ( {discoverMeta ? (
@ -1165,24 +1172,24 @@ export function PortTrafficPage() {
})} })}
{pickedList.length > 40 ? <li>… +{pickedList.length - 40}</li> : null} {pickedList.length > 40 ? <li>… +{pickedList.length - 40}</li> : null}
</ul> </ul>
<button type="button" className="btn-primary" disabled={createMut.isPending} onClick={submitWizard}> <Button variant="primary" isDisabled={createMut.isPending} onPress={submitWizard}>
{t("portTraffic.confirmCreate")} {t("portTraffic.confirmCreate")}
</button> </Button>
</div> </div>
) : null} ) : null}
<div className="btn-row" style={{ marginTop: 12 }}> <div className="btn-row" style={{ marginTop: 12 }}>
<button <Button
type="button" variant="secondary"
disabled={wizardStep <= 1} isDisabled={wizardStep <= 1}
onClick={() => setWizardStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3) : s))} onPress={() => setWizardStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3) : s))}
> >
{t("portTraffic.prev")} {t("portTraffic.prev")}
</button> </Button>
<button <Button
type="button" variant="secondary"
disabled={wizardStep >= 3} isDisabled={wizardStep >= 3}
onClick={() => { onPress={() => {
if (wizardStep === 1 && !selectedNe) { if (wizardStep === 1 && !selectedNe) {
showError(t("portTraffic.pickNeFirst")); showError(t("portTraffic.pickNeFirst"));
return; return;
@ -1195,7 +1202,7 @@ export function PortTrafficPage() {
}} }}
> >
{t("portTraffic.next")} {t("portTraffic.next")}
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
@ -1228,23 +1235,28 @@ export function PortTrafficPage() {
</label> </label>
<label className="config-sync-policy-field"> <label className="config-sync-policy-field">
{t("portTraffic.concurrency")} {t("portTraffic.concurrency")}
<input <Input
type="number" type="number"
min={1} min={1}
max={5} max={5}
value={concurrency} value={String(concurrency)}
onChange={(e) => setConcurrency(Number(e.target.value) || 1)} onChange={(e) => setConcurrency(Number(e.target.value) || 1)}
/> />
</label> </label>
<label className="config-sync-policy-field"> <label className="config-sync-policy-field">
{t("portTraffic.note")} {t("portTraffic.note")}
<input value={note} onChange={(e) => setNote(e.target.value)} /> <Input value={note} onChange={(e) => setNote(e.target.value)} />
</label> </label>
</div> </div>
<div className="btn-row" style={{ margin: "12px 0" }}> <div className="btn-row" style={{ margin: "12px 0" }}>
<button type="button" disabled={portsLoading} onClick={() => void discoverSelected()}> <Button
size="sm"
variant="secondary"
isDisabled={portsLoading}
onPress={() => void discoverSelected()}
>
{portsLoading ? "…" : t("portTraffic.discover")} {portsLoading ? "…" : t("portTraffic.discover")}
</button> </Button>
<span className="muted">{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</span> <span className="muted">{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</span>
</div> </div>
{portsError ? <p className="muted">{portsError}</p> : null} {portsError ? <p className="muted">{portsError}</p> : null}
@ -1315,49 +1327,28 @@ export function PortTrafficPage() {
</ul> </ul>
)} )}
<div className="btn-row" style={{ marginTop: 12 }}> <div className="btn-row" style={{ marginTop: 12 }}>
<button <Button
type="button" variant="primary"
className="btn-primary" isDisabled={updateMut.isPending || putIfacesMut.isPending}
disabled={updateMut.isPending || putIfacesMut.isPending} onPress={() => void saveEdit()}
onClick={() => void saveEdit()}
> >
{t("portTraffic.save")} {t("portTraffic.save")}
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
{logDeviceId ? ( <AppModalShell open={Boolean(logDeviceId)} onClose={closeLog} size="lg">
<div className="modal-backdrop" role="presentation" onClick={closeLog}> <Modal.Header>
<div <Modal.Heading>{t("portTraffic.logTitle")}</Modal.Heading>
className="modal modal--wide pt-log-modal" <Modal.CloseTrigger />
role="dialog" </Modal.Header>
aria-modal="true" <Modal.Body className="flex flex-col gap-3">
aria-labelledby="pt-log-title"
onClick={(e) => e.stopPropagation()}
>
<div className="pt-log-modal__head">
<div>
<h3 id="pt-log-title">{t("portTraffic.logTitle")}</h3>
<p className="muted pt-log-modal__sub"> <p className="muted pt-log-modal__sub">
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"} {t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
</p> </p>
</div> <div className="filter-inline">
<div className="btn-row"> <Input
<button
type="button"
disabled={!filteredLogEvents.length}
onClick={exportEventsCsv}
>
{t("common.exportCsv")}
</button>
<button type="button" onClick={closeLog}>
{t("portTraffic.logClose")}
</button>
</div>
</div>
<div className="filter-inline" style={{ marginBottom: 10 }}>
<input
value={logKeyword} value={logKeyword}
placeholder={t("portTraffic.keywordPh")} placeholder={t("portTraffic.keywordPh")}
onChange={(e) => setLogKeyword(e.target.value)} onChange={(e) => setLogKeyword(e.target.value)}
@ -1398,22 +1389,34 @@ export function PortTrafficPage() {
})} })}
</div> </div>
)} )}
</div> </Modal.Body>
</div> <Modal.Footer>
) : null} <Button
variant="secondary"
{rebindDevice ? ( isDisabled={!filteredLogEvents.length}
<div className="modal-backdrop" role="presentation" onClick={closeRebind}> onPress={exportEventsCsv}
<div
className="modal modal--wide pt-rebind-modal"
role="dialog"
aria-modal="true"
aria-labelledby="pt-rebind-title"
onClick={(e) => e.stopPropagation()}
> >
<div className="pt-log-modal__head"> {t("common.exportCsv")}
<div> </Button>
<h3 id="pt-rebind-title">{t("portTraffic.rebindTitle")}</h3> <Button variant="tertiary" onPress={closeLog}>
{t("portTraffic.logClose")}
</Button>
</Modal.Footer>
</AppModalShell>
<AppModalShell
open={Boolean(rebindDevice)}
onClose={closeRebind}
dismissible={!rebindMut.isPending}
size="lg"
>
<Modal.Header>
<Modal.Heading>{t("portTraffic.rebindTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{rebindDevice ? (
<>
<p className="muted pt-log-modal__sub"> <p className="muted pt-log-modal__sub">
{t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)} {t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)}
{rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""} {rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""}
@ -1421,14 +1424,9 @@ export function PortTrafficPage() {
{rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""} {rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
</p> </p>
<p className="muted pt-log-modal__sub">{t("portTraffic.rebindHint")}</p> <p className="muted pt-log-modal__sub">{t("portTraffic.rebindHint")}</p>
</div>
<button type="button" onClick={closeRebind} disabled={rebindMut.isPending}>
{t("portTraffic.logClose")}
</button>
</div>
<div className="filter-inline" style={{ marginBottom: 10 }}> <div className="filter-inline">
<input <Input
value={rebindKeyword} value={rebindKeyword}
onChange={(e) => { onChange={(e) => {
setRebindKeyword(e.target.value); setRebindKeyword(e.target.value);
@ -1500,48 +1498,51 @@ export function PortTrafficPage() {
</div> </div>
)} )}
<div className="pager pt-list-pager" style={{ marginTop: 8 }}> <div className="pager pt-list-pager">
<button <Button
type="button" size="sm"
variant="ghost"
isIconOnly
className="pager__btn" className="pager__btn"
disabled={rebindPage <= 1} isDisabled={rebindPage <= 1}
onClick={() => setRebindPage((p) => p - 1)} onPress={() => setRebindPage((p) => p - 1)}
> >
‹ ‹
</button> </Button>
<span className="muted"> <span className="muted">
{rebindPage}/ {rebindPage}/
{Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))} {Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))}
</span> </span>
<button <Button
type="button" size="sm"
variant="ghost"
isIconOnly
className="pager__btn" className="pager__btn"
disabled={ isDisabled={
rebindPage >= rebindPage >=
Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE)) Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
} }
onClick={() => setRebindPage((p) => p + 1)} onPress={() => setRebindPage((p) => p + 1)}
> >
› ›
</button> </Button>
</div> </div>
</>
<div className="modal__actions" style={{ marginTop: 12 }}> ) : null}
<button type="button" onClick={closeRebind} disabled={rebindMut.isPending}> </Modal.Body>
<Modal.Footer>
<Button variant="tertiary" isDisabled={rebindMut.isPending} onPress={closeRebind}>
{t("portTraffic.boardCancel")} {t("portTraffic.boardCancel")}
</button> </Button>
<button <Button
type="button" variant="primary"
className="btn-primary" isDisabled={!rebindSelectedNe || rebindMut.isPending}
disabled={!rebindSelectedNe || rebindMut.isPending} onPress={confirmRebind}
onClick={confirmRebind}
> >
{rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")} {rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null}
</section> </section>
); );
} }

View file

@ -1,3 +1,4 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
@ -9,6 +10,8 @@ import {
putPortTrafficBoardPanels, putPortTrafficBoardPanels,
updatePortTrafficBoard, updatePortTrafficBoard,
} from "../../services/api"; } from "../../services/api";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast"; import { useToast } from "../../hooks/useToast";
@ -318,7 +321,7 @@ export function PortTrafficWallPage() {
<> <>
<label className="pt-board-toolbar__field"> <label className="pt-board-toolbar__field">
{t("portTraffic.boardName")} {t("portTraffic.boardName")}
<input <Input
value={draftName} value={draftName}
onChange={(e) => { onChange={(e) => {
setDraftName(e.target.value); setDraftName(e.target.value);
@ -326,9 +329,9 @@ export function PortTrafficWallPage() {
}} }}
/> />
</label> </label>
<label className="pt-board-toolbar__field pt-board-toolbar__field--sm"> <FieldSelect
{t("portTraffic.boardCols")} className="pt-board-toolbar__field pt-board-toolbar__field--sm"
<select label={t("portTraffic.boardCols")}
value={draftCols} value={draftCols}
onChange={(e) => { onChange={(e) => {
setDraftCols(Number(e.target.value) || 2); setDraftCols(Number(e.target.value) || 2);
@ -339,8 +342,7 @@ export function PortTrafficWallPage() {
<option value={2}>2</option> <option value={2}>2</option>
<option value={3}>3</option> <option value={3}>3</option>
<option value={4}>4</option> <option value={4}>4</option>
</select> </FieldSelect>
</label>
</> </>
) : ( ) : (
<div className="pt-board-toolbar__title"> <div className="pt-board-toolbar__title">
@ -351,47 +353,45 @@ export function PortTrafficWallPage() {
</div> </div>
<div className="btn-row pt-board-toolbar__actions"> <div className="btn-row pt-board-toolbar__actions">
{panels.length && !editing ? ( {panels.length && !editing ? (
<button <Button
type="button" size="sm"
variant="secondary"
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar" className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
onClick={() => void toggleFullscreen()} onPress={() => void toggleFullscreen()}
title={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
aria-label={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")} aria-label={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
> >
<FullscreenIcon /> <FullscreenIcon />
<span>{fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}</span> <span>{fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}</span>
</button> </Button>
) : null} ) : null}
{!editing ? ( {!editing ? (
<button type="button" className="btn-primary" onClick={startEdit} disabled={!board}> <Button variant="primary" onPress={startEdit} isDisabled={!board}>
{t("portTraffic.boardEdit")} {t("portTraffic.boardEdit")}
</button> </Button>
) : ( ) : (
<> <>
<button <Button
type="button" variant="primary"
className="btn-primary" isDisabled={savePanelsMut.isPending}
disabled={savePanelsMut.isPending} onPress={() => savePanelsMut.mutate()}
onClick={() => savePanelsMut.mutate()}
> >
{t("portTraffic.boardSave")} {t("portTraffic.boardSave")}
</button> </Button>
<button type="button" onClick={cancelEdit}> <Button variant="tertiary" onPress={cancelEdit}>
{t("portTraffic.boardCancel")} {t("portTraffic.boardCancel")}
</button> </Button>
</> </>
)} )}
{!editing ? ( {!editing ? (
<button <Button
type="button" variant="danger"
className="btn--danger" isDisabled={deleteMut.isPending || !board}
disabled={deleteMut.isPending || !board} onPress={() => {
onClick={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) deleteMut.mutate(); if (window.confirm(t("portTraffic.boardConfirmDelete"))) deleteMut.mutate();
}} }}
> >
{t("portTraffic.delete")} {t("portTraffic.delete")}
</button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>
@ -436,9 +436,9 @@ export function PortTrafficWallPage() {
))} ))}
</select> </select>
</label> </label>
<button type="button" className="btn-primary" onClick={addPanel}> <Button variant="primary" onPress={addPanel}>
{t("portTraffic.boardAddPanel")} {t("portTraffic.boardAddPanel")}
</button> </Button>
</div> </div>
) : null} ) : null}
@ -446,9 +446,9 @@ export function PortTrafficWallPage() {
<div className="pt-wall-shell__empty"> <div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardNoPanels")}</p> <p>{t("portTraffic.boardNoPanels")}</p>
{!editing ? ( {!editing ? (
<button type="button" className="btn-primary" onClick={startEdit}> <Button variant="primary" onPress={startEdit}>
{t("portTraffic.boardEdit")} {t("portTraffic.boardEdit")}
</button> </Button>
) : null} ) : null}
</div> </div>
) : ( ) : (
@ -499,25 +499,23 @@ export function PortTrafficWallPage() {
)} )}
{editing && editPanel ? ( {editing && editPanel ? (
<div className="modal-backdrop" role="presentation" onClick={closePanelSettings}> <AppModalShell open onClose={closePanelSettings} size="lg">
<div <Modal.Header>
className="modal" <Modal.Heading>{t("portTraffic.boardPanelSettings")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger />
aria-modal="true" </Modal.Header>
onClick={(e) => e.stopPropagation()} <Modal.Body className="flex flex-col gap-3">
> <div className="form-grid">
<h3>{t("portTraffic.boardPanelSettings")}</h3>
<div className="form-grid" style={{ marginTop: 12 }}>
<label> <label>
{t("portTraffic.boardPanelTitle")} {t("portTraffic.boardPanelTitle")}
<input <Input
value={editPanel.title} value={editPanel.title}
onChange={(e) => updatePanel(editPanel.id, { title: e.target.value })} onChange={(e) => updatePanel(editPanel.id, { title: e.target.value })}
/> />
</label> </label>
<label> <label>
{t("portTraffic.boardPickDevice")} {t("portTraffic.boardPickDevice")}
<select <FieldSelect
value={editTargetsDeviceId} value={editTargetsDeviceId}
onChange={(e) => { onChange={(e) => {
setEditPickDeviceId(e.target.value); setEditPickDeviceId(e.target.value);
@ -530,11 +528,11 @@ export function PortTrafficWallPage() {
{d.ne_name || d.ne_ip || d.id} {d.ne_name || d.ne_ip || d.id}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> </label>
<label> <label>
{t("portTraffic.wallPort")} {t("portTraffic.wallPort")}
<select <FieldSelect
value={editPanel.target_id} value={editPanel.target_id}
disabled={!editTargetsDeviceId} disabled={!editTargetsDeviceId}
onChange={(e) => { onChange={(e) => {
@ -558,12 +556,12 @@ export function PortTrafficWallPage() {
{tgt.ifname} {tgt.ifname}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> </label>
<label> <label>
{t("portTraffic.range")} {t("portTraffic.range")}
<select <FieldSelect
value={editPanel.range_hours} value={String(editPanel.range_hours)}
onChange={(e) => onChange={(e) =>
updatePanel(editPanel.id, { range_hours: Number(e.target.value) || 24 }) updatePanel(editPanel.id, { range_hours: Number(e.target.value) || 24 })
} }
@ -571,11 +569,11 @@ export function PortTrafficWallPage() {
<option value={1}>1h</option> <option value={1}>1h</option>
<option value={6}>6h</option> <option value={6}>6h</option>
<option value={24}>24h</option> <option value={24}>24h</option>
</select> </FieldSelect>
</label> </label>
<label> <label>
{t("portTraffic.compare")} {t("portTraffic.compare")}
<select <FieldSelect
value={editPanel.baseline} value={editPanel.baseline}
onChange={(e) => { onChange={(e) => {
const next = e.target.value as BaselineMode; const next = e.target.value as BaselineMode;
@ -592,16 +590,16 @@ export function PortTrafficWallPage() {
<option value="week">{t("portTraffic.compareWeek")}</option> <option value="week">{t("portTraffic.compareWeek")}</option>
<option value="shift">{t("portTraffic.compareShift")}</option> <option value="shift">{t("portTraffic.compareShift")}</option>
<option value="custom">{t("portTraffic.compareCustom")}</option> <option value="custom">{t("portTraffic.compareCustom")}</option>
</select> </FieldSelect>
</label> </label>
{editPanel.baseline === "custom" ? ( {editPanel.baseline === "custom" ? (
<label> <label>
{t("portTraffic.offsetHours")} {t("portTraffic.offsetHours")}
<input <Input
type="number" type="number"
min={1} min={1}
max={24 * 90} max={24 * 90}
value={editPanel.offset_hours || 48} value={String(editPanel.offset_hours || 48)}
onChange={(e) => onChange={(e) =>
updatePanel(editPanel.id, { updatePanel(editPanel.id, {
offset_hours: Number(e.target.value) || 24, offset_hours: Number(e.target.value) || 24,
@ -612,7 +610,7 @@ export function PortTrafficWallPage() {
) : null} ) : null}
<label> <label>
{t("portTraffic.yMode")} {t("portTraffic.yMode")}
<select <FieldSelect
value={editPanel.y_mode} value={editPanel.y_mode}
onChange={(e) => onChange={(e) =>
updatePanel(editPanel.id, { y_mode: e.target.value as WallYMode }) updatePanel(editPanel.id, { y_mode: e.target.value as WallYMode })
@ -621,12 +619,12 @@ export function PortTrafficWallPage() {
<option value="auto">{t("portTraffic.yModeAuto")}</option> <option value="auto">{t("portTraffic.yModeAuto")}</option>
<option value="current">{t("portTraffic.yModeCurrent")}</option> <option value="current">{t("portTraffic.yModeCurrent")}</option>
<option value="util">{t("portTraffic.yModeUtil")}</option> <option value="util">{t("portTraffic.yModeUtil")}</option>
</select> </FieldSelect>
</label> </label>
<label> <label>
{t("portTraffic.boardColSpan")} {t("portTraffic.boardColSpan")}
<select <FieldSelect
value={editPanel.col_span || 1} value={String(editPanel.col_span || 1)}
onChange={(e) => onChange={(e) =>
updatePanel(editPanel.id, { updatePanel(editPanel.id, {
col_span: Math.min(cols, Number(e.target.value) || 1), col_span: Math.min(cols, Number(e.target.value) || 1),
@ -638,11 +636,11 @@ export function PortTrafficWallPage() {
{n} {n}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> </label>
<label> <label>
{t("portTraffic.filterMap")} · {t("portTraffic.wallDevice")} {t("portTraffic.filterMap")} · {t("portTraffic.wallDevice")}
<select <FieldSelect
value={editMapBaselineDeviceId} value={editMapBaselineDeviceId}
disabled={!editPanel.target_id} disabled={!editPanel.target_id}
onChange={(e) => { onChange={(e) => {
@ -659,11 +657,11 @@ export function PortTrafficWallPage() {
{d.ne_name || d.ne_ip || d.id} {d.ne_name || d.ne_ip || d.id}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> </label>
<label> <label>
{t("portTraffic.mapBaselinePort")} {t("portTraffic.mapBaselinePort")}
<select <FieldSelect
value={editPanel.baseline_target_id || ""} value={editPanel.baseline_target_id || ""}
disabled={!editMapBaselineDeviceId} disabled={!editMapBaselineDeviceId}
onChange={(e) => { onChange={(e) => {
@ -682,18 +680,18 @@ export function PortTrafficWallPage() {
{tgt.ifname} {tgt.ifname}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> </label>
{editPanel.baseline !== "off" || editPanel.baseline_target_id ? ( {editPanel.baseline !== "off" || editPanel.baseline_target_id ? (
<label> <label>
{t("portTraffic.aheadHours")} {t("portTraffic.aheadHours")}
<input <Input
type="number" type="number"
min={0} min={0}
max={24} max={24}
step={1} step={1}
title={t("portTraffic.aheadHoursHint")} aria-label={t("portTraffic.aheadHoursHint")}
value={editPanel.ahead_hours ?? 1} value={String(editPanel.ahead_hours ?? 1)}
onChange={(e) => onChange={(e) =>
updatePanel(editPanel.id, { updatePanel(editPanel.id, {
ahead_hours: Math.max(0, Math.min(24, Number(e.target.value) || 0)), ahead_hours: Math.max(0, Math.min(24, Number(e.target.value) || 0)),
@ -704,22 +702,18 @@ export function PortTrafficWallPage() {
) : null} ) : null}
</div> </div>
{editPanel.baseline === "week" ? ( {editPanel.baseline === "week" ? (
<p className="muted" style={{ marginTop: 10 }}> <p className="muted">{t("portTraffic.retentionHint")}</p>
{t("portTraffic.retentionHint")}
</p>
) : null} ) : null}
{editPanel.baseline_target_id ? ( {editPanel.baseline_target_id ? (
<p className="muted" style={{ marginTop: 10 }}> <p className="muted">{t("portTraffic.mapBaselineHint")}</p>
{t("portTraffic.mapBaselineHint")}
</p>
) : null} ) : null}
<div className="modal__actions"> </Modal.Body>
<button type="button" className="btn-primary" onClick={closePanelSettings}> <Modal.Footer>
<Button variant="primary" onPress={closePanelSettings}>
{t("portTraffic.logClose")} {t("portTraffic.logClose")}
</button> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
</div>
) : null} ) : null}
</section> </section>
); );

View file

@ -1,3 +1,4 @@
import { Button, Input } from "@heroui/react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -9,13 +10,15 @@ import {
matchFabricNodes, matchFabricNodes,
patchFabricNodeTags, patchFabricNodeTags,
} from "../../services/api"; } from "../../services/api";
import { ListPager } from "../../components/ListPager";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast"; import { useToast } from "../../hooks/useToast";
import { openOrFocusModule } from "../../utils/moduleWindows"; import { openOrFocusModule } from "../../utils/moduleWindows";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types"; import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
import { pageCount } from "../../utils/display";
const PAGE_SIZE = 50; const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] { function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
if (!root) return []; if (!root) return [];
@ -95,6 +98,7 @@ export function TopologyClassifyPage() {
const [unmatched, setUnmatched] = useState(""); const [unmatched, setUnmatched] = useState("");
const [linkStatus, setLinkStatus] = useState(""); const [linkStatus, setLinkStatus] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const linkLabel = (status: string | undefined) => { const linkLabel = (status: string | undefined) => {
switch (String(status || "").toLowerCase()) { switch (String(status || "").toLowerCase()) {
@ -144,6 +148,7 @@ export function TopologyClassifyPage() {
linkStatus, linkStatus,
page, page,
filterMajor, filterMajor,
pageSize,
), ),
queryFn: () => queryFn: () =>
fetchFabricNodes({ fetchFabricNodes({
@ -153,7 +158,7 @@ export function TopologyClassifyPage() {
unmatched, unmatched,
linkStatus, linkStatus,
page, page,
pageSize: PAGE_SIZE, pageSize,
}), }),
}); });
@ -246,7 +251,7 @@ export function TopologyClassifyPage() {
.filter((n) => selected[n.id] && isFabricNodeDeletable(n)) .filter((n) => selected[n.id] && isFabricNodeDeletable(n))
.map((n) => n.id); .map((n) => n.id);
const total = listQuery.data?.total || 0; const total = listQuery.data?.total || 0;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const pages = pageCount(total, pageSize);
const selectedCount = Object.values(selected).filter(Boolean).length; const selectedCount = Object.values(selected).filter(Boolean).length;
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]); const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
@ -273,10 +278,9 @@ export function TopologyClassifyPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("topoClassify.title")}</h2> <h2>{t("topoClassify.title")}</h2>
<div className="btn-row"> <div className="btn-row">
<button <Button
type="button" variant="primary"
className="btn-primary" onPress={() => {
onClick={() => {
const regionId = filterRegion || assignRegion || ""; const regionId = filterRegion || assignRegion || "";
const folder = regions.find((r) => r.id === regionId); const folder = regions.find((r) => r.id === regionId);
const views = folder?.views || []; const views = folder?.views || [];
@ -289,15 +293,14 @@ export function TopologyClassifyPage() {
}} }}
> >
{t("topoClassify.openTopo")} {t("topoClassify.openTopo")}
</button> </Button>
</div> </div>
</div> </div>
<section className="topo-classify__section topo-classify__section--first"> <section className="topo-classify__section topo-classify__section--first">
<h3>{t("topoClassify.inventory")}</h3> <h3>{t("topoClassify.inventory")}</h3>
<div className="topo-classify__draft"> <div className="topo-classify__draft">
<input <Input
className="input"
placeholder={t("topoClassify.keywordPh")} placeholder={t("topoClassify.keywordPh")}
value={keyword} value={keyword}
onChange={(e) => { onChange={(e) => {
@ -305,8 +308,7 @@ export function TopologyClassifyPage() {
setPage(1); setPage(1);
}} }}
/> />
<select <select className="input"
className="input"
value={filterMajor} value={filterMajor}
onChange={(e) => { onChange={(e) => {
setFilterMajor(e.target.value); setFilterMajor(e.target.value);
@ -319,8 +321,7 @@ export function TopologyClassifyPage() {
</option> </option>
))} ))}
</select> </select>
<select <select className="input"
className="input"
value={filterRegion} value={filterRegion}
onChange={(e) => { onChange={(e) => {
setFilterRegion(e.target.value); setFilterRegion(e.target.value);
@ -334,8 +335,7 @@ export function TopologyClassifyPage() {
</option> </option>
))} ))}
</select> </select>
<select <select className="input"
className="input"
value={unmatched} value={unmatched}
onChange={(e) => { onChange={(e) => {
setUnmatched(e.target.value); setUnmatched(e.target.value);
@ -347,8 +347,7 @@ export function TopologyClassifyPage() {
<option value="level">{t("topoClassify.kindLevel")}</option> <option value="level">{t("topoClassify.kindLevel")}</option>
<option value="region">{t("topoClassify.kindRegion")}</option> <option value="region">{t("topoClassify.kindRegion")}</option>
</select> </select>
<select <select className="input"
className="input"
value={linkStatus} value={linkStatus}
onChange={(e) => { onChange={(e) => {
setLinkStatus(e.target.value); setLinkStatus(e.target.value);
@ -363,14 +362,13 @@ export function TopologyClassifyPage() {
<div className="topo-classify__regex-bar"> <div className="topo-classify__regex-bar">
<strong>{t("topoClassify.regexTitle")}</strong> <strong>{t("topoClassify.regexTitle")}</strong>
<input <Input
className="input topo-classify__regex-input" className="topo-classify__regex-input"
placeholder={t("topoClassify.patternPh")} placeholder={t("topoClassify.patternPh")}
value={regex} value={regex}
onChange={(e) => setRegex(e.target.value)} onChange={(e) => setRegex(e.target.value)}
/> />
<select <select className="input"
className="input"
value={matchField} value={matchField}
onChange={(e) => setMatchField(e.target.value as "name" | "ip" | "name_ip")} onChange={(e) => setMatchField(e.target.value as "name" | "ip" | "name_ip")}
> >
@ -378,14 +376,14 @@ export function TopologyClassifyPage() {
<option value="ip">IP</option> <option value="ip">IP</option>
<option value="name_ip">{t("topoClassify.fieldNameIp")}</option> <option value="name_ip">{t("topoClassify.fieldNameIp")}</option>
</select> </select>
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
disabled={!regex.trim() || matchMut.isPending} isDisabled={!regex.trim() || matchMut.isPending}
onClick={() => matchMut.mutate()} onPress={() => matchMut.mutate()}
> >
{t("topoClassify.findMatches")} {t("topoClassify.findMatches")}
</button> </Button>
{matchTotal > 0 ? ( {matchTotal > 0 ? (
<span className="panel__hint"> <span className="panel__hint">
{t("topoClassify.matchHint").replace("{{count}}", String(matchTotal))} {t("topoClassify.matchHint").replace("{{count}}", String(matchTotal))}
@ -394,8 +392,7 @@ export function TopologyClassifyPage() {
</div> </div>
<div className="topo-classify__assign-bar"> <div className="topo-classify__assign-bar">
<select <select className="input"
className="input"
value={assignWhat} value={assignWhat}
onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")} onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")}
> >
@ -404,11 +401,7 @@ export function TopologyClassifyPage() {
<option value="both">{t("topoClassify.assignBoth")}</option> <option value="both">{t("topoClassify.assignBoth")}</option>
</select> </select>
{assignWhat !== "region" ? ( {assignWhat !== "region" ? (
<select <select className="input" value={assignLevel} onChange={(e) => setAssignLevel(e.target.value)}>
className="input"
value={assignLevel}
onChange={(e) => setAssignLevel(e.target.value)}
>
{LEVEL_PRESETS.map((r) => ( {LEVEL_PRESETS.map((r) => (
<option key={r.value} value={r.value}> <option key={r.value} value={r.value}>
{levelPresetLabel(t, r.value)} {levelPresetLabel(t, r.value)}
@ -417,11 +410,7 @@ export function TopologyClassifyPage() {
</select> </select>
) : null} ) : null}
{assignWhat !== "level" ? ( {assignWhat !== "level" ? (
<select <select className="input" value={assignRegion} onChange={(e) => setAssignRegion(e.target.value)}>
className="input"
value={assignRegion}
onChange={(e) => setAssignRegion(e.target.value)}
>
<option value="">{t("topoClassify.clearRegion")}</option> <option value="">{t("topoClassify.clearRegion")}</option>
{regions.map((r) => ( {regions.map((r) => (
<option key={r.id} value={r.id}> <option key={r.id} value={r.id}>
@ -430,24 +419,23 @@ export function TopologyClassifyPage() {
))} ))}
</select> </select>
) : null} ) : null}
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
disabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)} isDisabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)}
onClick={() => { onPress={() => {
const n = selectedCount || matchedIds.length; const n = selectedCount || matchedIds.length;
const msg = t("topoClassify.bulkConfirm").replace("{{count}}", String(n)); const msg = t("topoClassify.bulkConfirm").replace("{{count}}", String(n));
if (window.confirm(msg)) bulkMut.mutate(); if (window.confirm(msg)) bulkMut.mutate();
}} }}
> >
{t("topoClassify.confirmAssign")} {t("topoClassify.confirmAssign")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn btn--sm btn--danger" variant="danger"
disabled={deleteBulkMut.isPending || deletableSelected.length === 0} isDisabled={deleteBulkMut.isPending || deletableSelected.length === 0}
title={t("topoClassify.deleteHint")} onPress={() => {
onClick={() => {
const msg = t("topoClassify.deleteConfirm").replace( const msg = t("topoClassify.deleteConfirm").replace(
"{{count}}", "{{count}}",
String(deletableSelected.length), String(deletableSelected.length),
@ -459,7 +447,7 @@ export function TopologyClassifyPage() {
"{{count}}", "{{count}}",
String(deletableSelected.length), String(deletableSelected.length),
)} )}
</button> </Button>
<span className="topo-classify__help" tabIndex={0} aria-label={t("topoClassify.deleteHint")}> <span className="topo-classify__help" tabIndex={0} aria-label={t("topoClassify.deleteHint")}>
? ?
<span className="topo-classify__help-tip" role="tooltip"> <span className="topo-classify__help-tip" role="tooltip">
@ -510,8 +498,7 @@ export function TopologyClassifyPage() {
</td> </td>
<td> <td>
{editing ? ( {editing ? (
<input <Input
className="input"
value={editLevel} value={editLevel}
placeholder="1 / 1.1 / 2.1" placeholder="1 / 1.1 / 2.1"
onChange={(e) => setEditLevel(e.target.value)} onChange={(e) => setEditLevel(e.target.value)}
@ -522,11 +509,7 @@ export function TopologyClassifyPage() {
</td> </td>
<td> <td>
{editing ? ( {editing ? (
<select <select className="input" value={editRegion} onChange={(e) => setEditRegion(e.target.value)}>
className="input"
value={editRegion}
onChange={(e) => setEditRegion(e.target.value)}
>
<option value="">-</option> <option value="">-</option>
{regions.map((r) => ( {regions.map((r) => (
<option key={r.id} value={r.id}> <option key={r.id} value={r.id}>
@ -543,41 +526,28 @@ export function TopologyClassifyPage() {
<td> <td>
{editing ? ( {editing ? (
<div className="topo-classify__row-actions"> <div className="topo-classify__row-actions">
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
disabled={patchMut.isPending} isDisabled={patchMut.isPending}
onClick={() => patchMut.mutate()} onPress={() => patchMut.mutate()}
> >
{t("topoClassify.save")} {t("topoClassify.save")}
</button> </Button>
<button <Button size="sm" variant="ghost" onPress={() => setEditingId("")}>
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setEditingId("")}
>
{t("topoClassify.cancel")} {t("topoClassify.cancel")}
</button> </Button>
</div> </div>
) : ( ) : (
<div className="topo-classify__row-actions"> <div className="topo-classify__row-actions">
<button <Button size="sm" variant="ghost" onPress={() => startEdit(n)}>
type="button"
className="btn btn--sm btn--ghost"
onClick={() => startEdit(n)}
>
{t("topoClassify.edit")} {t("topoClassify.edit")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn btn--sm btn--danger" variant="danger"
disabled={deleteOneMut.isPending || !isFabricNodeDeletable(n)} isDisabled={deleteOneMut.isPending || !isFabricNodeDeletable(n)}
title={ onPress={() => {
isFabricNodeDeletable(n)
? t("topoClassify.deleteHint")
: t("topoClassify.deleteBlocked")
}
onClick={() => {
if (!isFabricNodeDeletable(n)) return; if (!isFabricNodeDeletable(n)) return;
const msg = t("topoClassify.deleteConfirm").replace( const msg = t("topoClassify.deleteConfirm").replace(
"{{count}}", "{{count}}",
@ -587,7 +557,7 @@ export function TopologyClassifyPage() {
}} }}
> >
{t("topoClassify.delete")} {t("topoClassify.delete")}
</button> </Button>
</div> </div>
)} )}
</td> </td>
@ -603,30 +573,19 @@ export function TopologyClassifyPage() {
) : null} ) : null}
</tbody> </tbody>
</table> </table>
<div className="topo-classify__pager"> <ListPager
<button page={page}
type="button" pages={pages}
className="btn btn--sm btn--ghost" total={total}
disabled={page <= 1} pageSize={pageSize}
onClick={() => setPage((p) => Math.max(1, p - 1))} pageSizeOptions={PAGE_SIZE_OPTIONS}
> onPageChange={setPage}
{t("common.prevPage")} onPageSizeChange={(size) => {
</button> setPageSize(size);
<span> setPage(1);
{t("common.pagerMeta") }}
.replace("{{total}}", String(total)) disabled={listQuery.isLoading}
.replace("{{page}}", String(page)) />
.replace("{{pages}}", String(pages))}
</span>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={page >= pages}
onClick={() => setPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
</section> </section>
</div> </div>
); );

View file

@ -1,5 +1,6 @@
import type { RefObject } from "react"; import type { RefObject } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Button } from "@heroui/react";
import type { Edge, Node } from "@xyflow/react"; import type { Edge, Node } from "@xyflow/react";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import type { CtxMenu } from "./pageTypes"; import type { CtxMenu } from "./pageTypes";
@ -372,16 +373,16 @@ export function TopologyCtxMenu({
))} ))}
</ul> </ul>
{!expandPhysicalLinks ? ( {!expandPhysicalLinks ? (
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
onClick={() => { onPress={() => {
onExpandPhysicalLinks(); onExpandPhysicalLinks();
onClose(); onClose();
}} }}
> >
{t("topology.expandPhysicalLinks")} {t("topology.expandPhysicalLinks")}
</button> </Button>
) : null} ) : null}
</div> </div>
) : null} ) : null}

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import type { TopologyDiscoverOut } from "../../types"; import type { TopologyDiscoverOut } from "../../types";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { openOrFocusModule } from "../../utils/moduleWindows"; import { openOrFocusModule } from "../../utils/moduleWindows";
@ -48,10 +49,10 @@ export function TopologyDiscoverPanel({
<div className="topo-discover"> <div className="topo-discover">
<div className="topo-discover__head"> <div className="topo-discover__head">
<strong>{t("topology.discoverReport")}</strong> <strong>{t("topology.discoverReport")}</strong>
<button <Button
type="button" size="sm"
className="btn btn--sm btn--ghost" variant="ghost"
onClick={() => { onPress={() => {
if (discovering) { if (discovering) {
void onCancelDiscover(); void onCancelDiscover();
return; return;
@ -60,7 +61,7 @@ export function TopologyDiscoverPanel({
}} }}
> >
{discovering ? t("topology.discoverCancel") : t("topology.discoverClose")} {discovering ? t("topology.discoverCancel") : t("topology.discoverClose")}
</button> </Button>
</div> </div>
{discovering && discoverProgress.total > 0 ? ( {discovering && discoverProgress.total > 0 ? (
<div className="topo-discover__progress" aria-live="polite"> <div className="topo-discover__progress" aria-live="polite">
@ -96,10 +97,10 @@ export function TopologyDiscoverPanel({
.replace("{{failed}}", String(discoverSummary.failed))} .replace("{{failed}}", String(discoverSummary.failed))}
</p> </p>
<p className="panel__hint"> <p className="panel__hint">
<button <Button
type="button" size="sm"
className="btn btn--sm btn--ghost" variant="ghost"
onClick={() => { onPress={() => {
const jobId = String(discoverReport?.job_id || "").trim(); const jobId = String(discoverReport?.job_id || "").trim();
const qs = jobId ? `?job_id=${encodeURIComponent(jobId)}` : ""; const qs = jobId ? `?job_id=${encodeURIComponent(jobId)}` : "";
openOrFocusModule({ openOrFocusModule({
@ -109,7 +110,7 @@ export function TopologyDiscoverPanel({
}} }}
> >
{t("topology.discoverGoLldp")} {t("topology.discoverGoLldp")}
</button> </Button>
</p> </p>
</> </>
) : null} ) : null}

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { Fragment } from "react"; import { Fragment } from "react";
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types"; import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
@ -125,17 +126,17 @@ export function TopologyHexBrowser({
<p>{t("topology.treeLoadFailed")}</p> <p>{t("topology.treeLoadFailed")}</p>
{treeError ? <span className="muted"> ({String(treeError)})</span> : null} {treeError ? <span className="muted"> ({String(treeError)})</span> : null}
<div className="topo-browser__empty-actions"> <div className="topo-browser__empty-actions">
<button type="button" className="btn btn--sm btn--ghost" onClick={onTreeRetry}> <Button size="sm" variant="ghost" onPress={onTreeRetry}>
{t("topology.treeRetry")} {t("topology.treeRetry")}
</button> </Button>
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
onClick={promptNewRegion} onPress={promptNewRegion}
disabled={createRegionPending} isDisabled={createRegionPending}
> >
{t("topology.newRegion")} {t("topology.newRegion")}
</button> </Button>
</div> </div>
</div> </div>
) : regions.length === 0 ? ( ) : regions.length === 0 ? (
@ -144,14 +145,14 @@ export function TopologyHexBrowser({
<RegionGlyph size={36} /> <RegionGlyph size={36} />
</span> </span>
<p>{t("topology.emptyMaps")}</p> <p>{t("topology.emptyMaps")}</p>
<button <Button
type="button" size="sm"
className="btn btn--sm" variant="secondary"
onClick={promptNewRegion} onPress={promptNewRegion}
disabled={createRegionPending} isDisabled={createRegionPending}
> >
{t("topology.newRegion")} {t("topology.newRegion")}
</button> </Button>
</div> </div>
) : ( ) : (
<div className="topo-browser__grid topo-browser__grid--regions"> <div className="topo-browser__grid topo-browser__grid--regions">

View file

@ -1,4 +1,5 @@
import type { MouseEvent as ReactMouseEvent, RefObject } from "react"; import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import { Button, Input } from "@heroui/react";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types"; import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { WORLD_MAP_ENABLED } from "./constants"; import { WORLD_MAP_ENABLED } from "./constants";
@ -71,24 +72,24 @@ export function TopologySidebar({
<aside className="topo-sidebar" aria-label={t("topology.maps")}> <aside className="topo-sidebar" aria-label={t("topology.maps")}>
{collapsed ? ( {collapsed ? (
<div className="topo-sidebar__rail-wrap"> <div className="topo-sidebar__rail-wrap">
<button <Button
type="button" isIconOnly
variant="ghost"
className="topo-sidebar__rail" className="topo-sidebar__rail"
title={t("topology.expandSidebar")}
aria-label={t("topology.expandSidebar")} aria-label={t("topology.expandSidebar")}
onClick={onExpand} onPress={onExpand}
> >
<span className="topo-sidebar__rail-icon" aria-hidden="true"> <span className="topo-sidebar__rail-icon" aria-hidden="true">
<SidebarFoldIcon expand /> <SidebarFoldIcon expand />
</span> </span>
</button> </Button>
</div> </div>
) : ( ) : (
<> <>
<div className="topo-sidebar__section"> <div className="topo-sidebar__section">
<div className="topo-tree-search" ref={treeSearchRef}> <div className="topo-tree-search" ref={treeSearchRef}>
<div className="topo-tree-search__bar"> <div className="topo-tree-search__bar">
<input <Input
className="input" className="input"
type="search" type="search"
value={treeNeQuery} value={treeNeQuery}
@ -100,25 +101,27 @@ export function TopologySidebar({
}} }}
onFocus={() => onTreeSearchOpen(true)} onFocus={() => onTreeSearchOpen(true)}
/> />
<button <Button
type="button" isIconOnly
size="sm"
variant="ghost"
className="topo-sidebar__icon-btn" className="topo-sidebar__icon-btn"
onClick={promptNewRegion} onPress={promptNewRegion}
disabled={createRegionPending} isDisabled={createRegionPending}
title={t("topology.newRegion")}
aria-label={t("topology.newRegion")} aria-label={t("topology.newRegion")}
> >
<PlusIcon /> <PlusIcon />
</button> </Button>
<button <Button
type="button" isIconOnly
size="sm"
variant="ghost"
className="topo-sidebar__icon-btn" className="topo-sidebar__icon-btn"
title={t("topology.collapseSidebar")}
aria-label={t("topology.collapseSidebar")} aria-label={t("topology.collapseSidebar")}
onClick={onCollapse} onPress={onCollapse}
> >
<SidebarFoldIcon /> <SidebarFoldIcon />
</button> </Button>
</div> </div>
{treeSearchOpen && treeNeQuery.trim() ? ( {treeSearchOpen && treeNeQuery.trim() ? (
<div className="topo-tree-search__panel" role="listbox"> <div className="topo-tree-search__panel" role="listbox">
@ -206,9 +209,9 @@ export function TopologySidebar({
<> <>
{t("topology.treeLoadFailed")} {t("topology.treeLoadFailed")}
{treeError ? <span className="muted"> ({String(treeError)})</span> : null}{" "} {treeError ? <span className="muted"> ({String(treeError)})</span> : null}{" "}
<button type="button" className="btn btn--sm btn--ghost" onClick={onTreeRetry}> <Button size="sm" variant="ghost" onPress={onTreeRetry}>
{t("topology.treeRetry")} {t("topology.treeRetry")}
</button> </Button>
</> </>
) : ( ) : (
t("topology.emptyMaps") t("topology.emptyMaps")

View file

@ -1,4 +1,5 @@
import { Fragment, type RefObject } from "react"; import { Button, Dropdown } from "@heroui/react";
import { Fragment, type Key, type RefObject, useMemo } from "react";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react"; import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types"; import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types";
import { HelpHint } from "../../components/HelpHint"; import { HelpHint } from "../../components/HelpHint";
@ -31,7 +32,6 @@ export type TopologyToolbarProps = {
savePending: boolean; savePending: boolean;
onSave: () => void; onSave: () => void;
onFit: () => void; onFit: () => void;
exportMenuRef: RefObject<HTMLDetailsElement | null>;
exporting: boolean; exporting: boolean;
onExport: (kind: "svg" | "xml") => void; onExport: (kind: "svg" | "xml") => void;
mapId: string; mapId: string;
@ -51,6 +51,16 @@ export type TopologyToolbarProps = {
) => TopologyTreeViewItem | null | undefined; ) => TopologyTreeViewItem | null | undefined;
}; };
type MoreActionKey =
| "live-sync"
| "back"
| "undo"
| "redo"
| "fit"
| "export-svg"
| "export-xml"
| "remove-stale";
export function TopologyToolbar({ export function TopologyToolbar({
readOnly = false, readOnly = false,
breadcrumbFolders, breadcrumbFolders,
@ -75,7 +85,6 @@ export function TopologyToolbar({
savePending, savePending,
onSave, onSave,
onFit, onFit,
exportMenuRef,
exporting, exporting,
onExport, onExport,
mapId, mapId,
@ -94,6 +103,27 @@ export function TopologyToolbar({
}: TopologyToolbarProps) { }: TopologyToolbarProps) {
const { t } = useI18n(); const { t } = useI18n();
const canvasEmpty = !isWorldFlatCanvas && nodes.length === 0;
const moreDisabledKeys = useMemo(() => {
const keys: MoreActionKey[] = [];
if (readOnly || !canUndo) keys.push("undo");
if (readOnly || !canRedo) keys.push("redo");
if (canvasEmpty) keys.push("fit");
if (exporting || canvasEmpty) keys.push("export-svg");
if (exporting || !mapId) keys.push("export-xml");
if (readOnly || !staleEdgeCount) keys.push("remove-stale");
return keys;
}, [
readOnly,
canUndo,
canRedo,
canvasEmpty,
exporting,
mapId,
staleEdgeCount,
]);
const handleCreateNe = () => { const handleCreateNe = () => {
let flowX = 80 + nodes.length * 24; let flowX = 80 + nodes.length * 24;
let flowY = 80 + nodes.length * 24; let flowY = 80 + nodes.length * 24;
@ -112,14 +142,51 @@ export function TopologyToolbar({
onCreateNe(flowX, flowY); onCreateNe(flowX, flowY);
}; };
const handleMoreAction = (key: Key) => {
switch (key as MoreActionKey) {
case "live-sync":
onLiveSyncToggle();
break;
case "back":
onBackUp();
break;
case "undo":
onUndo();
break;
case "redo":
onRedo();
break;
case "fit":
onFit();
break;
case "export-svg":
void onExport("svg");
break;
case "export-xml":
void onExport("xml");
break;
case "remove-stale":
void onRemoveStale();
break;
default:
break;
}
};
return ( return (
<div className="topo-toolbar"> <div className="topo-toolbar">
<div className="topo-toolbar__row"> <div className="topo-toolbar__row">
<div className="topo-toolbar__title"> <div className="topo-toolbar__title">
<div className="topo-breadcrumb"> <div className="topo-breadcrumb">
<button type="button" className="topo-breadcrumb__link" onClick={() => goRoot()}> <Button
type="button"
variant="ghost"
size="sm"
className="topo-breadcrumb__link"
onPress={() => goRoot()}
>
{t("topology.rootName")} {t("topology.rootName")}
</button> </Button>
{breadcrumbFolders.map((folder, idx) => { {breadcrumbFolders.map((folder, idx) => {
const isLast = idx === breadcrumbFolders.length - 1; const isLast = idx === breadcrumbFolders.length - 1;
const primaryView = primaryViewOfFolder(folder); const primaryView = primaryViewOfFolder(folder);
@ -140,13 +207,15 @@ export function TopologyToolbar({
{dirty ? " *" : ""} {dirty ? " *" : ""}
</span> </span>
) : ( ) : (
<button <Button
type="button" type="button"
variant="ghost"
size="sm"
className="topo-breadcrumb__link" className="topo-breadcrumb__link"
onClick={() => goRegion(folder.id)} onPress={() => goRegion(folder.id)}
> >
{regionDisplayName(folder, t)} {regionDisplayName(folder, t)}
</button> </Button>
)} )}
</Fragment> </Fragment>
); );
@ -175,116 +244,81 @@ export function TopologyToolbar({
) : null} ) : null}
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} /> <HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
</div> </div>
<div className="topo-toolbar__actions"> <div className="topo-toolbar__actions topo-toolbar__primary">
<button <Button
type="button" type="button"
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`} size="sm"
aria-pressed={liveSync} variant="secondary"
title={t("topology.liveSyncHint")} isDisabled={readOnly || isWorldFlatCanvas}
onClick={onLiveSyncToggle} onPress={onAddNe}
>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={onAddNe}
> >
{t("topology.addNe")} {t("topology.addNe")}
</button> </Button>
<button <Button
type="button" type="button"
className="btn btn--sm" size="sm"
disabled={readOnly || isWorldFlatCanvas} variant="secondary"
title={ isDisabled={readOnly || isWorldFlatCanvas}
readOnly onPress={handleCreateNe}
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={handleCreateNe}
> >
{t("topology.createNe")} {t("topology.createNe")}
</button> </Button>
<button type="button" className="btn btn--sm btn--ghost" onClick={onBackUp}> <Button
{t("topology.backUp")}
</button>
<button
type="button" type="button"
className="btn btn--sm btn--ghost" size="sm"
disabled={readOnly || !canUndo} className={dirty ? "topo-toolbar__save topo-toolbar__save--dirty" : "topo-toolbar__save"}
onClick={onUndo} variant={dirty ? "primary" : "ghost"}
title="Ctrl+Z" isDisabled={readOnly || savePending || !dirty}
> onPress={onSave}
{t("topology.undo")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={readOnly || !canRedo}
onClick={onRedo}
title="Ctrl+Y"
>
{t("topology.redo")}
</button>
<button
type="button"
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
disabled={readOnly || savePending || !dirty}
onClick={onSave}
title={readOnly ? t("topology.readOnlyHint") : "Ctrl+S"}
> >
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")} {savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
</button> </Button>
<button <Dropdown>
type="button" <Button type="button" size="sm" variant="ghost" aria-label={t("topology.more")}>
className="btn btn--sm btn--ghost" {t("topology.more")}
disabled={!isWorldFlatCanvas && nodes.length === 0} </Button>
onClick={onFit} <Dropdown.Popover className="topo-toolbar__more-popover" placement="bottom end">
<Dropdown.Menu
aria-label={t("topology.more")}
disabledKeys={moreDisabledKeys}
onAction={handleMoreAction}
> >
<Dropdown.Item id="live-sync" textValue={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</Dropdown.Item>
<Dropdown.Item id="back" textValue={t("topology.backUp")}>
{t("topology.backUp")}
</Dropdown.Item>
<Dropdown.Item id="undo" textValue={t("topology.undo")}>
{t("topology.undo")}
</Dropdown.Item>
<Dropdown.Item id="redo" textValue={t("topology.redo")}>
{t("topology.redo")}
</Dropdown.Item>
<Dropdown.Item id="fit" textValue={t("topology.fit")}>
{t("topology.fit")} {t("topology.fit")}
</button> </Dropdown.Item>
<details className="topo-toolbar__export" ref={exportMenuRef}> <Dropdown.Item
<summary>{exporting ? t("topology.exporting") : t("topology.export")}</summary> id="export-svg"
<div className="topo-export-menu" role="menu" aria-label={t("topology.export")}> textValue={exporting ? t("topology.exporting") : t("topology.exportSvg")}
<button
type="button"
className="topo-export-menu__btn"
role="menuitem"
disabled={exporting || (!isWorldFlatCanvas && nodes.length === 0)}
onClick={() => void onExport("svg")}
> >
{t("topology.exportSvg")} {exporting ? t("topology.exporting") : t("topology.exportSvg")}
</button> </Dropdown.Item>
<button <Dropdown.Item
type="button" id="export-xml"
className="topo-export-menu__btn" textValue={exporting ? t("topology.exporting") : t("topology.exportXml")}
role="menuitem"
disabled={exporting || !mapId}
onClick={() => void onExport("xml")}
> >
{t("topology.exportXml")} {exporting ? t("topology.exporting") : t("topology.exportXml")}
</button> </Dropdown.Item>
</div> <Dropdown.Item
</details> id="remove-stale"
<button textValue={t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
type="button"
className="btn btn--sm btn--ghost"
disabled={readOnly || !staleEdgeCount}
title={readOnly ? t("topology.readOnlyHint") : t("topology.removeStaleHint")}
onClick={() => void onRemoveStale()}
> >
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))} {t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
</button> </Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div> </div>
</div> </div>
<div className="topo-toolbar__row topo-toolbar__row--tools"> <div className="topo-toolbar__row topo-toolbar__row--tools">
@ -295,23 +329,27 @@ export function TopologyToolbar({
["pan", t("topology.toolPan"), "H"], ["pan", t("topology.toolPan"), "H"],
["connect", t("topology.toolConnect"), "C"], ["connect", t("topology.toolConnect"), "C"],
] as const ] as const
).map(([mode, label, key]) => ( ).map(([mode, label, key]) => {
<button const active = toolMode === mode;
return (
<Button
key={mode} key={mode}
type="button" type="button"
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`} className={`topo-tools__btn${active ? " is-active" : ""}`}
title={`${label} (${key})${readOnly && mode === "connect" ? ` — ${t("topology.readOnlyHint")}` : ""}`} variant={active ? "primary" : "ghost"}
aria-pressed={toolMode === mode} size="sm"
disabled={readOnly && mode === "connect"} aria-pressed={active}
onClick={() => { isDisabled={readOnly && mode === "connect"}
onPress={() => {
onToolModeChange(mode); onToolModeChange(mode);
onConnectClickReset(); onConnectClickReset();
}} }}
> >
<span className="topo-tools__label">{label}</span> <span className="topo-tools__label">{label}</span>
<kbd className="topo-tools__key">{key}</kbd> <kbd className="topo-tools__key">{key}</kbd>
</button> </Button>
))} );
})}
</div> </div>
{!fullscreen ? ( {!fullscreen ? (
<div <div
@ -322,14 +360,15 @@ export function TopologyToolbar({
/> />
) : null} ) : null}
{outsidePeerCount > 0 ? ( {outsidePeerCount > 0 ? (
<button <Button
type="button" type="button"
className="btn btn--sm btn--ghost topo-toolbar__outside-peers" size="sm"
title={t("topology.outsidePeers").replace("{{count}}", String(outsidePeerCount))} variant="ghost"
onClick={onOpenOutsidePeers} className="topo-toolbar__outside-peers"
onPress={onOpenOutsidePeers}
> >
{t("topology.outsidePeersView").replace("{{count}}", String(outsidePeerCount))} {t("topology.outsidePeersView").replace("{{count}}", String(outsidePeerCount))}
</button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>

View file

@ -1,3 +1,4 @@
import { Button, Input } from "@heroui/react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { RefObject } from "react"; import type { RefObject } from "react";
import type { Node } from "@xyflow/react"; import type { Node } from "@xyflow/react";
@ -299,16 +300,17 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors"> <div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head"> <div className="topo-display-defaults__head">
<strong>{t("topology.canvasBg")}</strong> <strong>{t("topology.canvasBg")}</strong>
<button <Button
type="button" type="button"
className="btn btn--sm btn--ghost" size="sm"
onClick={() => { variant="ghost"
onPress={() => {
onCanvasBgChange(DEFAULT_CANVAS_BG); onCanvasBgChange(DEFAULT_CANVAS_BG);
persistCanvasBg(DEFAULT_CANVAS_BG); persistCanvasBg(DEFAULT_CANVAS_BG);
}} }}
> >
{t("topology.canvasBgReset")} {t("topology.canvasBgReset")}
</button> </Button>
</div> </div>
<div className="topo-display-defaults__row"> <div className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{t("topology.canvasBgColor")}</span> <span className="topo-display-defaults__name">{t("topology.canvasBgColor")}</span>
@ -342,16 +344,17 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors"> <div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head"> <div className="topo-display-defaults__head">
<strong>{t("topology.textColors")}</strong> <strong>{t("topology.textColors")}</strong>
<button <Button
type="button" type="button"
className="btn btn--sm btn--ghost" size="sm"
onClick={() => { variant="ghost"
onPress={() => {
onLabelColorsChange({ ...DEFAULT_LABEL_COLORS }); onLabelColorsChange({ ...DEFAULT_LABEL_COLORS });
persistLabelColors({ ...DEFAULT_LABEL_COLORS }); persistLabelColors({ ...DEFAULT_LABEL_COLORS });
}} }}
> >
{t("topology.textColorsReset")} {t("topology.textColorsReset")}
</button> </Button>
</div> </div>
{( {(
[ [
@ -393,16 +396,17 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors"> <div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head"> <div className="topo-display-defaults__head">
<strong>{t("topology.vendorColors")}</strong> <strong>{t("topology.vendorColors")}</strong>
<button <Button
type="button" type="button"
className="btn btn--sm btn--ghost" size="sm"
onClick={() => { variant="ghost"
onPress={() => {
onVendorColorsChange({ ...DEFAULT_VENDOR_COLORS }); onVendorColorsChange({ ...DEFAULT_VENDOR_COLORS });
persistVendorColors({ ...DEFAULT_VENDOR_COLORS }); persistVendorColors({ ...DEFAULT_VENDOR_COLORS });
}} }}
> >
{t("topology.vendorColorsReset")} {t("topology.vendorColorsReset")}
</button> </Button>
</div> </div>
{VENDOR_TONE_KEYS.map((key) => { {VENDOR_TONE_KEYS.map((key) => {
const label = t(`topology.vendorTone.${key}`); const label = t(`topology.vendorTone.${key}`);
@ -444,9 +448,9 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-display-defaults"> <div className="topo-display-defaults">
<div className="topo-display-defaults__head"> <div className="topo-display-defaults__head">
<strong>{t("topology.edgeDefaults")}</strong> <strong>{t("topology.edgeDefaults")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onResetEdgeDefaults}> <Button type="button" size="sm" variant="ghost" onPress={onResetEdgeDefaults}>
{t("topology.edgeDefaultsReset")} {t("topology.edgeDefaultsReset")}
</button> </Button>
</div> </div>
{( {(
[ [
@ -528,7 +532,7 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
aria-label={t("topology.findNode")} aria-label={t("topology.findNode")}
ref={findBoxRef} ref={findBoxRef}
> >
<input <Input
className="topo-toolbar__find" className="topo-toolbar__find"
value={canvasQuery} value={canvasQuery}
onChange={(e) => onCanvasQueryChange(e.target.value)} onChange={(e) => onCanvasQueryChange(e.target.value)}
@ -571,20 +575,20 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div> <div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div>
) : ( ) : (
canvasHits.slice(0, 12).map((n, idx) => ( canvasHits.slice(0, 12).map((n, idx) => (
<button <Button
key={n.id} key={n.id}
type="button" type="button"
role="option" variant="ghost"
aria-selected={idx === findActiveIdx} size="sm"
className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`} className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
onMouseEnter={() => onFindActiveIdxChange(idx)} onHoverStart={() => onFindActiveIdxChange(idx)}
onClick={() => onFindOnCanvas(n.id)} onPress={() => onFindOnCanvas(n.id)}
> >
<span className="topo-find-suggest__name">{n.data.label || n.id}</span> <span className="topo-find-suggest__name">{n.data.label || n.id}</span>
<span className="topo-find-suggest__meta"> <span className="topo-find-suggest__meta">
{[n.data.ne_ip, n.data.vendor].filter(Boolean).join(SEP)} {[n.data.ne_ip, n.data.vendor].filter(Boolean).join(SEP)}
</span> </span>
</button> </Button>
)) ))
)} )}
{canvasHits.length > 12 ? ( {canvasHits.length > 12 ? (

View file

@ -25,7 +25,7 @@ import type { ManagedNeFormState } from "../../managedNe/formState";
import { isWorldFlatViewName } from "../treeUtils"; import { isWorldFlatViewName } from "../treeUtils";
import type { NeNodeData } from "../TopologyReactFlowView"; import type { NeNodeData } from "../TopologyReactFlowView";
type FocusNode = (fabricNodeId: string, fit?: boolean) => void; type FocusNode = (fabricNodeId: string, fit: boolean) => void;
type Args = { type Args = {
mapId: string | null | undefined; mapId: string | null | undefined;

View file

@ -1,4 +1,6 @@
import type { DragEvent } from "react"; import type { DragEvent } from "react";
import { Button, Chip, Input, Modal } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import { useI18n } from "../../../i18n"; import { useI18n } from "../../../i18n";
import type { PaletteItem, PaletteSource } from "../pageTypes"; import type { PaletteItem, PaletteSource } from "../pageTypes";
@ -34,54 +36,37 @@ export function AddNePaletteDialog({
onAddSelected, onAddSelected,
}: AddNePaletteDialogProps) { }: AddNePaletteDialogProps) {
const { t } = useI18n(); const { t } = useI18n();
if (!open) return null;
return ( return (
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}> <TopoModalShell open={open} onClose={onClose} dismissible={!paletteAdding} size="md">
<div <Modal.Header>
className="topo-modal__backdrop" <Modal.Heading>{t("topology.addNe")}</Modal.Heading>
onClick={() => { <Modal.CloseTrigger isDisabled={paletteAdding} />
if (paletteAdding) return; </Modal.Header>
onClose(); <Modal.Body className="flex flex-col gap-3">
}} <p className="text-sm text-muted">{t("topology.paletteHint")}</p>
/> <div className="topo-palette-source flex gap-2" role="tablist">
<div className="topo-modal__panel"> <Button
<div className="topo-modal__head"> size="sm"
<strong>{t("topology.addNe")}</strong> variant={paletteSource === "managed" ? "primary" : "secondary"}
<button isDisabled={paletteAdding}
type="button" className="flex-1"
className="btn btn--sm btn--ghost" onPress={() => onPaletteSourceChange("managed")}
disabled={paletteAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
<div className="topo-palette-source" role="tablist">
<button
type="button"
role="tab"
aria-selected={paletteSource === "managed"}
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("managed")}
> >
{t("topology.paletteManaged")} {t("topology.paletteManaged")}
</button> </Button>
<button <Button
type="button" size="sm"
role="tab" variant={paletteSource === "ume" ? "primary" : "secondary"}
aria-selected={paletteSource === "ume"} isDisabled={paletteAdding}
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`} className="flex-1"
disabled={paletteAdding} onPress={() => onPaletteSourceChange("ume")}
onClick={() => onPaletteSourceChange("ume")}
> >
{t("topology.paletteUme")} {t("topology.paletteUme")}
</button> </Button>
</div> </div>
<input <Input
className="input" className="w-full"
value={keyword} value={keyword}
onChange={(e) => onKeywordChange(e.target.value)} onChange={(e) => onKeywordChange(e.target.value)}
placeholder={t("topology.filterPh")} placeholder={t("topology.filterPh")}
@ -109,9 +94,11 @@ export function AddNePaletteDialog({
/> />
<span>{t("topology.selectAllVisible")}</span> <span>{t("topology.selectAllVisible")}</span>
</label> </label>
<span className="panel__hint"> <Chip size="sm" variant="soft">
<Chip.Label>
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))} {t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
</span> </Chip.Label>
</Chip>
</div> </div>
) : null} ) : null}
<ul className="topo-palette topo-modal__list"> <ul className="topo-palette topo-modal__list">
@ -168,27 +155,22 @@ export function AddNePaletteDialog({
}) })
)} )}
</ul> </ul>
<div className="topo-modal__foot"> </Modal.Body>
<button <Modal.Footer>
type="button" <Button variant="tertiary" size="sm" isDisabled={paletteAdding} onPress={onClose}>
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onClose}
>
{t("topology.discoverClose")} {t("topology.discoverClose")}
</button> </Button>
<button <Button
type="button" variant="primary"
className="btn btn--sm" size="sm"
disabled={paletteSelectedKeys.length === 0 || paletteAdding} isDisabled={paletteSelectedKeys.length === 0 || paletteAdding}
onClick={() => void onAddSelected()} onPress={() => void onAddSelected()}
> >
{paletteAdding {paletteAdding
? t("topology.addingNe") ? t("topology.addingNe")
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))} : t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
</button> </Button>
</div> </Modal.Footer>
</div> </TopoModalShell>
</div>
); );
} }

View file

@ -1,3 +1,5 @@
import { Button, Modal } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import { useI18n } from "../../../i18n"; import { useI18n } from "../../../i18n";
export type CreateNeModeDialogProps = { export type CreateNeModeDialogProps = {
@ -15,34 +17,34 @@ export function CreateNeModeDialog({
onPickPlaceholder, onPickPlaceholder,
}: CreateNeModeDialogProps) { }: CreateNeModeDialogProps) {
const { t } = useI18n(); const { t } = useI18n();
if (!open) return null;
return ( return (
<div className="topo-modal" role="dialog" aria-modal="true" aria-labelledby="topo-create-ne-mode-title"> <TopoModalShell open={open} onClose={onClose} size="sm">
<div className="topo-modal__backdrop" onClick={onClose} /> <Modal.Header>
<div className="topo-modal__panel" style={{ maxWidth: 440 }}> <Modal.Heading>{t("topology.createNe")}</Modal.Heading>
<div className="topo-modal__head"> <Modal.CloseTrigger />
<strong id="topo-create-ne-mode-title">{t("topology.createNe")}</strong> </Modal.Header>
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}> <Modal.Body className="flex flex-col gap-3">
{t("topology.discoverClose")} <p className="text-sm text-muted">{t("topology.createNeModeHint")}</p>
</button> <div className="flex flex-col gap-2.5">
</div> <Button
<p className="panel__hint topo-modal__hint">{t("topology.createNeModeHint")}</p> variant="primary"
<div className="topo-create-ne-mode" style={{ padding: "0 16px 16px", display: "grid", gap: 10 }}> className="h-auto flex-col items-start gap-1 whitespace-normal px-4 py-3 text-left"
<button type="button" className="btn" onClick={onPickManaged}> onPress={onPickManaged}
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNeManaged")}</span> >
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}> <span className="font-semibold">{t("topology.createNeManaged")}</span>
{t("topology.createNeManagedHint")} <span className="text-sm font-normal opacity-90">{t("topology.createNeManagedHint")}</span>
</span> </Button>
</button> <Button
<button type="button" className="btn btn--ghost" onClick={onPickPlaceholder}> variant="secondary"
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNePlaceholder")}</span> className="h-auto flex-col items-start gap-1 whitespace-normal px-4 py-3 text-left"
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}> onPress={onPickPlaceholder}
{t("topology.createNePlaceholderHint")} >
</span> <span className="font-semibold">{t("topology.createNePlaceholder")}</span>
</button> <span className="text-sm font-normal text-muted">{t("topology.createNePlaceholderHint")}</span>
</div> </Button>
</div>
</div> </div>
</Modal.Body>
</TopoModalShell>
); );
} }

View file

@ -1,3 +1,5 @@
import { Button, Input, Label, Modal, TextField } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import { useI18n } from "../../../i18n"; import { useI18n } from "../../../i18n";
export type NewRootDialogProps = { export type NewRootDialogProps = {
@ -10,57 +12,43 @@ export type NewRootDialogProps = {
export function NewRootDialog({ dialog, pending, onNameChange, onClose, onSubmit }: NewRootDialogProps) { export function NewRootDialog({ dialog, pending, onNameChange, onClose, onSubmit }: NewRootDialogProps) {
const { t } = useI18n(); const { t } = useI18n();
if (!dialog) return null; const open = Boolean(dialog);
return ( return (
<div <TopoModalShell open={open} onClose={onClose} dismissible={!pending} size="sm">
className="topo-modal" <Modal.Header>
role="dialog" <Modal.Heading>{t("topology.newRegion")}</Modal.Heading>
aria-modal="true" <Modal.CloseTrigger isDisabled={pending} />
aria-labelledby="topo-new-root-title" </Modal.Header>
> <Modal.Body className="flex flex-col gap-3">
<div className="topo-modal__backdrop" onClick={onClose} /> <p className="text-sm text-muted">{t("topology.folderHint")}</p>
<div className="topo-modal__panel" style={{ maxWidth: 420 }}> <TextField
<div className="topo-modal__head"> fullWidth
<strong id="topo-new-root-title">{t("topology.newRegion")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.folderHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">{t("topology.newRegionPrompt")}</span>
<input
autoFocus autoFocus
value={dialog.name} value={dialog?.name ?? ""}
disabled={pending} onChange={onNameChange}
onChange={(e) => onNameChange(e.target.value)} isDisabled={pending}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") onSubmit(); if (e.key === "Enter") onSubmit();
}} }}
/>
</label>
</div>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={pending}
onClick={onClose}
> >
<Label>{t("topology.newRegionPrompt")}</Label>
<Input />
</TextField>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" size="sm" isDisabled={pending} onPress={onClose}>
{t("topology.discoverClose")} {t("topology.discoverClose")}
</button> </Button>
<button <Button
type="button" variant="primary"
className="btn btn--sm" size="sm"
disabled={pending || !String(dialog.name || "").trim()} isDisabled={pending || !String(dialog?.name || "").trim()}
onClick={onSubmit} onPress={onSubmit}
> >
{pending ? "…" : t("topology.newRegion")} {pending ? "…" : t("topology.newRegion")}
</button> </Button>
</div> </Modal.Footer>
</div> </TopoModalShell>
</div>
); );
} }

View file

@ -1,3 +1,5 @@
import { Button, Chip, Input, Modal } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import type { TopologyOutsidePeer } from "../../../types"; import type { TopologyOutsidePeer } from "../../../types";
import { useI18n } from "../../../i18n"; import { useI18n } from "../../../i18n";
@ -29,40 +31,20 @@ export function OutsidePeersDialog({
onAddOutsidePeers, onAddOutsidePeers,
}: OutsidePeersDialogProps) { }: OutsidePeersDialogProps) {
const { t } = useI18n(); const { t } = useI18n();
if (!open) return null;
return ( return (
<div <TopoModalShell open={open} onClose={onClose} dismissible={!outsidePeersAdding} size="lg">
className="topo-modal" <Modal.Header>
role="dialog" <Modal.Heading>
aria-modal="true"
aria-label={t("topology.outsidePeersTitle")}
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (outsidePeersAdding) return;
onClose();
}}
/>
<div className="topo-modal__panel topo-modal__panel--wide">
<div className="topo-modal__head">
<strong>
{t("topology.outsidePeersTitle")} {t("topology.outsidePeersTitle")}
<span className="topo-modal__count"> · {outsidePeers.length}</span> <span className="topo-modal__count"> · {outsidePeers.length}</span>
</strong> </Modal.Heading>
<button <Modal.CloseTrigger isDisabled={outsidePeersAdding} />
type="button" </Modal.Header>
className="btn btn--sm btn--ghost" <Modal.Body className="flex flex-col gap-3">
disabled={outsidePeersAdding} <p className="text-sm text-muted">{t("topology.outsidePeersHint")}</p>
onClick={onClose} <Input
> className="w-full"
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.outsidePeersHint")}</p>
<input
className="input"
value={outsidePeerQuery} value={outsidePeerQuery}
onChange={(e) => onOutsidePeerQueryChange(e.target.value)} onChange={(e) => onOutsidePeerQueryChange(e.target.value)}
placeholder={t("topology.outsidePeersFilterPh")} placeholder={t("topology.outsidePeersFilterPh")}
@ -95,12 +77,14 @@ export function OutsidePeersDialog({
/> />
<span>{t("topology.selectAllVisible")}</span> <span>{t("topology.selectAllVisible")}</span>
</label> </label>
<span className="panel__hint"> <Chip size="sm" variant="soft">
<Chip.Label>
{t("topology.selectedCount").replace( {t("topology.selectedCount").replace(
"{{count}}", "{{count}}",
String(outsidePeerSelectedIds.length), String(outsidePeerSelectedIds.length),
)} )}
</span> </Chip.Label>
</Chip>
</div> </div>
) : null} ) : null}
<ul className="topo-palette topo-modal__list"> <ul className="topo-palette topo-modal__list">
@ -157,20 +141,16 @@ export function OutsidePeersDialog({
}) })
)} )}
</ul> </ul>
<div className="topo-modal__foot"> </Modal.Body>
<button <Modal.Footer>
type="button" <Button variant="tertiary" size="sm" isDisabled={outsidePeersAdding} onPress={onClose}>
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onClose}
>
{t("topology.discoverClose")} {t("topology.discoverClose")}
</button> </Button>
<button <Button
type="button" variant="primary"
className="btn btn--sm" size="sm"
disabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding} isDisabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
onClick={() => void onAddOutsidePeers(outsidePeerSelectedIds)} onPress={() => void onAddOutsidePeers(outsidePeerSelectedIds)}
> >
{outsidePeersAdding {outsidePeersAdding
? t("topology.addingNe") ? t("topology.addingNe")
@ -178,9 +158,8 @@ export function OutsidePeersDialog({
"{{count}}", "{{count}}",
String(outsidePeerSelectedIds.length), String(outsidePeerSelectedIds.length),
)} )}
</button> </Button>
</div> </Modal.Footer>
</div> </TopoModalShell>
</div>
); );
} }

View file

@ -1,3 +1,5 @@
import { Button, Input, Label, Modal, TextField } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import { useI18n } from "../../../i18n"; import { useI18n } from "../../../i18n";
export type PlaceholderCreateDialogState = { export type PlaceholderCreateDialogState = {
@ -23,82 +25,56 @@ export function PlaceholderCreateDialog({
onSubmit, onSubmit,
}: PlaceholderCreateDialogProps) { }: PlaceholderCreateDialogProps) {
const { t } = useI18n(); const { t } = useI18n();
if (!dialog) return null; const open = Boolean(dialog);
return ( return (
<div <TopoModalShell
className="topo-modal" open={open}
role="dialog" onClose={onClose}
aria-modal="true" dismissible={!busy}
aria-labelledby="topo-create-ne-title" size="sm"
> >
<div <Modal.Header>
className="topo-modal__backdrop" <Modal.Heading>{t("topology.createNeTitle")}</Modal.Heading>
onClick={() => { <Modal.CloseTrigger isDisabled={busy} />
if (busy) return; </Modal.Header>
onClose(); <Modal.Body className="flex flex-col gap-3">
}} <p className="text-sm text-muted">{t("topology.createNeHint")}</p>
/> <TextField
<div className="topo-modal__panel" style={{ maxWidth: 420 }}> fullWidth
<div className="topo-modal__head">
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={busy}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">
{t("topology.createNeName")}
<span className="form-label__required" aria-hidden="true">
{" "}
*
</span>
</span>
<input
autoFocus autoFocus
value={dialog.name} isRequired
placeholder={t("topology.createNeNamePh")} value={dialog?.name ?? ""}
disabled={busy} onChange={(name) => onChange({ name })}
onChange={(e) => onChange({ name: e.target.value })} isDisabled={busy}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit(); if (e.key === "Enter") void onSubmit();
}} }}
/>
</label>
<label>
<span className="form-label">{t("topology.createNeIp")}</span>
<input
value={dialog.ip_address}
placeholder={t("topology.createNeIpPh")}
disabled={busy}
onChange={(e) => onChange({ ip_address: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button type="button" className="btn btn--ghost" disabled={busy} onClick={onClose}>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--primary"
disabled={busy}
onClick={() => void onSubmit()}
> >
<Label>{t("topology.createNeName")}</Label>
<Input placeholder={t("topology.createNeNamePh")} />
</TextField>
<TextField
fullWidth
value={dialog?.ip_address ?? ""}
onChange={(ip_address) => onChange({ ip_address })}
isDisabled={busy}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
>
<Label>{t("topology.createNeIp")}</Label>
<Input placeholder={t("topology.createNeIpPh")} />
</TextField>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" isDisabled={busy} onPress={onClose}>
{t("topology.discoverClose")}
</Button>
<Button variant="primary" isDisabled={busy} onPress={() => void onSubmit()}>
{busy ? t("topology.createNeBusy") : t("topology.createNePlaceholder")} {busy ? t("topology.createNeBusy") : t("topology.createNePlaceholder")}
</button> </Button>
</div> </Modal.Footer>
</div> </TopoModalShell>
</div>
); );
} }

View file

@ -1,7 +1,7 @@
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */ /** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
export const TOPO_NODE_W = 80; export const TOPO_NODE_W = 88;
export const TOPO_NODE_H = 25; export const TOPO_NODE_H = 28;
export const TOPO_ICON = 25; export const TOPO_ICON = 28;
/** region-building.png is 195×133 — keep aspect so icon center == handle. */ /** region-building.png is 195×133 — keep aspect so icon center == handle. */
export const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195); export const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195);
export const TOPO_HANDLE_X = TOPO_NODE_W / 2; export const TOPO_HANDLE_X = TOPO_NODE_W / 2;

View file

@ -1,9 +1,10 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
build: { build: {
rollupOptions: { rollupOptions: {
output: { output: {
@ -28,6 +29,8 @@ export default defineConfig({
ws: true, ws: true,
// Keep Origin aligned with upstream for WS handshake through proxy. // Keep Origin aligned with upstream for WS handshake through proxy.
rewriteWsOrigin: true, rewriteWsOrigin: true,
// So NetX sees the browser address instead of 127.0.0.1 (X-Forwarded-For).
xfwd: true,
}, },
"/metrics": { "/metrics": {
target: "http://127.0.0.1:8890", target: "http://127.0.0.1:8890",