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,143 +98,151 @@ 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"
value={value.hop_vendor} label={t("managedNe.hop.type")}
onChange={(e) => { required
const hop_vendor = e.target.value as HopVendor; value={value.hop_vendor}
set(patchHopVendorChange(hop_vendor, value)); hint={t(hopHintKey(value.hop_vendor))}
}} onChange={(e) => {
> const hop_vendor = e.target.value as HopVendor;
{HOP_VENDORS.map((v) => ( set(patchHopVendorChange(hop_vendor, value));
<option key={v} value={v}> }}
{t(`managedNe.hop.vendor.${v}`)} >
</option> {HOP_VENDORS.map((v) => (
))} <option key={v} value={v}>
</select> {t(`managedNe.hop.vendor.${v}`)}
<span className="form-field-hint">{t(hopHintKey(value.hop_vendor))}</span> </option>
</label> ))}
<label> </FieldSelect>
<FormLabel required>{t("managedNe.hop.host")}</FormLabel> <TextField
<input fullWidth
required isRequired
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(); if (!raw.includes("@")) return;
if (!raw.includes("@")) return; const parsed = expandBastionHopFields(raw, value.hop_username);
const parsed = expandBastionHopFields(raw, value.hop_username); if (!parsed.hop_host || parsed.hop_host === raw) return;
if (!parsed.hop_host || parsed.hop_host === raw) return; set({
set({ hop_host: parsed.hop_host,
hop_host: parsed.hop_host, 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={String(value.hop_port)}
value={value.hop_port} onChange={(hop_port) => set({ hop_port: Number(hop_port) || 22 })}
onChange={(e) => set({ hop_port: Number(e.target.value) || 22 })} >
/> <Label>{t("managedNe.hop.port")}</Label>
</label> <Input />
</TextField>
{bastion ? ( {bastion ? (
<label className="form-grid__full"> <FieldSelect
<FormLabel>{t("managedNe.hop.targetAuthMode")}</FormLabel> fullWidth
<select className="form-grid__full"
value={value.hop_target_auth_mode} label={t("managedNe.hop.targetAuthMode")}
onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })} value={value.hop_target_auth_mode}
> hint={t("managedNe.hop.targetAuthHint")}
<option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option> onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })}
<option value="manual">{t("managedNe.hop.targetAuthManual")}</option> >
</select> <option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option>
<span className="form-field-hint">{t("managedNe.hop.targetAuthHint")}</span> <option value="manual">{t("managedNe.hop.targetAuthManual")}</option>
</label> </FieldSelect>
) : 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; set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) }); }}
}} >
> <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> </FieldSelect>
</select>
</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={(hop_vrf) => {
onChange={(e) => { set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
const hop_vrf = e.target.value; }}
set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) }); >
}} <Label>{t(vrfLabelKey(value.hop_vendor))}</Label>
/> <Input />
</label> </TextField>
{huawei ? ( {huawei ? (
<label className="form-grid__full"> <FieldSelect
<FormLabel>{t("managedNe.hop.enterSystemView")}</FormLabel> fullWidth
<select className="form-grid__full"
value={value.hop_enter_system_view ? "yes" : "no"} label={t("managedNe.hop.enterSystemView")}
onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })} value={value.hop_enter_system_view ? "yes" : "no"}
> hint={t("managedNe.hop.enterSystemViewHint")}
<option value="no">{t("managedNe.hop.enterSystemViewNo")}</option> onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })}
<option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option> >
</select> <option value="no">{t("managedNe.hop.enterSystemViewNo")}</option>
<span className="form-field-hint">{t("managedNe.hop.enterSystemViewHint")}</span> <option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option>
</label> </FieldSelect>
) : 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,77 +216,76 @@ 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={(username) => setForm({ ...form, username })}
onChange={(e) => setForm({ ...form, username: e.target.value })} >
placeholder="ca-oper" <Label>{t("ume.cli.targetUsername")}</Label>
/> <Input placeholder="ca-oper" />
</label> </TextField>
<label> <TextField
<span className="form-label">{t("ume.cli.targetPassword")}</span> fullWidth
<input type="password"
type="password" value={form.password}
value={form.password} onChange={(password) => setForm({ ...form, password })}
onChange={(e) => setForm({ ...form, password: e.target.value })} >
placeholder={form.id ? t("ume.cli.passwordKeep") : ""} <Label>{t("ume.cli.targetPassword")}</Label>
/> <Input placeholder={form.id ? t("ume.cli.passwordKeep") : ""} />
</label> </TextField>
<label> <FieldSelect
<span className="form-label">{t("managedNe.form.deviceType")}</span> label={t("managedNe.form.deviceType")}
<select 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 })} >
> {deviceTypes.map((dt) => (
{deviceTypes.map((dt) => ( <option key={dt} value={dt}>
<option key={dt} value={dt}> {dt}
{dt} </option>
</option> ))}
))} </FieldSelect>
</select> <FieldSelect
</label> label={t("managedNe.form.vendor")}
<label> value={form.vendor_default}
<span className="form-label">{t("managedNe.form.vendor")}</span> onChange={(e) => setForm({ ...form, vendor_default: e.target.value })}
<select value={form.vendor_default} 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} setForm((prev) => ({
onChange={(e) => { ...prev,
const hop_enabled = e.target.checked; hop_enabled,
setForm((prev) => ({ ...(hop_enabled
...prev, ? {
hop_enabled, hop: {
...(hop_enabled ...prev.hop,
? { ...patchHopVendorChange(prev.hop.hop_vendor, prev.hop),
hop: { ...applyHopTemplate(prev.hop, prev.hop.hop_protocol, prev.hop.hop_vrf, true),
...prev.hop, },
...patchHopVendorChange(prev.hop.hop_vendor, prev.hop), }
...applyHopTemplate(prev.hop, prev.hop.hop_protocol, prev.hop.hop_vrf, true), : {}),
}, }));
} }}
: {}), >
})); <Checkbox.Control>
}} <Checkbox.Indicator />
/> </Checkbox.Control>
<span className="form-check__text">{t("managedNe.hop.enable")}</span> <Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
</label> </Checkbox>
{form.hop_enabled ? ( {form.hop_enabled ? (
<div className="hop-proxy-fields"> <div className="hop-proxy-fields">
<HopProxyFields <HopProxyFields
@ -297,50 +298,57 @@ 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={() => {
if (!window.confirm(t("ume.cli.deleteConfirm"))) return; void (async () => {
try { if (!window.confirm(t("ume.cli.deleteConfirm"))) return;
await apiDelete(`/v1/cli/profiles/${form.id}`); try {
setForm(emptyForm()); await apiDelete(`/v1/cli/profiles/${form.id}`);
await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles }); setForm(emptyForm());
showOk(t("ume.cli.deleted")); await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles });
} catch (e) { showOk(t("ume.cli.deleted"));
showError(e instanceof Error ? e.message : String(e)); } catch (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>
{editingRow ? ( <AppModalShell open={Boolean(editingRow)} onClose={() => setEditingRow(null)} size="md">
<div <Modal.Header>
className="token-scopes-modal" <Modal.Heading>{t("auth.editScopes")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger />
aria-modal="true" </Modal.Header>
aria-label={t("auth.editScopes")} <Modal.Body className="flex flex-col gap-3">
> {editingRow ? (
<div className="token-scopes-modal__backdrop" onClick={() => setEditingRow(null)} /> <p className="text-sm text-muted">
<div className="token-scopes-modal__panel"> {editingRow.name}
<div className="token-scopes-modal__head"> {editingRow.username ? ` · ${editingRow.username}` : ""}
<div> </p>
<h3>{t("auth.editScopes")}</h3> ) : null}
<p className="panel__hint" style={{ margin: "4px 0 0" }}> <ul className="token-scopes-modal__list">
{editingRow.name} {ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => (
{editingRow.username ? ` · ${editingRow.username}` : ""} <li key={scope}>
</p> <Checkbox
</div> isSelected={editScopes.includes(scope)}
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}> onChange={() => toggleEditScope(scope)}
{t("common.cancel")} >
</button> <Checkbox.Control>
</div> <Checkbox.Indicator />
<ul className="token-scopes-modal__list"> </Checkbox.Control>
{ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => ( <Checkbox.Content>{t(SCOPE_LABEL_KEYS[scope])}</Checkbox.Content>
<li key={scope}> </Checkbox>
<label className="token-scopes-modal__row"> </li>
<input ))}
type="checkbox" {!editAvailable.length ? (
checked={editScopes.includes(scope)} <li className="muted">{t("auth.scopesNoneAvailable")}</li>
onChange={() => toggleEditScope(scope)} ) : null}
/> </ul>
<span className="token-scopes-modal__label">{t(SCOPE_LABEL_KEYS[scope])}</span> </Modal.Body>
</label> <Modal.Footer>
</li> <Button
))} variant="primary"
{!editAvailable.length ? ( isDisabled={updateMut.isPending || editScopes.length === 0 || !editingRow}
<li className="muted">{t("auth.scopesNoneAvailable")}</li> onPress={() => {
) : null} if (editingRow) updateMut.mutate({ id: editingRow.id, scopes: editScopes });
</ul> }}
<div className="token-scopes-modal__foot"> >
<button {t("auth.saveScopes")}
type="button" </Button>
disabled={updateMut.isPending || editScopes.length === 0} <Button variant="tertiary" onPress={() => setEditingRow(null)}>
onClick={() => updateMut.mutate({ id: editingRow.id, scopes: editScopes })} {t("common.cancel")}
> </Button>
{t("auth.saveScopes")} </Modal.Footer>
</button> </AppModalShell>
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}>
{t("common.cancel")}
</button>
</div>
</div>
</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,324 +804,312 @@ 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()} <Modal.Header>
> <Modal.Heading>{t("collect.create.title")}</Modal.Heading>
<div className="ops-detail-modal__head"> <Modal.CloseTrigger isDisabled={createMutation.isPending} />
<div className="ops-detail-modal__title"> </Modal.Header>
<h3>{t("collect.create.title")}</h3> <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"> <div className="form-grid form-grid--single">
<button type="button" onClick={() => setCreateOpen(false)}> <label className="form-grid__full">
{t("networkConfigs.close")} {t("collect.form.jobTitle")}
</button> <Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("collect.form.jobTitlePh")}
/>
</label>
<label className="form-grid__full">
{t("collect.form.commands")}
<textarea
className="collect-commands"
rows={6}
value={commands}
onChange={(e) => setCommands(e.target.value)}
placeholder={t("collect.form.commandsPh")}
/>
</label>
</div>
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
<div className="collect-selected-block">
<div className="collect-selected-block__head">
<h3>{t("collect.create.selectedTitle")}</h3>
<span className="collect-meta">
{t("collect.create.selectedCount", { count: selectedList.length })}
</span>
{selectedList.length > 0 ? (
<Button size="sm" variant="ghost" className="link-btn" onPress={clearSelected}>
{t("collect.create.clearSelected")}
</Button>
) : null}
</div> </div>
{selectedList.length === 0 ? (
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
) : (
<div className="collect-selected-list">
{selectedList.map((row) => {
const key = eligibleKey(row);
const src = String(row.source || "managed").toLowerCase();
return (
<div key={key} className="collect-selected-chip">
<span className="collect-selected-chip__main">
<strong>{row.name || row.ip_address}</strong>
<span className="collect-selected-chip__meta">
{src}
{row.ip_address ? ` · ${row.ip_address}` : ""}
{row.vendor ? ` · ${row.vendor}` : ""}
</span>
</span>
<Button size="sm" variant="ghost" className="link-btn" onPress={() => removeNe(key)}>
{t("collect.create.remove")}
</Button>
</div>
);
})}
</div>
)}
</div> </div>
<div className="ops-detail-modal__scroll ops-detail-modal__scroll--pad collect-create-modal"> <div className="collect-pick-block">
<div className="form-grid form-grid--single"> <div className="panel__toolbar">
<label className="form-grid__full"> <div>
{t("collect.form.jobTitle")} <h3>{t("collect.create.pickTitle")}</h3>
<input <p className="panel__hint">{t("collect.create.pickHint")}</p>
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("collect.form.jobTitlePh")}
/>
</label>
<label className="form-grid__full">
{t("collect.form.commands")}
<textarea
className="collect-commands"
rows={6}
value={commands}
onChange={(e) => setCommands(e.target.value)}
placeholder={t("collect.form.commandsPh")}
/>
</label>
</div>
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
<div className="collect-selected-block">
<div className="collect-selected-block__head">
<h3>{t("collect.create.selectedTitle")}</h3>
<span className="collect-meta">
{t("collect.create.selectedCount", { count: selectedList.length })}
</span>
{selectedList.length > 0 ? (
<button type="button" className="link-btn" onClick={clearSelected}>
{t("collect.create.clearSelected")}
</button>
) : null}
</div> </div>
{selectedList.length === 0 ? ( <div className="table-actions">
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p> <Button
) : ( size="sm"
<div className="collect-selected-list"> variant="ghost"
{selectedList.map((row) => { className="link-btn"
isDisabled={batchPickCount === 0}
onPress={() => {
const rows = eligibleItems.filter(
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
);
addBatchNe(rows);
setPickSelectedIds([]);
}}
>
{t("collect.create.addBatch", { count: batchPickCount })}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={eligibleQuery.isFetching}
onPress={() => void eligibleQuery.refetch()}
>
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</Button>
</div>
</div>
<div className="ops-detail-modal__toolbar filter-inline">
<Input
type="search"
value={neKeyword}
placeholder={t("collect.create.filterKeywordPh")}
onChange={(e) => {
setNeKeyword(e.target.value);
setNePage(1);
setPickSelectedIds([]);
}}
/>
</div>
{eligibleQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>
<input
type="checkbox"
checked={allPickSelected}
onChange={(e) => {
if (e.target.checked) {
setPickSelectedIds(selectablePickItems.map((row) => eligibleKey(row)));
return;
}
setPickSelectedIds([]);
}}
aria-label="pick all"
/>
</th>
<th>{t("managedNe.col.source")}</th>
<th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.vendor")}</th>
<th>{t("managedNe.col.ip")}</th>
<th>{t("managedNe.col.connect")}</th>
<th />
</tr>
</thead>
<tbody>
{eligibleItems.map((row) => {
const key = eligibleKey(row); const key = eligibleKey(row);
const picked = Boolean(selectedMap[key]);
const src = String(row.source || "managed").toLowerCase(); const src = String(row.source || "managed").toLowerCase();
return ( return (
<div key={key} className="collect-selected-chip"> <tr key={key}>
<span className="collect-selected-chip__main"> <td>
<strong>{row.name || row.ip_address}</strong> <input
<span className="collect-selected-chip__meta"> type="checkbox"
{src} checked={pickSelectedIds.includes(key)}
{row.ip_address ? ` · ${row.ip_address}` : ""} disabled={picked}
{row.vendor ? ` · ${row.vendor}` : ""} onChange={(e) => {
</span> if (picked) return;
</span> setPickSelectedIds((prev) =>
<button type="button" className="link-btn" onClick={() => removeNe(key)}> e.target.checked
{t("collect.create.remove")} ? [...new Set([...prev, key])]
</button> : prev.filter((id) => id !== key),
</div> );
}}
/>
</td>
<td>
<span className="table-tag">{src}</span>
</td>
<td>{row.name || row.ip_address}</td>
<td>{row.vendor}</td>
<td>{row.ip_address}</td>
<td>
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
</td>
<td className="table-actions">
<Button
size="sm"
variant="ghost"
className="link-btn"
isDisabled={picked}
onPress={() => {
addNe(row);
setPickSelectedIds((prev) => prev.filter((id) => id !== key));
}}
>
{picked ? t("collect.create.added") : t("collect.create.add")}
</Button>
</td>
</tr>
); );
})} })}
</div> {!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
)} <tr>
<td colSpan={7} className="muted">
{t("collect.create.pickEmpty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div> </div>
{neTotal > 0 ? (
<div className="collect-pick-block"> <div className="ops-detail-modal__foot" style={{ marginTop: 10 }}>
<div className="panel__toolbar"> <span className="muted">
<div> {t("common.pagerMeta", {
<h3>{t("collect.create.pickTitle")}</h3> total: String(neTotal),
<p className="panel__hint">{t("collect.create.pickHint")}</p> page: String(nePage),
</div> pages: String(nePages),
<div className="table-actions"> })}
<button </span>
type="button" <div className="btn-row">
className="link-btn" <Button
disabled={batchPickCount === 0} size="sm"
onClick={() => { variant="ghost"
const rows = eligibleItems.filter( isDisabled={nePage <= 1}
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)], onPress={() => {
); setNePage(nePage - 1);
addBatchNe(rows);
setPickSelectedIds([]); setPickSelectedIds([]);
}} }}
> >
{t("collect.create.addBatch", { count: batchPickCount })} {t("common.prevPage")}
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => eligibleQuery.refetch()} variant="ghost"
disabled={eligibleQuery.isFetching} isDisabled={nePage >= nePages}
onPress={() => {
setNePage(nePage + 1);
setPickSelectedIds([]);
}}
> >
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")} {t("common.nextPage")}
</button> </Button>
</div> </div>
</div> </div>
<div className="ops-detail-modal__toolbar filter-inline"> ) : null}
<input
type="search"
value={neKeyword}
placeholder={t("collect.create.filterKeywordPh")}
onChange={(e) => {
setNeKeyword(e.target.value);
setNePage(1);
setPickSelectedIds([]);
}}
/>
</div>
{eligibleQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>
<input
type="checkbox"
checked={allPickSelected}
onChange={(e) => {
if (e.target.checked) {
setPickSelectedIds(selectablePickItems.map((row) => eligibleKey(row)));
return;
}
setPickSelectedIds([]);
}}
aria-label="pick all"
/>
</th>
<th>{t("managedNe.col.source")}</th>
<th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.vendor")}</th>
<th>{t("managedNe.col.ip")}</th>
<th>{t("managedNe.col.connect")}</th>
<th />
</tr>
</thead>
<tbody>
{eligibleItems.map((row) => {
const key = eligibleKey(row);
const picked = Boolean(selectedMap[key]);
const src = String(row.source || "managed").toLowerCase();
return (
<tr key={key}>
<td>
<input
type="checkbox"
checked={pickSelectedIds.includes(key)}
disabled={picked}
onChange={(e) => {
if (picked) return;
setPickSelectedIds((prev) =>
e.target.checked
? [...new Set([...prev, key])]
: prev.filter((id) => id !== key),
);
}}
/>
</td>
<td>
<span className="table-tag">{src}</span>
</td>
<td>{row.name || row.ip_address}</td>
<td>{row.vendor}</td>
<td>{row.ip_address}</td>
<td>
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
</td>
<td className="table-actions">
<button
type="button"
className="link-btn"
disabled={picked}
onClick={() => {
addNe(row);
setPickSelectedIds((prev) => prev.filter((id) => id !== key));
}}
>
{picked ? t("collect.create.added") : t("collect.create.add")}
</button>
</td>
</tr>
);
})}
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
<tr>
<td colSpan={7} className="muted">
{t("collect.create.pickEmpty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{neTotal > 0 ? (
<div className="ops-detail-modal__foot" style={{ marginTop: 10 }}>
<span className="muted">
{t("common.pagerMeta", {
total: String(neTotal),
page: String(nePage),
pages: String(nePages),
})}
</span>
<div className="btn-row">
<button
type="button"
disabled={nePage <= 1}
onClick={() => {
setNePage(nePage - 1);
setPickSelectedIds([]);
}}
>
{t("common.prevPage")}
</button>
<button
type="button"
disabled={nePage >= nePages}
onClick={() => {
setNePage(nePage + 1);
setPickSelectedIds([]);
}}
>
{t("common.nextPage")}
</button>
</div>
</div>
) : null}
</div>
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
</span>
<div className="btn-row">
<button type="button" onClick={() => setCreateOpen(false)}>
{t("networkConfigs.close")}
</button>
<button
type="button"
className="btn-primary"
disabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
</button>
</div>
</div> </div>
</div> </div>
</div> </Modal.Body>
) : null} <Modal.Footer>
<span className="muted">
{expandedJobId ? ( {t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
<div </span>
className="modal-backdrop" <Button
role="presentation" variant="tertiary"
onClick={() => setExpandedJobId("")} isDisabled={createMutation.isPending}
> onPress={() => setCreateOpen(false)}
<div
className="modal modal--wide ops-detail-modal ops-detail-modal--xl"
role="dialog"
aria-modal="true"
aria-label={t("collect.jobs.detailTitle")}
onClick={(e) => e.stopPropagation()}
> >
<div className="ops-detail-modal__head"> {t("networkConfigs.close")}
<div className="ops-detail-modal__title"> </Button>
<h3>{t("collect.jobs.detailTitle")}</h3> <Button
<p className="muted"> variant="primary"
{(() => { isDisabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job; onPress={() => createMutation.mutate()}
return job >
? `${job.title} · ${job.status} · ${job.success_count}/${job.ne_count}` {createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
: expandedJobId.slice(0, 8); </Button>
})()} </Modal.Footer>
</p> </AppModalShell>
</div>
<div className="btn-row ops-detail-modal__actions"> <AppModalShell
<button type="button" onClick={() => setExpandedJobId("")}> open={Boolean(expandedJobId)}
{t("networkConfigs.close")} onClose={() => setExpandedJobId("")}
</button> size="lg"
</div> className="ops-detail-modal"
</div> >
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} <Modal.Header>
<JobRunsPanel <Modal.Heading>{t("collect.jobs.detailTitle")}</Modal.Heading>
jobId={expandedJobId} <Modal.CloseTrigger />
jobStatus={ </Modal.Header>
jobs.find((j) => j.id === expandedJobId)?.status || <Modal.Body className="flex flex-col gap-3">
detailQuery.data?.job.status || <p className="muted">
"" {(() => {
} const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job;
failCount={ return job
jobs.find((j) => j.id === expandedJobId)?.fail_count ?? ? `${job.title} · ${job.status} · ${job.success_count}/${job.ne_count}`
detailQuery.data?.job.fail_count ?? : expandedJobId.slice(0, 8);
0 })()}
} </p>
commands={detailQuery.data?.job.commands ?? ""} {detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)} <JobRunsPanel
retryPending={actionPending} jobId={expandedJobId}
/> jobStatus={
</div> jobs.find((j) => j.id === expandedJobId)?.status ||
</div> detailQuery.data?.job.status ||
) : null} ""
}
failCount={
jobs.find((j) => j.id === expandedJobId)?.fail_count ??
detailQuery.data?.job.fail_count ??
0
}
commands={detailQuery.data?.job.commands ?? ""}
onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)}
retryPending={actionPending}
/>
</Modal.Body>
<Modal.Footer>
<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
size="sm"
variant="ghost"
className="link-btn"
isDisabled={actionPending}
onPress={onRetryFailed}
>
{t("collect.jobs.retryFailed")} {t("collect.jobs.retryFailed")}
</button> </Button>
) : null} ) : null}
<button <Button
type="button" size="sm"
variant="ghost"
className="link-btn" className="link-btn"
disabled={actionPending || !hasResults} isDisabled={actionPending || !hasResults}
onClick={downloadResults} 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,130 +748,117 @@ export function ConfigSyncPage() {
/> />
</div> </div>
{expandedCycleId ? ( <AppModalShell
<div open={Boolean(expandedCycleId)}
className="modal-backdrop" onClose={() => setExpandedCycleId("")}
role="presentation" size="lg"
onClick={() => setExpandedCycleId("")} >
> <Modal.Header>
<div <Modal.Heading>{t("configSync.tasksTitle")}</Modal.Heading>
className="modal modal--wide ops-detail-modal" <Modal.CloseTrigger />
role="dialog" </Modal.Header>
aria-modal="true" <Modal.Body className="flex flex-col gap-3">
aria-label={t("configSync.tasksTitle")} <p className="text-sm text-muted">
onClick={(e) => e.stopPropagation()} {expandedCycleId.slice(0, 8)}
> {(() => {
<div className="ops-detail-modal__head"> const c = cycles.find((x) => x.id === expandedCycleId);
<div className="ops-detail-modal__title"> return c
<h3>{t("configSync.tasksTitle")}</h3> ? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}`
<p className="muted"> : "";
{expandedCycleId.slice(0, 8)} })()}
{(() => { </p>
const c = cycles.find((x) => x.id === expandedCycleId); <div className="filter-inline">
return c <Input
? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}` value={taskKeyword}
: ""; placeholder={t("configSync.taskKeywordPh")}
})()} onChange={(e) => {
</p> setTaskKeyword(e.target.value);
</div> setTaskPage(1);
<div className="btn-row ops-detail-modal__actions"> }}
<button />
type="button" <FieldSelect
disabled={exportingTasks || taskTotal === 0} value={taskStatus}
onClick={() => void exportTasksCsv()} onChange={(e) => {
> setTaskStatus(e.target.value);
{exportingTasks ? t("common.exporting") : t("common.exportCsv")} setTaskPage(1);
</button> }}
<button type="button" onClick={() => setExpandedCycleId("")}> >
{t("networkConfigs.close")} <option value="">{t("configSync.statusAll")}</option>
</button> <option value="pending">pending</option>
</div> <option value="running">running</option>
</div> <option value="success">success</option>
<option value="fail">fail</option>
<div className="ops-detail-modal__toolbar filter-inline"> <option value="skipped">skipped</option>
<input </FieldSelect>
value={taskKeyword} <Button
placeholder={t("configSync.taskKeywordPh")} size="sm"
onChange={(e) => { variant="secondary"
setTaskKeyword(e.target.value); isDisabled={exportingTasks || taskTotal === 0}
setTaskPage(1); onPress={() => void exportTasksCsv()}
}} >
/> {exportingTasks ? t("common.exporting") : t("common.exportCsv")}
<select </Button>
value={taskStatus}
onChange={(e) => {
setTaskStatus(e.target.value);
setTaskPage(1);
}}
>
<option value="">{t("configSync.statusAll")}</option>
<option value="pending">pending</option>
<option value="running">running</option>
<option value="success">success</option>
<option value="fail">fail</option>
<option value="skipped">skipped</option>
</select>
</div>
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{tasksQuery.isError ? (
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
) : null}
<div className="ops-detail-modal__scroll">
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("configSync.col.name")}</th>
<th>IP</th>
<th>{t("configSync.col.vendor")}</th>
<th>{t("configSync.col.source")}</th>
<th>{t("configSync.col.status")}</th>
<th>{t("configSync.col.message")}</th>
</tr>
</thead>
<tbody>
{(tasksQuery.data?.items ?? []).map((task) => (
<tr key={task.id}>
<td>{task.ne_name || task.target_id}</td>
<td>{task.ne_ip}</td>
<td>{task.vendor || "-"}</td>
<td>{task.source}</td>
<td>{task.status}</td>
<td title={task.message}>{task.message || "-"}</td>
</tr>
))}
{!tasksQuery.isLoading && !(tasksQuery.data?.items ?? []).length ? (
<tr>
<td colSpan={6} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
<div className="ops-detail-modal__foot">
<ListPager
page={taskPage}
pages={taskPages}
total={taskTotal}
pageSize={taskPageSize}
pageSizeOptions={[20, 50, 100, 200]}
onPageChange={setTaskPage}
onPageSizeChange={(size) => {
setTaskPageSize(size);
setTaskPage(1);
}}
disabled={tasksQuery.isLoading}
/>
</div>
</div> </div>
</div>
) : null} {tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{tasksQuery.isError ? (
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
) : null}
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("configSync.col.name")}</th>
<th>IP</th>
<th>{t("configSync.col.vendor")}</th>
<th>{t("configSync.col.source")}</th>
<th>{t("configSync.col.status")}</th>
<th>{t("configSync.col.message")}</th>
</tr>
</thead>
<tbody>
{(tasksQuery.data?.items ?? []).map((task) => (
<tr key={task.id}>
<td>{task.ne_name || task.target_id}</td>
<td>{task.ne_ip}</td>
<td>{task.vendor || "-"}</td>
<td>{task.source}</td>
<td>{task.status}</td>
<td title={task.message}>{task.message || "-"}</td>
</tr>
))}
{!tasksQuery.isLoading && !(tasksQuery.data?.items ?? []).length ? (
<tr>
<td colSpan={6} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<ListPager
page={taskPage}
pages={taskPages}
total={taskTotal}
pageSize={taskPageSize}
pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setTaskPage}
onPageSizeChange={(size) => {
setTaskPageSize(size);
setTaskPage(1);
}}
disabled={tasksQuery.isLoading}
/>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={() => setExpandedCycleId("")}>
{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>
<p className="login-card__hint"> <Card.Content>
{t("auth.forceChangeHint", { user: user?.username || "admin" })} <p className="login-card__hint">
</p> {t("auth.forceChangeHint", { user: user?.username || "admin" })}
<label className="login-card__label"> </p>
<span className="login-card__sr">{t("auth.oldPassword")}</span> <Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
<input <TextField
type="password" fullWidth
autoComplete="current-password" type="password"
autoFocus autoComplete="current-password"
placeholder={t("auth.oldPassword")} autoFocus
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
type="password" fullWidth
autoComplete="new-password" type="password"
placeholder={t("auth.newPassword")} autoComplete="new-password"
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
type="password" fullWidth
autoComplete="new-password" type="password"
placeholder={t("auth.confirmPassword")} autoComplete="new-password"
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>
{error ? ( <Input placeholder={t("auth.confirmPassword")} />
<div className="login-card__error" role="alert"> </TextField>
{error} {error ? (
</div> <Alert status="danger" className="login-card__error">
) : null} <Alert.Content>
<button type="submit" className="login-card__submit" disabled={busy}> <Alert.Description>{error}</Alert.Description>
{busy ? t("auth.savingPassword") : t("auth.savePassword")} </Alert.Content>
</button> </Alert>
<button ) : null}
type="button" <Button type="submit" variant="primary" fullWidth isDisabled={busy}>
className="login-card__submit login-card__submit--ghost" {busy ? t("auth.savingPassword") : t("auth.savePassword")}
disabled={busy} </Button>
onClick={() => void logout()} <Button
> type="button"
{t("auth.logout")} variant="tertiary"
</button> fullWidth
</form> isDisabled={busy}
onPress={() => void logout()}
>
{t("auth.logout")}
</Button>
</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,75 +35,96 @@ 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
autoFocus fullWidth
autoComplete="username" name="username"
placeholder={t("auth.username")} value={username}
value={username} onChange={setUsername}
onChange={(e) => setUsername(e.target.value)} isDisabled={busy || !ready}
disabled={busy || !ready} autoFocus
/> autoComplete="username"
</label>
<label className="login-card__label">
<span className="login-card__sr">{t("auth.password")}</span>
<span className="login-card__password">
<input
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder={t("auth.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy || !ready}
/>
<button
type="button"
className="login-card__eye"
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
onClick={() => setShowPassword((v) => !v)}
> >
{showPassword ? ( <Label className="login-card__sr">{t("auth.username")}</Label>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"> <Input placeholder={t("auth.username")} />
<path </TextField>
d="M3 3l18 18M10.6 10.6a2.5 2.5 0 0 0 3.5 3.5M9.9 5.1A10 10 0 0 1 12 4.8c5.5 0 9.2 5.3 10.2 6.7a1.2 1.2 0 0 1 0 1.4c-.4.6-1.3 1.8-2.7 3M6.1 6.1C4.2 7.5 2.9 9.3 2 11.1a1.2 1.2 0 0 0 0 1.4C3 13.9 6.7 19.2 12 19.2c1.4 0 2.7-.3 3.9-.8"
stroke="currentColor" <TextField
strokeWidth="1.75" fullWidth
strokeLinecap="round" name="password"
strokeLinejoin="round" value={password}
/> onChange={setPassword}
</svg> isDisabled={busy || !ready}
) : ( autoComplete="current-password"
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"> >
<path <Label className="login-card__sr">{t("auth.password")}</Label>
d="M2 12.2C3 10.5 6.7 4.8 12 4.8s9 5.7 10 7.4a1.2 1.2 0 0 1 0 1.2C21 15.1 17.3 20.8 12 20.8S3 15.1 2 13.4a1.2 1.2 0 0 1 0-1.2Z" <InputGroup fullWidth>
stroke="currentColor" <InputGroup.Input
strokeWidth="1.75" type={showPassword ? "text" : "password"}
strokeLinejoin="round" placeholder={t("auth.password")}
/> />
<circle cx="12" cy="12.8" r="3" stroke="currentColor" strokeWidth="1.75" /> <InputGroup.Suffix>
</svg> <Button
)} isIconOnly
</button> size="sm"
</span> variant="ghost"
</label> aria-label={showPassword ? "Hide password" : "Show password"}
{error ? ( onPress={() => setShowPassword((v) => !v)}
<div className="login-card__error" role="alert"> >
{error} {showPassword ? (
</div> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
) : null} <path
<button type="submit" className="login-card__submit" disabled={busy || !ready || !password}> d="M3 3l18 18M10.6 10.6a2.5 2.5 0 0 0 3.5 3.5M9.9 5.1A10 10 0 0 1 12 4.8c5.5 0 9.2 5.3 10.2 6.7a1.2 1.2 0 0 1 0 1.4c-.4.6-1.3 1.8-2.7 3M6.1 6.1C4.2 7.5 2.9 9.3 2 11.1a1.2 1.2 0 0 0 0 1.4C3 13.9 6.7 19.2 12 19.2c1.4 0 2.7-.3 3.9-.8"
{busy ? t("auth.loggingIn") : t("auth.login")} stroke="currentColor"
</button> strokeWidth="1.75"
</form> strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M2 12.2C3 10.5 6.7 4.8 12 4.8s9 5.7 10 7.4a1.2 1.2 0 0 1 0 1.2C21 15.1 17.3 20.8 12 20.8S3 15.1 2 13.4a1.2 1.2 0 0 1 0-1.2Z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<circle cx="12" cy="12.8" r="3" stroke="currentColor" strokeWidth="1.75" />
</svg>
)}
</Button>
</InputGroup.Suffix>
</InputGroup>
</TextField>
{error ? (
<Alert status="danger" className="login-card__error">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
type="submit"
variant="primary"
fullWidth
className="login-card__submit"
isDisabled={busy || !ready || !password}
>
{busy ? t("auth.loggingIn") : t("auth.login")}
</Button>
</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,185 +864,213 @@ 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"
<HopProxyFields value={batchHop} onChange={(patch) => setBatchHop((prev) => ({ ...prev, ...patch }))} /> >
<div className="modal__actions"> <Modal.Header>
<button type="button" onClick={() => setBatchHopOpen(false)}> <Modal.Heading>{t("managedNe.hop.batchTitle")}</Modal.Heading>
{t("managedNe.form.cancel")} <Modal.CloseTrigger isDisabled={batchHopMutation.isPending} />
</button> </Modal.Header>
<button <Modal.Body className="flex flex-col gap-3">
type="button" <p className="text-sm text-muted">{t("managedNe.hop.batchHint", { n: selected.length })}</p>
disabled={batchHopMutation.isPending} <HopProxyFields value={batchHop} onChange={(patch) => setBatchHop((prev) => ({ ...prev, ...patch }))} />
onClick={() => { </Modal.Body>
if (!batchHop.hop_host.trim()) { <Modal.Footer>
showError(t("managedNe.hop.hostRequired")); <Button variant="tertiary" onPress={() => setBatchHopOpen(false)}>
return; {t("managedNe.form.cancel")}
} </Button>
if (!batchHop.hop_username.trim()) { <Button
showError(t("managedNe.hop.userRequired")); variant="primary"
return; isDisabled={batchHopMutation.isPending}
} onPress={() => {
if (!batchHop.hop_password && batchHop.hop_target_auth_mode !== "bastion_managed") { if (!batchHop.hop_host.trim()) {
showError(t("managedNe.hop.passwordRequired")); showError(t("managedNe.hop.hostRequired"));
return; return;
} }
batchHopMutation.mutate(); if (!batchHop.hop_username.trim()) {
}} showError(t("managedNe.hop.userRequired"));
> return;
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.apply")} }
</button> if (!batchHop.hop_password && batchHop.hop_target_auth_mode !== "bastion_managed") {
</div> showError(t("managedNe.hop.passwordRequired"));
</div> return;
</div> }
) : null} batchHopMutation.mutate();
}}
>
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.apply")}
</Button>
</Modal.Footer>
</AppModalShell>
{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"
<div className="form-grid"> >
<label> <Modal.Header>
<FormLabel>{t("managedNe.col.user")}</FormLabel> <Modal.Heading>{t("managedNe.account.batchTitle")}</Modal.Heading>
<input <Modal.CloseTrigger isDisabled={batchAccountMutation.isPending} />
value={batchAccount.username} </Modal.Header>
onChange={(e) => setBatchAccount((prev) => ({ ...prev, username: e.target.value }))} <Modal.Body className="flex flex-col gap-3">
/> <p className="text-sm text-muted">{t("managedNe.account.batchHint", { n: selected.length })}</p>
</label> <div className="form-grid">
<label> <TextField
<FormLabel> fullWidth
{t("managedNe.col.password")} value={batchAccount.username}
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span> onChange={(username) => setBatchAccount((prev) => ({ ...prev, username }))}
</FormLabel> >
<input <Label>{t("managedNe.col.user")}</Label>
<Input />
</TextField>
<TextField
fullWidth
type="password"
value={batchAccount.password}
onChange={(password) => setBatchAccount((prev) => ({ ...prev, password }))}
>
<Label>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
</Label>
<Input />
</TextField>
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={() => setBatchAccountOpen(false)}>
{t("managedNe.form.cancel")}
</Button>
<Button
variant="primary"
isDisabled={batchAccountMutation.isPending}
onPress={() => {
if (!batchAccount.username.trim() && !batchAccount.password) {
showError(t("managedNe.account.usernameOrPasswordRequired"));
return;
}
batchAccountMutation.mutate();
}}
>
{batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.apply")}
</Button>
</Modal.Footer>
</AppModalShell>
<AppModalShell
open={bulkTagModalOpen}
onClose={() => setBulkTagModalOpen(false)}
dismissible={!bulkByTagMutation.isPending}
size="lg"
>
<Modal.Header>
<Modal.Heading>
{bulkTagAction === "proxy"
? t("managedNe.stats.batchProxy")
: bulkTagAction === "account"
? t("managedNe.account.batchByTag")
: t("managedNe.stats.batchTest")}
{bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""}
{bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""}
{bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}
</Modal.Heading>
<Modal.CloseTrigger isDisabled={bulkByTagMutation.isPending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<FieldSelect
label={t("managedNe.stats.tagFilter")}
fullWidth
value={bulkTagSelected}
onChange={(e) => setBulkTagSelected(e.target.value)}
>
<option value="">{t("managedNe.stats.allTag")}</option>
<option value="__no_tag__">{t("managedNe.stats.noTag")}</option>
{statTags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</FieldSelect>
{bulkTagAction === "proxy" ? (
<>
<p className="text-sm text-muted">{t("managedNe.hop.batchHint", { n: "?" })}</p>
<HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} />
</>
) : bulkTagAction === "account" ? (
<>
<p className="text-sm text-muted">{t("managedNe.account.batchByTagHint")}</p>
<div className="form-grid">
<TextField
fullWidth
value={bulkAccount.username}
onChange={(username) => setBulkAccount((prev) => ({ ...prev, username }))}
>
<Label>{t("managedNe.col.user")}</Label>
<Input />
</TextField>
<TextField
fullWidth
type="password" type="password"
value={batchAccount.password} value={bulkAccount.password}
onChange={(e) => setBatchAccount((prev) => ({ ...prev, password: e.target.value }))} onChange={(password) => setBulkAccount((prev) => ({ ...prev, password }))}
/> >
</label> <Label>
</div> {t("managedNe.col.password")}
<div className="modal__actions"> <span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
<button type="button" onClick={() => setBatchAccountOpen(false)}> </Label>
{t("managedNe.form.cancel")} <Input />
</button> </TextField>
<button </div>
type="button" </>
disabled={batchAccountMutation.isPending} ) : (
onClick={() => { <p className="text-sm text-muted">{t("managedNe.stats.confirm", { n: "?" })}</p>
if (!batchAccount.username.trim() && !batchAccount.password) { )}
showError(t("managedNe.account.usernameOrPasswordRequired")); </Modal.Body>
return; <Modal.Footer>
} <Button variant="tertiary" onPress={() => setBulkTagModalOpen(false)}>
batchAccountMutation.mutate(); {t("managedNe.form.cancel")}
}} </Button>
> <Button
{batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.apply")} variant="primary"
</button> isDisabled={bulkByTagMutation.isPending}
</div> onPress={() => {
</div> if (bulkTagAction === "proxy") {
</div> if (!bulkHop.hop_host.trim()) {
) : null} showError(t("managedNe.hop.hostRequired"));
return;
{bulkTagModalOpen ? ( }
<div className="modal-backdrop" role="presentation" onClick={() => setBulkTagModalOpen(false)}> if (!bulkHop.hop_username.trim()) {
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}> showError(t("managedNe.hop.userRequired"));
<h3> return;
{bulkTagAction === "proxy" }
? t("managedNe.stats.batchProxy") if (!bulkHop.hop_password && bulkHop.hop_target_auth_mode !== "bastion_managed") {
showError(t("managedNe.hop.passwordRequired"));
return;
}
}
if (bulkTagAction === "account") {
if (!bulkAccount.username.trim() && !bulkAccount.password) {
showError(t("managedNe.account.usernameOrPasswordRequired"));
return;
}
}
bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected });
}}
>
{bulkByTagMutation.isPending
? t("managedNe.stats.loadingIds")
: bulkTagAction === "proxy"
? t("managedNe.hop.apply")
: bulkTagAction === "account" : bulkTagAction === "account"
? t("managedNe.account.batchByTag") ? t("managedNe.account.apply")
: t("managedNe.stats.batchTest")} : t("managedNe.connect.run")}
{bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""} </Button>
{bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""} </Modal.Footer>
{bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""} </AppModalShell>
</h3>
<label>
<FormLabel>{t("managedNe.stats.tagFilter")}</FormLabel>
<select
className="ne-stats-card__tag-select"
value={bulkTagSelected}
onChange={(e) => setBulkTagSelected(e.target.value)}
>
<option value="">{t("managedNe.stats.allTag")}</option>
<option value="__no_tag__">{t("managedNe.stats.noTag")}</option>
{statTags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
</label>
{bulkTagAction === "proxy" ? (
<>
<p className="form-hint">{t("managedNe.hop.batchHint", { n: "?" })}</p>
<HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} />
</>
) : bulkTagAction === "account" ? (
<>
<p className="form-hint">{t("managedNe.account.batchByTagHint")}</p>
<div className="form-grid">
<label>
<FormLabel>{t("managedNe.col.user")}</FormLabel>
<input
value={bulkAccount.username}
onChange={(e) => setBulkAccount((prev) => ({ ...prev, username: e.target.value }))}
/>
</label>
<label>
<FormLabel>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
</FormLabel>
<input
type="password"
value={bulkAccount.password}
onChange={(e) => setBulkAccount((prev) => ({ ...prev, password: e.target.value }))}
/>
</label>
</div>
</>
) : (
<p className="form-hint">{t("managedNe.stats.confirm", { n: "?" })}</p>
)}
<div className="modal__actions">
<button type="button" onClick={() => setBulkTagModalOpen(false)}>
{t("managedNe.form.cancel")}
</button>
<button
type="button"
disabled={bulkByTagMutation.isPending}
onClick={() => {
if (bulkTagAction === "proxy") {
if (!bulkHop.hop_host.trim()) { showError(t("managedNe.hop.hostRequired")); return; }
if (!bulkHop.hop_username.trim()) { showError(t("managedNe.hop.userRequired")); return; }
if (!bulkHop.hop_password && bulkHop.hop_target_auth_mode !== "bastion_managed") {
showError(t("managedNe.hop.passwordRequired")); return;
}
}
if (bulkTagAction === "account") {
if (!bulkAccount.username.trim() && !bulkAccount.password) {
showError(t("managedNe.account.usernameOrPasswordRequired")); return;
}
}
bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected });
}}
>
{bulkByTagMutation.isPending
? t("managedNe.stats.loadingIds")
: bulkTagAction === "proxy"
? t("managedNe.hop.apply")
: bulkTagAction === "account"
? t("managedNe.account.apply")
: t("managedNe.connect.run")}
</button>
</div>
</div>
</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>

File diff suppressed because it is too large Load diff

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>

File diff suppressed because it is too large Load diff

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 />
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol} </Modal.Header>
{row.connect_tested_at <Modal.Body className="flex flex-col gap-3">
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}` {row ? (
: ""} <>
</p> <p className="text-sm text-muted">
<p> {row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
<span className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`}> {row.connect_tested_at
{row.connect_status} ? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
</span> : ""}
{row.connect_message ? ( </p>
<span className="connect-detail-summary"> — {row.connect_message}</span> <div className="flex flex-wrap items-center gap-2">
) : null} <Chip size="sm" color={chipColor(level)} variant="soft">
</p> <Chip.Label>{row.connect_status}</Chip.Label>
<pre className="connect-log"> </Chip>
{row.connect_detail?.trim() || row.connect_message?.trim() || t("managedNe.connectDetailEmpty")} {row.connect_message ? (
</pre> <span className="connect-detail-summary text-sm">— {row.connect_message}</span>
<div className="modal__actions"> ) : null}
<button </div>
type="button" <pre className="connect-log">
disabled={connectMutation.isPending} {row.connect_detail?.trim() ||
onClick={() => connectMutation.mutate([row.id])} row.connect_message?.trim() ||
> t("managedNe.connectDetailEmpty")}
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")} </pre>
</button> </>
<button type="button" onClick={onClose}>
{t("managedNe.form.cancel")}
</button>
</div>
{connectMutation.isError ? (
<p className="form-hint" role="alert">
{String(connectMutation.error)}
</p>
) : null} ) : null}
</div> {connectMutation.isError ? (
</div> <Alert status="danger">
<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")}
</Button>
<Button variant="tertiary" onPress={onClose}>
{t("managedNe.form.cancel")}
</Button>
</Modal.Footer>
</AppModalShell>
); );
} }

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,142 +81,148 @@ 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) => { const vendor = e.target.value;
const vendor = e.target.value; setForm((prev) => {
setForm((prev) => { const next = { ...prev, vendor };
const next = { ...prev, vendor }; const dt = String(prev.device_type || "").trim().toLowerCase();
const dt = String(prev.device_type || "").trim().toLowerCase(); if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
if (!dt || dt === "generic" || dt === "other" || dt === "linux") { if (vendor === "ZTE") next.device_type = "zte_zxros";
if (vendor === "ZTE") next.device_type = "zte_zxros"; else if (vendor === "Huawei") next.device_type = "huawei";
else if (vendor === "Huawei") next.device_type = "huawei"; else if (vendor === "Cisco") next.device_type = "cisco_ios";
else if (vendor === "Cisco") next.device_type = "cisco_ios"; else if (vendor === "Juniper") next.device_type = "juniper_junos";
else if (vendor === "Juniper") next.device_type = "juniper_junos"; else if (vendor === "Nokia") next.device_type = "nokia_sros";
else if (vendor === "Nokia") next.device_type = "nokia_sros"; }
} return next;
return next; });
}); }}
}} >
> {vendors.map((v) => (
{vendors.map((v) => ( <option key={v} value={v}>
<option key={v} value={v}> {v}
{v} </option>
</option> ))}
))} </FieldSelect>
</select> <FieldSelect
</label> label={t("managedNe.col.deviceType")}
<label> required
<FormLabel required>{t("managedNe.col.deviceType")}</FormLabel> value={form.device_type}
<select onChange={(e) => setForm({ ...form, device_type: e.target.value })}
required >
value={form.device_type} {deviceTypes.map((dt) => (
onChange={(e) => setForm({ ...form, device_type: e.target.value })} <option key={dt} value={dt}>
> {dt}
{deviceTypes.map((dt) => ( </option>
<option key={dt} value={dt}> ))}
{dt} </FieldSelect>
</option> <TextField
))} fullWidth
</select> isRequired
</label> value={form.ip_address}
<label> onChange={(ip_address) => setForm({ ...form, ip_address })}
<FormLabel required>{t("managedNe.col.ip")}</FormLabel> >
<input <Label>{t("managedNe.col.ip")}</Label>
required <Input />
value={form.ip_address} </TextField>
onChange={(e) => setForm({ ...form, ip_address: e.target.value })} <TextField
/> fullWidth
</label> type="number"
<label> value={String(form.port)}
<FormLabel>{t("managedNe.col.port")}</FormLabel> onChange={(port) => setForm({ ...form, port: Number(port) || 22 })}
<input >
type="number" <Label>{t("managedNe.col.port")}</Label>
value={form.port} <Input />
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 22 })} </TextField>
/> <FieldSelect
</label> label={t("managedNe.col.protocol")}
<label> value={form.protocol}
<FormLabel>{t("managedNe.col.protocol")}</FormLabel> onChange={(e) => setForm({ ...form, protocol: e.target.value })}
<select 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 value={form.username}
required onChange={(username) => setForm({ ...form, username })}
value={form.username} >
onChange={(e) => setForm({ ...form, username: e.target.value })} <Label>{t("managedNe.col.user")}</Label>
/> <Input />
</label> </TextField>
<label> <TextField
<FormLabel> fullWidth
type="password"
value={form.password}
onChange={(password) => setForm({ ...form, password })}
>
<Label>
{t("managedNe.col.password")} {t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span> <span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
</FormLabel> </Label>
<input <Input />
type="password" </TextField>
value={form.password} <TextField fullWidth value={form.tags} onChange={(tags) => setForm({ ...form, tags })}>
onChange={(e) => setForm({ ...form, password: e.target.value })} <Label>{t("managedNe.col.tags")}</Label>
/> <Input />
</label> </TextField>
<label> <TextField
<FormLabel>{t("managedNe.col.tags")}</FormLabel> fullWidth
<input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} /> className="form-grid__full"
</label> value={form.remark}
<label className="form-grid__full"> onChange={(remark) => setForm({ ...form, remark })}
<FormLabel>{t("managedNe.col.remark")}</FormLabel> >
<input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} /> <Label>{t("managedNe.col.remark")}</Label>
</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} setForm((prev) => ({
onChange={(e) => { ...prev,
const hop_enabled = e.target.checked; hop_enabled,
setForm((prev) => ({ ...(hop_enabled
...prev, ? {
hop_enabled, ...patchHopVendorChange(prev.hop_vendor, prev),
...(hop_enabled ...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true),
? { }
...patchHopVendorChange(prev.hop_vendor, prev), : {}),
...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true), }));
} }}
: {}), >
})); <Checkbox.Control>
}} <Checkbox.Indicator />
/> </Checkbox.Control>
<span className="form-check__text">{t("managedNe.hop.enable")}</span> <Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
</label> </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">
<button type="button" disabled={saveMutation.isPending} onClick={onClose}>
{t("managedNe.form.cancel")}
</button>
<button
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button>
</div>
{saveMutation.isError ? ( {saveMutation.isError ? (
<p className="form-hint" role="alert"> <Alert status="danger">
{String(saveMutation.error)} <Alert.Content>
</p> <Alert.Description>{String(saveMutation.error)}</Alert.Description>
</Alert.Content>
</Alert>
) : null} ) : null}
</div> </Modal.Body>
</div> <Modal.Footer>
<Button variant="tertiary" isDisabled={saveMutation.isPending} onPress={onClose}>
{t("managedNe.form.cancel")}
</Button>
<Button
variant="primary"
isDisabled={saveMutation.isPending}
onPress={() => saveMutation.mutate()}
>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</Button>
</Modal.Footer>
</AppModalShell>
); );
} }

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,265 +1092,238 @@ export function LldpLinksPage() {
disabled={jobsQuery.isLoading} disabled={jobsQuery.isLoading}
/> />
{expandedJobId ? ( <AppModalShell
<div open={Boolean(expandedJobId)}
className="modal-backdrop" onClose={() => {
role="presentation" setExpandedJobId("");
onClick={() => { setItemDetail(null);
setExpandedJobId(""); setItemStatus("");
setItemDetail(null); setItemKeyword("");
}} setItemPage(1);
> }}
<div size="lg"
className="modal modal--wide ops-detail-modal" >
role="dialog" <Modal.Header>
aria-modal="true" <Modal.Heading>{t("lldpLinks.jobDetailTitle")}</Modal.Heading>
aria-label={t("lldpLinks.jobDetailTitle")} <Modal.CloseTrigger />
onClick={(e) => e.stopPropagation()} </Modal.Header>
> <Modal.Body className="flex flex-col gap-3">
<div className="ops-detail-modal__head"> <p className="muted">
<div className="ops-detail-modal__title"> {(() => {
<h3>{t("lldpLinks.jobDetailTitle")}</h3> const job = jobDetailQuery.data;
<p className="muted"> if (job) {
{(() => { return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`;
const job = jobDetailQuery.data; }
if (job) { if (jobDetailQuery.isLoading) {
return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`; return `${expandedJobId.slice(0, 8)} · …`;
} }
if (jobDetailQuery.isLoading) { return expandedJobId.slice(0, 8);
return `${expandedJobId.slice(0, 8)} · …`; })()}
} </p>
return expandedJobId.slice(0, 8);
})()}
</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);
setItemPage(1); setItemPage(1);
}} }}
> >
<option value="">{t("lldpLinks.itemResultAll")}</option> <option value="">{t("lldpLinks.itemResultAll")}</option>
<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) => {
setItemKeyword(e.target.value); setItemKeyword(e.target.value);
setItemPage(1); setItemPage(1);
}} }}
/> />
</div>
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobDetailQuery.data?.error ? (
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
) : null}
<div className="ops-detail-modal__scroll">
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("lldpLinks.col.name")}</th>
<th>IP</th>
<th>{t("lldpLinks.col.status")}</th>
<th>{t("lldpLinks.col.neighbors")}</th>
<th>{t("lldpLinks.col.edges")}</th>
<th>{t("lldpLinks.col.error")}</th>
<th />
</tr>
</thead>
<tbody>
{detailItems.map((it) => {
const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
return (
<tr key={it.id}>
<td>{it.ne_name || it.ne_id || "—"}</td>
<td>{it.ne_ip || "—"}</td>
<td>
{lldpItemResultLabel(t, it)}
</td>
<td>{it.neighbors}</td>
<td>
+{it.edges_added} / ~{it.edges_updated}
{unmatchedCount > 0
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
: ""}
</td>
<td className="muted">{it.error || "—"}</td>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setItemDetail(it)}
>
{t("topology.discoverViewDetail")}
</button>
</td>
</tr>
);
})}
{!detailItems.length && !jobDetailQuery.isLoading ? (
<tr>
<td colSpan={7} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
<div className="ops-detail-modal__foot">
<ListPager
page={itemPage}
pages={itemPages}
total={itemTotal}
pageSize={itemPageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setItemPage}
onPageSizeChange={(size) => {
setItemPageSize(size);
setItemPage(1);
}}
disabled={jobDetailQuery.isLoading}
/>
</div>
</div> </div>
</div>
) : null}
{itemDetail ? ( {jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<div className="modal-backdrop" role="presentation" onClick={() => setItemDetail(null)}> {jobDetailQuery.data?.error ? (
<div <p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
className="modal modal--wide topo-discover-modal" ) : null}
role="dialog"
aria-modal="true"
aria-labelledby="lldp-item-detail-title"
onClick={(e) => e.stopPropagation()}
>
<div className="topo-discover-modal__head">
<div>
<h3 id="lldp-item-detail-title">
{itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id}
</h3>
<p className="topo-discover-modal__sub">
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
</p>
</div>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setItemDetail(null)}
>
{t("topology.discoverClose")}
</button>
</div>
{!itemDetail.ok ? ( <div className="ops-detail-modal__scroll">
<p className="topo-discover__error"> <div className="pt-list-table-wrap">
{itemDetail.error === "vendor_or_device_type_required" <table className="data-table pt-list-table">
? t("topology.discoverVendorRequired") <thead>
: itemDetail.error || t("topology.discoverNeFail")} <tr>
</p> <th>{t("lldpLinks.col.name")}</th>
) : null} <th>IP</th>
{itemDetail.parser_stub ? ( <th>{t("lldpLinks.col.status")}</th>
<p className="topo-discover__item-warn"> <th>{t("lldpLinks.col.neighbors")}</th>
{t("topology.discoverParserStub").replace( <th>{t("lldpLinks.col.edges")}</th>
"{{parser}}", <th>{t("lldpLinks.col.error")}</th>
itemDetail.parser_key || "unknown", <th />
)} </tr>
</p> </thead>
) : null} <tbody>
{detailItems.map((it) => {
<div className="topo-discover-modal__stats"> const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
<span> return (
{t("topology.discoverNeOk") <tr key={it.id}>
.replace("{{neighbors}}", String(itemDetail.neighbors || 0)) <td>{it.ne_name || it.ne_id || "—"}</td>
.replace("{{added}}", String(itemDetail.edges_added || 0)) <td>{it.ne_ip || "—"}</td>
.replace("{{updated}}", String(itemDetail.edges_updated || 0))} <td>{lldpItemResultLabel(t, it)}</td>
</span> <td>{it.neighbors}</td>
</div>
<h4 className="topo-discover-modal__section">
{t("topology.discoverUnmatchedTitle").replace(
"{{count}}",
String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
)}
</h4>
{(itemDetail.unmatched || []).length === 0 ? (
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
) : (
<div className="topo-discover-modal__table-wrap">
<table className="topo-discover-modal__table">
<thead>
<tr>
<th>{t("topology.discoverColRemote")}</th>
<th>{t("topology.discoverColLocalPort")}</th>
<th>{t("topology.discoverColRemotePort")}</th>
</tr>
</thead>
<tbody>
{(itemDetail.unmatched || []).map((u, idx) => (
<tr key={`${itemDetail.id}-u-${idx}`}>
<td> <td>
{(u.remote_name || u.remote_ip || "?").trim()} +{it.edges_added} / ~{it.edges_updated}
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""} {unmatchedCount > 0
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
: ""}
</td>
<td className="muted">{it.error || "—"}</td>
<td>
<Button size="sm" variant="tertiary" onPress={() => setItemDetail(it)}>
{t("topology.discoverViewDetail")}
</Button>
</td> </td>
<td>{u.local_port || "?"}</td>
<td>{u.remote_port || "?"}</td>
</tr> </tr>
))} );
</tbody> })}
</table> {!detailItems.length && !jobDetailQuery.isLoading ? (
</div> <tr>
)} <td colSpan={7} className="muted">
{t("common.empty")}
{itemDetail.raw_preview ? ( </td>
<> </tr>
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4> ) : null}
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre> </tbody>
</> </table>
) : null}
<div className="modal__actions">
<button type="button" className="btn btn--sm" onClick={() => setItemDetail(null)}>
{t("topology.discoverClose")}
</button>
</div> </div>
</div> </div>
</div>
) : null} <ListPager
page={itemPage}
pages={itemPages}
total={itemTotal}
pageSize={itemPageSize}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setItemPage}
onPageSizeChange={(size) => {
setItemPageSize(size);
setItemPage(1);
}}
disabled={jobDetailQuery.isLoading}
/>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
isDisabled={exportingItems || itemTotal === 0}
onPress={() => void exportItemsCsv()}
>
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
</Button>
<Button
variant="tertiary"
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">
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
</p>
{!itemDetail.ok ? (
<p className="topo-discover__error">
{itemDetail.error === "vendor_or_device_type_required"
? t("topology.discoverVendorRequired")
: itemDetail.error || t("topology.discoverNeFail")}
</p>
) : null}
{itemDetail.parser_stub ? (
<p className="topo-discover__item-warn">
{t("topology.discoverParserStub").replace(
"{{parser}}",
itemDetail.parser_key || "unknown",
)}
</p>
) : null}
<div className="topo-discover-modal__stats">
<span>
{t("topology.discoverNeOk")
.replace("{{neighbors}}", String(itemDetail.neighbors || 0))
.replace("{{added}}", String(itemDetail.edges_added || 0))
.replace("{{updated}}", String(itemDetail.edges_updated || 0))}
</span>
</div>
<h4 className="topo-discover-modal__section">
{t("topology.discoverUnmatchedTitle").replace(
"{{count}}",
String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
)}
</h4>
{(itemDetail.unmatched || []).length === 0 ? (
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
) : (
<div className="topo-discover-modal__table-wrap">
<table className="topo-discover-modal__table">
<thead>
<tr>
<th>{t("topology.discoverColRemote")}</th>
<th>{t("topology.discoverColLocalPort")}</th>
<th>{t("topology.discoverColRemotePort")}</th>
</tr>
</thead>
<tbody>
{(itemDetail.unmatched || []).map((u, idx) => (
<tr key={`${itemDetail.id}-u-${idx}`}>
<td>
{(u.remote_name || u.remote_ip || "?").trim()}
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
</td>
<td>{u.local_port || "?"}</td>
<td>{u.remote_port || "?"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{itemDetail.raw_preview ? (
<>
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre>
</>
) : 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,89 +309,84 @@ export function NetworkConfigsPage() {
/> />
</div> </div>
{selected ? ( <AppModalShell
<div open={Boolean(selected)}
className="modal-backdrop" onClose={() => setSelected(null)}
role="presentation" size="lg"
onClick={() => setSelected(null)} className="nm-config-modal"
> >
<div <Modal.Header>
className="modal modal--wide nm-config-modal" <Modal.Heading>{detail?.ne_name || selected?.id || t("networkConfigs.view")}</Modal.Heading>
role="dialog" <Modal.CloseTrigger />
aria-modal="true" </Modal.Header>
aria-label={t("networkConfigs.view")} <Modal.Body className="flex flex-col gap-3">
onClick={(e) => e.stopPropagation()} {selected ? (
> <p className="muted">
<div className="nm-config-modal__head"> {selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
<div className="nm-config-modal__title">
<h3>{detail?.ne_name || selected.id}</h3>
<p className="muted">
{selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
</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}
<button type="button" onClick={() => setSelected(null)}>
{t("networkConfigs.close")}
</button>
</div>
</div>
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{showAlt ? (
<div className="btn-row nm-config-modal__tabs">
<button
type="button"
className={tab === "primary" ? "btn-primary" : undefined}
onClick={() => setTab("primary")}
>
{t("networkConfigs.tabSet")}
</button>
<button
type="button"
className={tab === "alt" ? "btn-primary" : undefined}
onClick={() => setTab("alt")}
>
{t("networkConfigs.tabHier")}
</button>
</div>
) : null}
<textarea
className="nm-config-modal__body"
readOnly
spellCheck={false}
value={tab === "alt" ? detail?.config_alt_text || "" : detail?.config_text || ""}
/>
<p className="muted nm-config-modal__meta">
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
</p> </p>
</div> ) : null}
</div> {detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
) : null} {showAlt ? (
<div className="btn-row nm-config-modal__tabs">
<Button
size="sm"
variant={tab === "primary" ? "primary" : "secondary"}
onPress={() => setTab("primary")}
>
{t("networkConfigs.tabSet")}
</Button>
<Button
size="sm"
variant={tab === "alt" ? "primary" : "secondary"}
onPress={() => setTab("alt")}
>
{t("networkConfigs.tabHier")}
</Button>
</div>
) : null}
<textarea
className="nm-config-modal__body"
readOnly
spellCheck={false}
value={tab === "alt" ? detail?.config_alt_text || "" : detail?.config_text || ""}
/>
<p className="muted nm-config-modal__meta">
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
</p>
</Modal.Body>
<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}
<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,90 +53,91 @@ 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>
<div className="pt-list"> <div className="pt-list">
{boardsQuery.isLoading ? ( {boardsQuery.isLoading ? (
<p className="muted">…</p> <p className="muted">…</p>
) : boardsQuery.isError ? ( ) : boardsQuery.isError ? (
<p className="error">{(boardsQuery.error as Error)?.message || t("common.opFailed")}</p> <p className="error">{(boardsQuery.error as Error)?.message || t("common.opFailed")}</p>
) : !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">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
<thead> <thead>
<tr> <tr>
<th>{t("portTraffic.boardName")}</th> <th>{t("portTraffic.boardName")}</th>
<th>{t("portTraffic.boardPanelCount")}</th> <th>{t("portTraffic.boardPanelCount")}</th>
<th>{t("portTraffic.boardCols")}</th> <th>{t("portTraffic.boardCols")}</th>
<th>{t("portTraffic.boardUpdated")}</th> <th>{t("portTraffic.boardUpdated")}</th>
<th>{t("portTraffic.col.actions")}</th> <th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{boards.map((b) => (
<tr key={b.id}>
<td>
<button
type="button"
className="link-btn pt-list-task-name"
onClick={() => openPortTrafficBoardWindow(b.id)}
>
{b.name || t("portTraffic.boardDefaultName")}
</button>
</td>
<td>{b.panel_count}</td>
<td>{b.cols}</td>
<td>{b.updated_at ? formatSystemTime(b.updated_at) : "—"}</td>
<td>
<div className="btn-row table-actions">
<button
type="button"
className="btn-primary"
onClick={() => openPortTrafficBoardWindow(b.id)}
>
{t("portTraffic.boardOpen")}
</button>
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending}
onClick={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
deleteMut.mutate(b.id);
}
}}
>
{t("portTraffic.delete")}
</button>
</div>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {boards.map((b) => (
</div> <tr key={b.id}>
)} <td>
<Button
size="sm"
variant="ghost"
className="link-btn pt-list-task-name"
onPress={() => openPortTrafficBoardWindow(b.id)}
>
{b.name || t("portTraffic.boardDefaultName")}
</Button>
</td>
<td>{b.panel_count}</td>
<td>{b.cols}</td>
<td>{b.updated_at ? formatSystemTime(b.updated_at) : "—"}</td>
<td>
<div className="btn-row table-actions">
<Button
size="sm"
variant="primary"
onPress={() => openPortTrafficBoardWindow(b.id)}
>
{t("portTraffic.boardOpen")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={deleteMut.isPending}
onPress={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
deleteMut.mutate(b.id);
}
}}
>
{t("portTraffic.delete")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
</section> </section>
); );

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,233 +1327,222 @@ 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" <p className="muted pt-log-modal__sub">
onClick={(e) => e.stopPropagation()} {t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
> </p>
<div className="pt-log-modal__head"> <div className="filter-inline">
<div> <Input
<h3 id="pt-log-title">{t("portTraffic.logTitle")}</h3> value={logKeyword}
<p className="muted pt-log-modal__sub"> placeholder={t("portTraffic.keywordPh")}
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"} onChange={(e) => setLogKeyword(e.target.value)}
</p> />
</div> </div>
<div className="btn-row"> {logQuery.isLoading ? (
<button <p className="muted">…</p>
type="button" ) : !logEvents.length ? (
disabled={!filteredLogEvents.length} <p className="muted">{t("portTraffic.logEmpty")}</p>
onClick={exportEventsCsv} ) : !filteredLogEvents.length ? (
> <p className="muted">{t("portTraffic.logEmpty")}</p>
{t("common.exportCsv")} ) : (
</button> <div className="pt-log-modal__list">
<button type="button" onClick={closeLog}> {filteredLogEvents.map((ev) => {
{t("portTraffic.logClose")} const level = String(ev.level || "error").toLowerCase();
</button> const tone =
</div> level === "warn" || level === "warning"
</div> ? "warn"
<div className="filter-inline" style={{ marginBottom: 10 }}> : level === "info"
<input ? "info"
value={logKeyword} : "error";
placeholder={t("portTraffic.keywordPh")} return (
onChange={(e) => setLogKeyword(e.target.value)} <article key={ev.id} className={`pt-log-item pt-log-item--${tone}`}>
/> <div className="pt-log-item__meta">
</div> <span className="pt-log-item__level">{level}</span>
{logQuery.isLoading ? ( <span className="pt-log-item__time">
<p className="muted">…</p> {formatSystemTime(ev.created_at) || "—"}
) : !logEvents.length ? ( </span>
<p className="muted">{t("portTraffic.logEmpty")}</p> {ev.ifname ? (
) : !filteredLogEvents.length ? ( <span className="pt-log-item__if" title={ev.ifname}>
<p className="muted">{t("portTraffic.logEmpty")}</p> {ev.ifname}
) : (
<div className="pt-log-modal__list">
{filteredLogEvents.map((ev) => {
const level = String(ev.level || "error").toLowerCase();
const tone =
level === "warn" || level === "warning"
? "warn"
: level === "info"
? "info"
: "error";
return (
<article key={ev.id} className={`pt-log-item pt-log-item--${tone}`}>
<div className="pt-log-item__meta">
<span className="pt-log-item__level">{level}</span>
<span className="pt-log-item__time">
{formatSystemTime(ev.created_at) || "—"}
</span> </span>
{ev.ifname ? ( ) : null}
<span className="pt-log-item__if" title={ev.ifname}> </div>
{ev.ifname} <pre className="pt-log-item__msg">{formatPortTrafficLogMessage(ev.message, t)}</pre>
</span> </article>
) : null} );
</div> })}
<pre className="pt-log-item__msg">{formatPortTrafficLogMessage(ev.message, t)}</pre> </div>
</article> )}
); </Modal.Body>
})} <Modal.Footer>
</div> <Button
)} variant="secondary"
</div> isDisabled={!filteredLogEvents.length}
</div> onPress={exportEventsCsv}
) : null}
{rebindDevice ? (
<div className="modal-backdrop" role="presentation" onClick={closeRebind}>
<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}>
<p className="muted pt-log-modal__sub"> {t("portTraffic.logClose")}
{t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)} </Button>
{rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""} </Modal.Footer>
{` · ${rebindSource}`} </AppModalShell>
{rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
</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 }}> <AppModalShell
<input open={Boolean(rebindDevice)}
value={rebindKeyword} onClose={closeRebind}
onChange={(e) => { dismissible={!rebindMut.isPending}
setRebindKeyword(e.target.value); size="lg"
setRebindPage(1); >
}} <Modal.Header>
placeholder={t("portTraffic.neKeywordPh")} <Modal.Heading>{t("portTraffic.rebindTitle")}</Modal.Heading>
/> <Modal.CloseTrigger />
{rebindSelectedNe ? ( </Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{rebindDevice ? (
<>
<p className="muted pt-log-modal__sub">
{t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)}
{rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""}
{` · ${rebindSource}`}
{rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
</p>
<p className="muted pt-log-modal__sub">{t("portTraffic.rebindHint")}</p>
<div className="filter-inline">
<Input
value={rebindKeyword}
onChange={(e) => {
setRebindKeyword(e.target.value);
setRebindPage(1);
}}
placeholder={t("portTraffic.neKeywordPh")}
/>
{rebindSelectedNe ? (
<span className="muted">
{t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} (
{rebindSelectedNe.ip_address})
</span>
) : null}
</div>
{rebindNesQuery.isLoading ? (
<p className="muted">…</p>
) : !(rebindNesQuery.data?.items || []).length ? (
<p className="muted">{t("portTraffic.rebindEmpty")}</p>
) : (
<div className="pt-rebind-modal__table-wrap">
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("portTraffic.col.name")}</th>
<th>IP</th>
<th>{t("portTraffic.col.vendor")}</th>
<th>ID</th>
</tr>
</thead>
<tbody>
{(rebindNesQuery.data?.items || []).map((row) => {
const checked = rebindSelectedNe?.id === row.id;
const sameIp =
Boolean(rebindDevice.ne_ip) &&
String(row.ip_address || "").trim() ===
String(rebindDevice.ne_ip || "").trim();
return (
<tr
key={`${row.source}:${row.id}`}
className={sameIp ? "pt-rebind-row--same-ip" : undefined}
title={sameIp ? t("portTraffic.rebindSameIpHint") : undefined}
>
<td>
<input
type="radio"
name="pt-rebind-ne"
checked={checked}
onChange={() => setRebindSelectedNe(row)}
/>
</td>
<td>{row.name}</td>
<td>
{row.ip_address}
{sameIp ? (
<span className="pt-rebind-same-ip">{t("portTraffic.rebindSameIp")}</span>
) : null}
</td>
<td>{row.vendor || "—"}</td>
<td className="muted" style={{ fontSize: 12 }}>
{row.id.slice(0, 8)}…
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="pager pt-list-pager">
<Button
size="sm"
variant="ghost"
isIconOnly
className="pager__btn"
isDisabled={rebindPage <= 1}
onPress={() => setRebindPage((p) => p - 1)}
>
‹
</Button>
<span className="muted"> <span className="muted">
{t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} ( {rebindPage}/
{rebindSelectedNe.ip_address}) {Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))}
</span> </span>
) : null} <Button
</div> size="sm"
variant="ghost"
{rebindNesQuery.isLoading ? ( isIconOnly
<p className="muted">…</p> className="pager__btn"
) : !(rebindNesQuery.data?.items || []).length ? ( isDisabled={
<p className="muted">{t("portTraffic.rebindEmpty")}</p> rebindPage >=
) : ( Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
<div className="pt-rebind-modal__table-wrap"> }
<table className="data-table"> onPress={() => setRebindPage((p) => p + 1)}
<thead> >
<tr> ›
<th /> </Button>
<th>{t("portTraffic.col.name")}</th>
<th>IP</th>
<th>{t("portTraffic.col.vendor")}</th>
<th>ID</th>
</tr>
</thead>
<tbody>
{(rebindNesQuery.data?.items || []).map((row) => {
const checked = rebindSelectedNe?.id === row.id;
const sameIp =
Boolean(rebindDevice.ne_ip) &&
String(row.ip_address || "").trim() ===
String(rebindDevice.ne_ip || "").trim();
return (
<tr
key={`${row.source}:${row.id}`}
className={sameIp ? "pt-rebind-row--same-ip" : undefined}
title={sameIp ? t("portTraffic.rebindSameIpHint") : undefined}
>
<td>
<input
type="radio"
name="pt-rebind-ne"
checked={checked}
onChange={() => setRebindSelectedNe(row)}
/>
</td>
<td>{row.name}</td>
<td>
{row.ip_address}
{sameIp ? (
<span className="pt-rebind-same-ip">{t("portTraffic.rebindSameIp")}</span>
) : null}
</td>
<td>{row.vendor || "—"}</td>
<td className="muted" style={{ fontSize: 12 }}>
{row.id.slice(0, 8)}…
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
)} </>
) : null}
<div className="pager pt-list-pager" style={{ marginTop: 8 }}> </Modal.Body>
<button <Modal.Footer>
type="button" <Button variant="tertiary" isDisabled={rebindMut.isPending} onPress={closeRebind}>
className="pager__btn" {t("portTraffic.boardCancel")}
disabled={rebindPage <= 1} </Button>
onClick={() => setRebindPage((p) => p - 1)} <Button
> variant="primary"
‹ isDisabled={!rebindSelectedNe || rebindMut.isPending}
</button> onPress={confirmRebind}
<span className="muted"> >
{rebindPage}/ {rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
{Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))} </Button>
</span> </Modal.Footer>
<button </AppModalShell>
type="button"
className="pager__btn"
disabled={
rebindPage >=
Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
}
onClick={() => setRebindPage((p) => p + 1)}
>
›
</button>
</div>
<div className="modal__actions" style={{ marginTop: 12 }}>
<button type="button" onClick={closeRebind} disabled={rebindMut.isPending}>
{t("portTraffic.boardCancel")}
</button>
<button
type="button"
className="btn-primary"
disabled={!rebindSelectedNe || rebindMut.isPending}
onClick={confirmRebind}
>
{rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
</button>
</div>
</div>
</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,21 +329,20 @@ 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);
setDirty(true); setDirty(true);
}} }}
> >
<option value={1}>1</option> <option value={1}>1</option>
<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>
{t("portTraffic.logClose")} <Button variant="primary" onPress={closePanelSettings}>
</button> {t("portTraffic.logClose")}
</div> </Button>
</div> </Modal.Footer>
</div> </AppModalShell>
) : 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
{t("topology.fit")} aria-label={t("topology.more")}
</button> disabledKeys={moreDisabledKeys}
<details className="topo-toolbar__export" ref={exportMenuRef}> onAction={handleMoreAction}
<summary>{exporting ? t("topology.exporting") : t("topology.export")}</summary>
<div className="topo-export-menu" role="menu" aria-label={t("topology.export")}>
<button
type="button"
className="topo-export-menu__btn"
role="menuitem"
disabled={exporting || (!isWorldFlatCanvas && nodes.length === 0)}
onClick={() => void onExport("svg")}
> >
{t("topology.exportSvg")} <Dropdown.Item id="live-sync" textValue={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}>
</button> {liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
<button </Dropdown.Item>
type="button" <Dropdown.Item id="back" textValue={t("topology.backUp")}>
className="topo-export-menu__btn" {t("topology.backUp")}
role="menuitem" </Dropdown.Item>
disabled={exporting || !mapId} <Dropdown.Item id="undo" textValue={t("topology.undo")}>
onClick={() => void onExport("xml")} {t("topology.undo")}
> </Dropdown.Item>
{t("topology.exportXml")} <Dropdown.Item id="redo" textValue={t("topology.redo")}>
</button> {t("topology.redo")}
</div> </Dropdown.Item>
</details> <Dropdown.Item id="fit" textValue={t("topology.fit")}>
<button {t("topology.fit")}
type="button" </Dropdown.Item>
className="btn btn--sm btn--ghost" <Dropdown.Item
disabled={readOnly || !staleEdgeCount} id="export-svg"
title={readOnly ? t("topology.readOnlyHint") : t("topology.removeStaleHint")} textValue={exporting ? t("topology.exporting") : t("topology.exportSvg")}
onClick={() => void onRemoveStale()} >
> {exporting ? t("topology.exporting") : t("topology.exportSvg")}
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))} </Dropdown.Item>
</button> <Dropdown.Item
id="export-xml"
textValue={exporting ? t("topology.exporting") : t("topology.exportXml")}
>
{exporting ? t("topology.exporting") : t("topology.exportXml")}
</Dropdown.Item>
<Dropdown.Item
id="remove-stale"
textValue={t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
</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;
key={mode} return (
type="button" <Button
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`} key={mode}
title={`${label} (${key})${readOnly && mode === "connect" ? ` — ${t("topology.readOnlyHint")}` : ""}`} type="button"
aria-pressed={toolMode === mode} className={`topo-tools__btn${active ? " is-active" : ""}`}
disabled={readOnly && mode === "connect"} variant={active ? "primary" : "ghost"}
onClick={() => { size="sm"
onToolModeChange(mode); aria-pressed={active}
onConnectClickReset(); isDisabled={readOnly && mode === "connect"}
}} onPress={() => {
> onToolModeChange(mode);
<span className="topo-tools__label">{label}</span> onConnectClickReset();
<kbd className="topo-tools__key">{key}</kbd> }}
</button> >
))} <span className="topo-tools__label">{label}</span>
<kbd className="topo-tools__key">{key}</kbd>
</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">
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))} <Chip.Label>
</span> {t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
</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" {t("topology.discoverClose")}
disabled={paletteAdding} </Button>
onClick={onClose} <Button
> variant="primary"
{t("topology.discoverClose")} size="sm"
</button> isDisabled={paletteSelectedKeys.length === 0 || paletteAdding}
<button onPress={() => void onAddSelected()}
type="button" >
className="btn btn--sm" {paletteAdding
disabled={paletteSelectedKeys.length === 0 || paletteAdding} ? t("topology.addingNe")
onClick={() => void onAddSelected()} : t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
> </Button>
{paletteAdding </Modal.Footer>
? t("topology.addingNe") </TopoModalShell>
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
</button>
</div>
</div>
</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">
<Button
variant="primary"
className="h-auto flex-col items-start gap-1 whitespace-normal px-4 py-3 text-left"
onPress={onPickManaged}
>
<span className="font-semibold">{t("topology.createNeManaged")}</span>
<span className="text-sm font-normal opacity-90">{t("topology.createNeManagedHint")}</span>
</Button>
<Button
variant="secondary"
className="h-auto flex-col items-start gap-1 whitespace-normal px-4 py-3 text-left"
onPress={onPickPlaceholder}
>
<span className="font-semibold">{t("topology.createNePlaceholder")}</span>
<span className="text-sm font-normal text-muted">{t("topology.createNePlaceholderHint")}</span>
</Button>
</div> </div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeModeHint")}</p> </Modal.Body>
<div className="topo-create-ne-mode" style={{ padding: "0 16px 16px", display: "grid", gap: 10 }}> </TopoModalShell>
<button type="button" className="btn" onClick={onPickManaged}>
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNeManaged")}</span>
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}>
{t("topology.createNeManagedHint")}
</span>
</button>
<button type="button" className="btn btn--ghost" onClick={onPickPlaceholder}>
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNePlaceholder")}</span>
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}>
{t("topology.createNePlaceholderHint")}
</span>
</button>
</div>
</div>
</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 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> autoFocus
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}> value={dialog?.name ?? ""}
{t("topology.discoverClose")} onChange={onNameChange}
</button> isDisabled={pending}
</div> onKeyDown={(e) => {
<p className="panel__hint topo-modal__hint">{t("topology.folderHint")}</p> if (e.key === "Enter") onSubmit();
<div className="form-grid" style={{ padding: "0 16px 8px" }}> }}
<label> >
<span className="form-label">{t("topology.newRegionPrompt")}</span> <Label>{t("topology.newRegionPrompt")}</Label>
<input <Input />
autoFocus </TextField>
value={dialog.name} </Modal.Body>
disabled={pending} <Modal.Footer>
onChange={(e) => onNameChange(e.target.value)} <Button variant="tertiary" size="sm" isDisabled={pending} onPress={onClose}>
onKeyDown={(e) => { {t("topology.discoverClose")}
if (e.key === "Enter") onSubmit(); </Button>
}} <Button
/> variant="primary"
</label> size="sm"
</div> isDisabled={pending || !String(dialog?.name || "").trim()}
<div className="topo-modal__foot"> onPress={onSubmit}
<button >
type="button" {pending ? "…" : t("topology.newRegion")}
className="btn btn--sm btn--ghost" </Button>
disabled={pending} </Modal.Footer>
onClick={onClose} </TopoModalShell>
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={pending || !String(dialog.name || "").trim()}
onClick={onSubmit}
>
{pending ? "…" : t("topology.newRegion")}
</button>
</div>
</div>
</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" {t("topology.outsidePeersTitle")}
aria-label={t("topology.outsidePeersTitle")} <span className="topo-modal__count"> · {outsidePeers.length}</span>
> </Modal.Heading>
<div <Modal.CloseTrigger isDisabled={outsidePeersAdding} />
className="topo-modal__backdrop" </Modal.Header>
onClick={() => { <Modal.Body className="flex flex-col gap-3">
if (outsidePeersAdding) return; <p className="text-sm text-muted">{t("topology.outsidePeersHint")}</p>
onClose(); <Input
}} className="w-full"
/>
<div className="topo-modal__panel topo-modal__panel--wide">
<div className="topo-modal__head">
<strong>
{t("topology.outsidePeersTitle")}
<span className="topo-modal__count"> · {outsidePeers.length}</span>
</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onClose}
>
{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">
{t("topology.selectedCount").replace( <Chip.Label>
"{{count}}", {t("topology.selectedCount").replace(
String(outsidePeerSelectedIds.length), "{{count}}",
)} 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,30 +141,25 @@ 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" {t("topology.discoverClose")}
disabled={outsidePeersAdding} </Button>
onClick={onClose} <Button
> variant="primary"
{t("topology.discoverClose")} size="sm"
</button> isDisabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
<button onPress={() => void onAddOutsidePeers(outsidePeerSelectedIds)}
type="button" >
className="btn btn--sm" {outsidePeersAdding
disabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding} ? t("topology.addingNe")
onClick={() => void onAddOutsidePeers(outsidePeerSelectedIds)} : t("topology.addSelected").replace(
> "{{count}}",
{outsidePeersAdding String(outsidePeerSelectedIds.length),
? t("topology.addingNe") )}
: t("topology.addSelected").replace( </Button>
"{{count}}", </Modal.Footer>
String(outsidePeerSelectedIds.length), </TopoModalShell>
)}
</button>
</div>
</div>
</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"> autoFocus
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong> isRequired
<button value={dialog?.name ?? ""}
type="button" onChange={(name) => onChange({ name })}
className="btn btn--sm btn--ghost" isDisabled={busy}
disabled={busy} onKeyDown={(e) => {
onClick={onClose} if (e.key === "Enter") void onSubmit();
> }}
{t("topology.discoverClose")} >
</button> <Label>{t("topology.createNeName")}</Label>
</div> <Input placeholder={t("topology.createNeNamePh")} />
<p className="panel__hint topo-modal__hint">{t("topology.createNeHint")}</p> </TextField>
<div className="form-grid" style={{ padding: "0 16px 8px" }}> <TextField
<label> fullWidth
<span className="form-label"> value={dialog?.ip_address ?? ""}
{t("topology.createNeName")} onChange={(ip_address) => onChange({ ip_address })}
<span className="form-label__required" aria-hidden="true"> isDisabled={busy}
{" "} onKeyDown={(e) => {
* if (e.key === "Enter") void onSubmit();
</span> }}
</span> >
<input <Label>{t("topology.createNeIp")}</Label>
autoFocus <Input placeholder={t("topology.createNeIpPh")} />
value={dialog.name} </TextField>
placeholder={t("topology.createNeNamePh")} </Modal.Body>
disabled={busy} <Modal.Footer>
onChange={(e) => onChange({ name: e.target.value })} <Button variant="tertiary" isDisabled={busy} onPress={onClose}>
onKeyDown={(e) => { {t("topology.discoverClose")}
if (e.key === "Enter") void onSubmit(); </Button>
}} <Button variant="primary" isDisabled={busy} onPress={() => void onSubmit()}>
/> {busy ? t("topology.createNeBusy") : t("topology.createNePlaceholder")}
</label> </Button>
<label> </Modal.Footer>
<span className="form-label">{t("topology.createNeIp")}</span> </TopoModalShell>
<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()}
>
{busy ? t("topology.createNeBusy") : t("topology.createNePlaceholder")}
</button>
</div>
</div>
</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",