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:
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:

View file

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

View file

@ -235,6 +235,7 @@ def ensure_default_mcp_token(db: Session, user: AppUser | None = None) -> str |
name="mcp-default",
expires_in_days=0,
scopes=list(MCP_DEFAULT_SCOPES),
enforce_limit=False,
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(plaintext + "\n", encoding="utf-8")
@ -571,6 +572,17 @@ def change_password(
revoke_auth_sessions(db, user_id=str(row.id), except_jti=keep_jti)
def count_active_api_tokens(db: Session) -> int:
"""Non-revoked API keys (expired-but-not-revoked still occupy a slot)."""
return int(db.query(ApiToken).filter(ApiToken.revoked_at.is_(None)).count())
def api_token_quota(db: Session) -> dict[str, int]:
max_count = max(0, int(getattr(settings, "auth_api_token_max_count", 20) or 0))
active = count_active_api_tokens(db)
return {"active_count": active, "max_count": max_count}
def create_api_token(
db: Session,
*,
@ -578,10 +590,20 @@ def create_api_token(
name: str,
expires_in_days: int | None = None,
scopes: list[str] | None = None,
enforce_limit: bool = True,
) -> tuple[ApiToken, str]:
label = str(name or "").strip() or "default"
if len(label) > 128:
raise HTTPException(status_code=400, detail="token_name_too_long")
if enforce_limit:
max_count = max(0, int(getattr(settings, "auth_api_token_max_count", 20) or 0))
if max_count > 0:
active = count_active_api_tokens(db)
if active >= max_count:
raise HTTPException(
status_code=400,
detail=f"api_token_limit_reached:{active}/{max_count}",
)
expires_at: datetime | None = None
if expires_in_days is not None and int(expires_in_days) > 0:
expires_at = utcnow_naive() + timedelta(days=int(expires_in_days))

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"
# Written on first boot for MCP; path relative to cwd / absolute
auth_mcp_token_file: str = "data/auth/mcp_token"
# Cap non-revoked API keys (MCP / DSH / scripts). 0 = unlimited.
auth_api_token_max_count: int = 20
# Comma-separated peers allowed to supply X-Forwarded-For / X-Real-IP
# (Vite dev proxy, local nginx). Add your reverse-proxy LAN IP in production.
trusted_proxy_ips: str = "127.0.0.1,::1"
# Expose /docs /redoc /openapi.json without auth when true (lab only).
docs_enabled: bool = False
# Refuse start when bind host is non-loopback and insecure defaults remain.

View file

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

View file

@ -440,6 +440,30 @@ class AuthApiTests(unittest.TestCase):
)
self.assertEqual(empty.status_code, 400)
def test_api_token_max_count(self) -> None:
token = self._login()
with patch("netx_api.auth_service.settings.auth_api_token_max_count", 2):
listed = self.client.get("/v1/api-tokens", headers={"Authorization": f"Bearer {token}"})
self.assertEqual(listed.status_code, 200, listed.text)
body = listed.json()
self.assertEqual(body.get("max_count"), 2)
active = int(body.get("active_count") or 0)
while active < 2:
created = self.client.post(
"/v1/api-tokens",
headers={"Authorization": f"Bearer {token}"},
json={"name": f"cap-{active}", "expires_in_days": 7},
)
self.assertEqual(created.status_code, 200, created.text)
active += 1
blocked = self.client.post(
"/v1/api-tokens",
headers={"Authorization": f"Bearer {token}"},
json={"name": "over-cap", "expires_in_days": 7},
)
self.assertEqual(blocked.status_code, 400, blocked.text)
self.assertIn("api_token_limit_reached", str(blocked.json().get("detail") or ""))
if __name__ == "__main__":
unittest.main()

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>
<html lang="zh-CN">
<html lang="zh-CN" class="dark" data-theme="dark">
<head>
<meta charset="UTF-8" />
<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"
},
"dependencies": {
"@heroui/react": "^3.2.5",
"@heroui/styles": "^3.2.5",
"@tanstack/react-query": "^5.100.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
@ -21,10 +23,12 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"tailwind-variants": "^3.3.1",
"uplot": "^1.6.32"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.3",
"@types/dagre": "^0.7.54",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
@ -34,6 +38,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useI18n } from "../i18n";
/** Compact locale toggle: shows current language, click switches ZH ↔ EN. */
@ -7,14 +8,14 @@ export function HeaderMenu() {
const label = locale === "zh" ? "ZH" : "EN";
return (
<button
type="button"
<Button
size="sm"
variant="ghost"
className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--locale"
title={t("layout.switchLanguage")}
aria-label={t("layout.switchLanguage")}
onClick={() => setLocale(next)}
onPress={() => setLocale(next)}
>
{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 { FieldSelect } from "./ui/FieldSelect";
import {
HOP_VENDORS,
defaultHopTemplate,
@ -38,20 +39,6 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({
hop_enter_system_view: false,
});
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
function applyHopTemplate(
prev: HopProxyFieldsState,
protocol: string,
@ -111,143 +98,151 @@ export function HopProxyFields({
return (
<div className="form-grid">
<label className="form-grid__full">
<FormLabel required>{t("managedNe.hop.type")}</FormLabel>
<select
value={value.hop_vendor}
onChange={(e) => {
const hop_vendor = e.target.value as HopVendor;
set(patchHopVendorChange(hop_vendor, value));
}}
>
{HOP_VENDORS.map((v) => (
<option key={v} value={v}>
{t(`managedNe.hop.vendor.${v}`)}
</option>
))}
</select>
<span className="form-field-hint">{t(hopHintKey(value.hop_vendor))}</span>
</label>
<label>
<FormLabel required>{t("managedNe.hop.host")}</FormLabel>
<input
required
value={value.hop_host}
placeholder={bastion ? t("managedNe.hop.hostPlaceholderBastion") : undefined}
onChange={(e) => set({ hop_host: e.target.value })}
onBlur={() => {
if (!bastion) return;
const raw = String(value.hop_host || "").trim();
if (!raw.includes("@")) return;
const parsed = expandBastionHopFields(raw, value.hop_username);
if (!parsed.hop_host || parsed.hop_host === raw) return;
set({
hop_host: parsed.hop_host,
hop_username: parsed.hop_username || value.hop_username,
});
}}
/>
<FieldSelect
fullWidth
className="form-grid__full"
label={t("managedNe.hop.type")}
required
value={value.hop_vendor}
hint={t(hopHintKey(value.hop_vendor))}
onChange={(e) => {
const hop_vendor = e.target.value as HopVendor;
set(patchHopVendorChange(hop_vendor, value));
}}
>
{HOP_VENDORS.map((v) => (
<option key={v} value={v}>
{t(`managedNe.hop.vendor.${v}`)}
</option>
))}
</FieldSelect>
<TextField
fullWidth
isRequired
value={value.hop_host}
onChange={(hop_host) => set({ hop_host })}
onBlur={() => {
if (!bastion) return;
const raw = String(value.hop_host || "").trim();
if (!raw.includes("@")) return;
const parsed = expandBastionHopFields(raw, value.hop_username);
if (!parsed.hop_host || parsed.hop_host === raw) return;
set({
hop_host: parsed.hop_host,
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}
</label>
<label>
<FormLabel>{t("managedNe.hop.port")}</FormLabel>
<input
type="number"
value={value.hop_port}
onChange={(e) => set({ hop_port: Number(e.target.value) || 22 })}
/>
</label>
</TextField>
<TextField
fullWidth
type="number"
value={String(value.hop_port)}
onChange={(hop_port) => set({ hop_port: Number(hop_port) || 22 })}
>
<Label>{t("managedNe.hop.port")}</Label>
<Input />
</TextField>
{bastion ? (
<label className="form-grid__full">
<FormLabel>{t("managedNe.hop.targetAuthMode")}</FormLabel>
<select
value={value.hop_target_auth_mode}
onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })}
>
<option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option>
<option value="manual">{t("managedNe.hop.targetAuthManual")}</option>
</select>
<span className="form-field-hint">{t("managedNe.hop.targetAuthHint")}</span>
</label>
<FieldSelect
fullWidth
className="form-grid__full"
label={t("managedNe.hop.targetAuthMode")}
value={value.hop_target_auth_mode}
hint={t("managedNe.hop.targetAuthHint")}
onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })}
>
<option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option>
<option value="manual">{t("managedNe.hop.targetAuthManual")}</option>
</FieldSelect>
) : null}
{cliHop ? (
<label>
<FormLabel>{t("managedNe.hop.protocol")}</FormLabel>
<select
value={value.hop_protocol}
onChange={(e) => {
const hop_protocol = e.target.value;
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
}}
>
<option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option>
<option value="telnet">telnet</option>
</select>
</label>
<FieldSelect
label={t("managedNe.hop.protocol")}
value={value.hop_protocol}
onChange={(e) => {
const hop_protocol = e.target.value;
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
}}
>
<option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option>
<option value="telnet">telnet</option>
</FieldSelect>
) : null}
<label>
<FormLabel required>{t("managedNe.hop.username")}</FormLabel>
<input required value={value.hop_username} onChange={(e) => set({ hop_username: e.target.value })} />
</label>
<label>
<FormLabel required={hopPasswordRequired}>
<TextField
fullWidth
isRequired
value={value.hop_username}
onChange={(hop_username) => set({ hop_username })}
>
<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")}
{hopPasswordOptional ? (
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
) : null}
</FormLabel>
<input
type="password"
required={hopPasswordRequired}
value={value.hop_password}
onChange={(e) => set({ hop_password: e.target.value })}
/>
</label>
</Label>
<Input />
</TextField>
{bastion ? (
<label className="form-grid__full">
<FormLabel>{t("managedNe.hop.usernameTemplate")}</FormLabel>
<input
value={value.hop_command_template}
onChange={(e) => set({ hop_command_template: e.target.value })}
placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)}
/>
<TextField
fullWidth
className="form-grid__full"
value={value.hop_command_template}
onChange={(hop_command_template) => set({ hop_command_template })}
>
<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>
</label>
</TextField>
) : null}
{cliHop ? (
<>
<label>
<FormLabel>{t(vrfLabelKey(value.hop_vendor))}</FormLabel>
<input
value={value.hop_vrf}
onChange={(e) => {
const hop_vrf = e.target.value;
set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
}}
/>
</label>
<TextField
fullWidth
value={value.hop_vrf}
onChange={(hop_vrf) => {
set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
}}
>
<Label>{t(vrfLabelKey(value.hop_vendor))}</Label>
<Input />
</TextField>
{huawei ? (
<label className="form-grid__full">
<FormLabel>{t("managedNe.hop.enterSystemView")}</FormLabel>
<select
value={value.hop_enter_system_view ? "yes" : "no"}
onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })}
>
<option value="no">{t("managedNe.hop.enterSystemViewNo")}</option>
<option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option>
</select>
<span className="form-field-hint">{t("managedNe.hop.enterSystemViewHint")}</span>
</label>
<FieldSelect
fullWidth
className="form-grid__full"
label={t("managedNe.hop.enterSystemView")}
value={value.hop_enter_system_view ? "yes" : "no"}
hint={t("managedNe.hop.enterSystemViewHint")}
onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })}
>
<option value="no">{t("managedNe.hop.enterSystemViewNo")}</option>
<option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option>
</FieldSelect>
) : null}
<label className="form-grid__full">
<FormLabel>{t("managedNe.hop.commandTemplate")}</FormLabel>
<input
value={value.hop_command_template}
onChange={(e) => set({ hop_command_template: e.target.value })}
placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)}
/>
<TextField
fullWidth
className="form-grid__full"
value={value.hop_command_template}
onChange={(hop_command_template) => set({ hop_command_template })}
>
<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>
</label>
</TextField>
</>
) : null}
</div>

View file

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

View file

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

View file

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

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;
@ -27,12 +36,22 @@ function useToastState(autoHideMs: number): ToastApi {
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 }) {
const api = useToastState(autoHideMs);
return (
<ToastContext.Provider value={api}>
{children}
{api.toast ? <div className={`toast toast--${api.toast.type}`}>{api.toast.text}</div> : null}
{api.toast ? <ToastViewport toast={api.toast} /> : null}
</ToastContext.Provider>
);
}

View file

@ -562,7 +562,8 @@ const en = {
loginFailed: "Login failed",
logout: "Sign out",
sessionsTitle: "Login sessions",
sessionsHint: "Manage browser/device logins for this account. Revoked sessions must sign in again.",
sessionsHint:
"Manage browser/device logins for this account. Behind a reverse proxy, IP is taken from trusted forwarded headers; use the Client column to tell browsers apart on the same public IP. Revoked sessions must sign in again.",
sessionsEmpty: "No active sessions.",
revokeOtherSessions: "Revoke other sessions",
revokeSession: "Revoke",
@ -571,6 +572,7 @@ const en = {
sessionsRevokedOthers: "Revoked {{count}} other session(s)",
revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?",
colSession: "Session",
colClient: "Client",
colLastSeen: "Last seen",
colCreated: "Created",
usersTitle: "User management",
@ -603,7 +605,9 @@ const en = {
colIp: "IP",
apiKeysTitle: "API Key management",
apiKeysHelp:
"The web UI uses login sessions, not API keys — keys are for MCP/scripts. Defaults on create: alarms:read + ne:read + ne:write + ne:exec; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved for future MCP tools.",
"The web UI uses login sessions, not API keys — keys are for MCP/scripts. Defaults on create: alarms:read + ne:read + ne:write + ne:exec; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved for future MCP tools. System-wide cap: {{max}} non-revoked keys (revoke to free a slot).",
tokenQuota: "Active {{active}} / {{max}}",
tokenLimitReached: "API key limit reached ({{max}}). Revoke unused keys first.",
tokenName: "Name",
expiresIn: "Expiry",
expire7d: "7 days",
@ -1511,6 +1515,7 @@ const en = {
browserRegionsSub: "{{count}} item(s)",
browserEmptyMaps: "No root map under this root yet.",
addNe: "Add NE",
more: "More",
backUp: "Back",
newRegion: "New root",
newRegionName: "New root",

View file

@ -558,7 +558,8 @@ const zh = {
loginFailed: "登录失败",
logout: "退出",
sessionsTitle: "登录会话",
sessionsHint: "管理当前账号在各浏览器/设备上的登录。踢掉会话后对方需重新登录。",
sessionsHint:
"管理当前账号在各浏览器/设备上的登录。IP 经反代时会取真实客户端地址;同公网出口可用「客户端」列区分浏览器/系统。踢掉会话后对方需重新登录。",
sessionsEmpty: "当前没有活跃会话。",
revokeOtherSessions: "踢掉其他会话",
revokeSession: "踢掉",
@ -567,6 +568,7 @@ const zh = {
sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话",
revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?",
colSession: "会话",
colClient: "客户端",
colLastSeen: "最近活动",
colCreated: "创建时间",
usersTitle: "用户管理",
@ -599,7 +601,9 @@ const zh = {
colIp: "IP",
apiKeysTitle: "API Key 管理",
apiKeysHelp:
"网页登录不用 API Key,Key 给 MCP/脚本用。生成时默认 alarms:read + ne:read + ne:write + ne:exec,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接 MCP 的项为预留。",
"网页登录不用 API Key,Key 给 MCP/脚本用。生成时默认 alarms:read + ne:read + ne:write + ne:exec,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接 MCP 的项为预留。全站未吊销 Key 上限 {{max}} 把(吊销后可再建)。",
tokenQuota: "未吊销 {{active}} / {{max}}",
tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销不用的 Key",
tokenName: "名称",
expiresIn: "有效期",
expire7d: "7 天",
@ -1500,6 +1504,7 @@ const zh = {
browserRegionsSub: "共 {{count}} 个",
browserEmptyMaps: "此根下还没有根图。",
addNe: "添加网元",
more: "更多",
backUp: "返回",
newRegion: "新建根",
newRegionName: "新根",

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,7 +1,9 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../components/ListPager";
import { AppModalShell } from "../components/ui/AppModalShell";
import {
createNeCollection,
deleteCollectionJob,
@ -29,8 +31,8 @@ import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
const POLL_MS = 2000;
const ELIGIBLE_PAGE_SIZE = 20;
const POLICY_TARGET_PAGE_SIZE = 20;
const ELIGIBLE_PAGE_SIZE = 10;
const POLICY_TARGET_PAGE_SIZE = 10;
const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const;
const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const RUN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
@ -71,7 +73,7 @@ export function CollectPage() {
const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1);
const [jobPage, setJobPage] = useState(1);
const [jobPageSize, setJobPageSize] = useState(20);
const [jobPageSize, setJobPageSize] = useState(10);
const [jobStatus, setJobStatus] = useState("");
const [jobKeyword, setJobKeyword] = useState("");
const [exportingJobs, setExportingJobs] = useState(false);
@ -468,35 +470,42 @@ export function CollectPage() {
) : null}
</div>
<div className="btn-row">
<button
type="button"
disabled={exportingJobs || jobTotal === 0}
onClick={() => void exportJobsCsv()}
<Button
size="sm"
variant="secondary"
isDisabled={exportingJobs || jobTotal === 0}
onPress={() => void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}>
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={jobsQuery.isFetching || dashQuery.isFetching}
onPress={refreshAll}
>
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
<button
type="button"
className="btn-primary"
disabled={startFromPolicyMutation.isPending || jobActive}
onClick={() => startFromPolicyMutation.mutate()}
</Button>
<Button
size="sm"
variant="primary"
isDisabled={startFromPolicyMutation.isPending || jobActive}
onPress={() => startFromPolicyMutation.mutate()}
>
{startFromPolicyMutation.isPending ? t("collect.form.starting") : t("collect.collectNow")}
</button>
<button type="button" onClick={() => setCreateOpen(true)}>
</Button>
<Button size="sm" variant="secondary" onPress={() => setCreateOpen(true)}>
{t("collect.create.expand")}
</button>
</Button>
{running?.status === "running" || running?.status === "pending" ? (
<button
type="button"
onClick={() => pauseMutation.mutate(running.id)}
disabled={pauseMutation.isPending}
<Button
size="sm"
variant="secondary"
isDisabled={pauseMutation.isPending}
onPress={() => pauseMutation.mutate(running.id)}
>
{t("collect.jobs.pause")}
</button>
</Button>
) : null}
</div>
</div>
@ -600,14 +609,13 @@ export function CollectPage() {
<option value="selected">{t("collect.scopeSelected")}</option>
</select>
</label>
<button
type="button"
className="btn-primary"
disabled={savePolicyMutation.isPending}
onClick={() => savePolicyMutation.mutate()}
<Button
variant="primary"
isDisabled={savePolicyMutation.isPending}
onPress={() => savePolicyMutation.mutate()}
>
{t("collect.savePolicy")}
</button>
</Button>
</div>
<div className="form-grid form-grid--single" style={{ marginTop: 12 }}>
@ -726,17 +734,18 @@ export function CollectPage() {
</option>
))}
</select>
<button
type="button"
disabled={!hasJobFilters}
onClick={() => {
<Button
size="sm"
variant="tertiary"
isDisabled={!hasJobFilters}
onPress={() => {
setJobKeyword("");
setJobStatus("");
setJobPage(1);
}}
>
{t("common.clearFilters")}
</button>
</Button>
</div>
{jobsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -795,324 +804,312 @@ export function CollectPage() {
</div>
</section>
{createOpen ? (
<div className="modal-backdrop" role="presentation" onClick={() => setCreateOpen(false)}>
<div
className="modal modal--wide ops-detail-modal ops-detail-modal--xl"
role="dialog"
aria-modal="true"
aria-label={t("collect.create.title")}
onClick={(e) => e.stopPropagation()}
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("collect.create.title")}</h3>
<p className="muted">{t("collect.create.hint")}</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button type="button" onClick={() => setCreateOpen(false)}>
{t("networkConfigs.close")}
</button>
<AppModalShell
open={createOpen}
onClose={() => setCreateOpen(false)}
dismissible={!createMutation.isPending}
size="lg"
className="ops-detail-modal"
>
<Modal.Header>
<Modal.Heading>{t("collect.create.title")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={createMutation.isPending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">{t("collect.create.hint")}</p>
<div className="collect-create-modal">
<div className="form-grid form-grid--single">
<label className="form-grid__full">
{t("collect.form.jobTitle")}
<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>
{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 className="ops-detail-modal__scroll ops-detail-modal__scroll--pad collect-create-modal">
<div className="form-grid form-grid--single">
<label className="form-grid__full">
{t("collect.form.jobTitle")}
<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 type="button" className="link-btn" onClick={clearSelected}>
{t("collect.create.clearSelected")}
</button>
) : null}
<div className="collect-pick-block">
<div className="panel__toolbar">
<div>
<h3>{t("collect.create.pickTitle")}</h3>
<p className="panel__hint">{t("collect.create.pickHint")}</p>
</div>
{selectedList.length === 0 ? (
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
) : (
<div className="collect-selected-list">
{selectedList.map((row) => {
<div className="table-actions">
<Button
size="sm"
variant="ghost"
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 picked = Boolean(selectedMap[key]);
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 type="button" className="link-btn" onClick={() => removeNe(key)}>
{t("collect.create.remove")}
</button>
</div>
<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
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 className="collect-pick-block">
<div className="panel__toolbar">
<div>
<h3>{t("collect.create.pickTitle")}</h3>
<p className="panel__hint">{t("collect.create.pickHint")}</p>
</div>
<div className="table-actions">
<button
type="button"
className="link-btn"
disabled={batchPickCount === 0}
onClick={() => {
const rows = eligibleItems.filter(
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
);
addBatchNe(rows);
{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
size="sm"
variant="ghost"
isDisabled={nePage <= 1}
onPress={() => {
setNePage(nePage - 1);
setPickSelectedIds([]);
}}
>
{t("collect.create.addBatch", { count: batchPickCount })}
</button>
<button
type="button"
onClick={() => eligibleQuery.refetch()}
disabled={eligibleQuery.isFetching}
{t("common.prevPage")}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={nePage >= nePages}
onPress={() => {
setNePage(nePage + 1);
setPickSelectedIds([]);
}}
>
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
{t("common.nextPage")}
</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 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>
) : null}
</div>
</div>
</div>
) : null}
{expandedJobId ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setExpandedJobId("")}
>
<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()}
</Modal.Body>
<Modal.Footer>
<span className="muted">
{t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
</span>
<Button
variant="tertiary"
isDisabled={createMutation.isPending}
onPress={() => setCreateOpen(false)}
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("collect.jobs.detailTitle")}</h3>
<p className="muted">
{(() => {
const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job;
return job
? `${job.title} · ${job.status} · ${job.success_count}/${job.ne_count}`
: expandedJobId.slice(0, 8);
})()}
</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button type="button" onClick={() => setExpandedJobId("")}>
{t("networkConfigs.close")}
</button>
</div>
</div>
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<JobRunsPanel
jobId={expandedJobId}
jobStatus={
jobs.find((j) => j.id === expandedJobId)?.status ||
detailQuery.data?.job.status ||
""
}
failCount={
jobs.find((j) => j.id === expandedJobId)?.fail_count ??
detailQuery.data?.job.fail_count ??
0
}
commands={detailQuery.data?.job.commands ?? ""}
onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)}
retryPending={actionPending}
/>
</div>
</div>
) : null}
{t("networkConfigs.close")}
</Button>
<Button
variant="primary"
isDisabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
onPress={() => createMutation.mutate()}
>
{createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
</Button>
</Modal.Footer>
</AppModalShell>
<AppModalShell
open={Boolean(expandedJobId)}
onClose={() => setExpandedJobId("")}
size="lg"
className="ops-detail-modal"
>
<Modal.Header>
<Modal.Heading>{t("collect.jobs.detailTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">
{(() => {
const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job;
return job
? `${job.title} · ${job.status} · ${job.success_count}/${job.ne_count}`
: expandedJobId.slice(0, 8);
})()}
</p>
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<JobRunsPanel
jobId={expandedJobId}
jobStatus={
jobs.find((j) => j.id === expandedJobId)?.status ||
detailQuery.data?.job.status ||
""
}
failCount={
jobs.find((j) => j.id === expandedJobId)?.fail_count ??
detailQuery.data?.job.fail_count ??
0
}
commands={detailQuery.data?.job.commands ?? ""}
onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)}
retryPending={actionPending}
/>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={() => setExpandedJobId("")}>
{t("networkConfigs.close")}
</Button>
</Modal.Footer>
</AppModalShell>
</div>
);
}
@ -1156,36 +1153,49 @@ function JobRow({
<td>{formatSystemTime(job.created_at)}</td>
<td>{job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")}</td>
<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")}
</button>
</Button>
{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")}
</button>
</Button>
) : null}
{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")}
</button>
</Button>
) : null}
{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")}
</button>
</Button>
) : null}
<button
type="button"
<Button
size="sm"
variant="ghost"
className="link-btn"
disabled={actionPending || !hasResults}
onClick={downloadResults}
isDisabled={actionPending || !hasResults}
onPress={downloadResults}
>
{t("collect.jobs.downloadResults")}
</button>
</Button>
{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")}
</button>
</Button>
) : null}
</td>
</tr>
@ -1212,7 +1222,7 @@ function JobRunsPanel({
const { t } = useI18n();
const { showOk, showError } = useToast();
const [runPage, setRunPage] = useState(1);
const [runPageSize, setRunPageSize] = useState(20);
const [runPageSize, setRunPageSize] = useState(10);
const [runStatus, setRunStatus] = useState("");
const [runKeyword, setRunKeyword] = useState("");
const [exportingRuns, setExportingRuns] = useState(false);
@ -1322,17 +1332,22 @@ function JobRunsPanel({
))}
</select>
{hasRunFilters ? (
<button type="button" onClick={clearRunFilters}>
<Button size="sm" variant="tertiary" onPress={clearRunFilters}>
{t("common.clearFilters")}
</button>
</Button>
) : 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")}
</button>
</Button>
{jobStatus !== "running" && failCount > 0 ? (
<button type="button" disabled={retryPending} onClick={onRetryFailed}>
<Button size="sm" variant="secondary" isDisabled={retryPending} onPress={onRetryFailed}>
{t("collect.jobs.retryFailed")}
</button>
</Button>
) : null}
</div>
{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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../components/ListPager";
import { AppModalShell } from "../components/ui/AppModalShell";
import { FieldSelect } from "../components/ui/FieldSelect";
import { queryKeys } from "../constants/queryKeys";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { useToast } from "../hooks/useToast";
@ -24,7 +27,7 @@ import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 20;
const TARGET_PAGE_SIZE = 10;
export function ConfigSyncPage() {
const { t } = useI18n();
@ -33,13 +36,13 @@ export function ConfigSyncPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [cyclePage, setCyclePage] = useState(1);
const [cyclePageSize, setCyclePageSize] = useState(20);
const [cyclePageSize, setCyclePageSize] = useState(10);
const [cycleStatus, setCycleStatus] = useState("");
const [cycleKeyword, setCycleKeyword] = useState("");
const [exportingCycles, setExportingCycles] = useState(false);
const [expandedCycleId, setExpandedCycleId] = useState("");
const [taskPage, setTaskPage] = useState(1);
const [taskPageSize, setTaskPageSize] = useState(20);
const [taskPageSize, setTaskPageSize] = useState(10);
const [taskStatus, setTaskStatus] = useState("");
const [taskKeyword, setTaskKeyword] = useState("");
const [exportingTasks, setExportingTasks] = useState(false);
@ -354,44 +357,61 @@ export function ConfigSyncPage() {
<div className="panel__toolbar">
<h2>{t("configSync.title")}</h2>
<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")}
</button>
<button
type="button"
className="btn-primary"
disabled={Boolean(running) || startMut.isPending}
onClick={() => startMut.mutate("full")}
</Button>
<Button
size="sm"
variant="primary"
isDisabled={Boolean(running) || startMut.isPending}
onPress={() => startMut.mutate("full")}
>
{t("configSync.syncNow")}
</button>
<button
type="button"
disabled={Boolean(running) || startMut.isPending || !(last && last.fail_count > 0)}
onClick={() => startMut.mutate("retry_failed")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={Boolean(running) || startMut.isPending || !(last && last.fail_count > 0)}
onPress={() => startMut.mutate("retry_failed")}
>
{t("configSync.retryFailed")}
</button>
</Button>
{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")}
</button>
</Button>
) : null}
{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")}
</button>
</Button>
) : null}
{running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? (
<button
type="button"
onClick={() => {
<Button
size="sm"
variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(running.id);
}}
disabled={stopMut.isPending}
>
{t("configSync.stop")}
</button>
</Button>
) : null}
</div>
</div>
@ -436,41 +456,41 @@ export function ConfigSyncPage() {
</label>
<label className="config-sync-policy-field">
<span>{t("configSync.intervalDays")}</span>
<input
<Input
type="number"
min={1}
max={365}
value={intervalDays}
value={String(intervalDays)}
onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("configSync.concurrency")}</span>
<input
<Input
type="number"
min={1}
max={30}
value={concurrency}
value={String(concurrency)}
onChange={(e) => setConcurrency(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("configSync.historyKeep")}</span>
<input
<Input
type="number"
min={0}
max={30}
value={historyKeep}
value={String(historyKeep)}
onChange={(e) => setHistoryKeep(Math.max(0, Math.min(30, Number(e.target.value) || 0)))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("configSync.cycleKeep")}</span>
<input
<Input
type="number"
min={0}
max={200}
value={cycleKeep}
value={String(cycleKeep)}
onChange={(e) => setCycleKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))}
/>
</label>
@ -484,14 +504,13 @@ export function ConfigSyncPage() {
<option value="selected">{t("configSync.scopeSelected")}</option>
</select>
</label>
<button
type="button"
className="btn-primary"
disabled={savePolicyMut.isPending}
onClick={() => savePolicyMut.mutate()}
<Button
variant="primary"
isDisabled={savePolicyMut.isPending}
onPress={() => savePolicyMut.mutate()}
>
{t("configSync.savePolicy")}
</button>
</Button>
</div>
{scopeMode === "selected" ? (
@ -540,9 +559,14 @@ export function ConfigSyncPage() {
</tbody>
</table>
<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")}
</button>
</Button>
<span className="muted">
{t("common.pagerMeta", {
total: String(targetsQuery.data?.total ?? 0),
@ -550,13 +574,14 @@ export function ConfigSyncPage() {
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
})}
</span>
<button
type="button"
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onClick={() => setTargetPage((p) => p + 1)}
<Button
size="sm"
variant="ghost"
isDisabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onPress={() => setTargetPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</Button>
</div>
</div>
) : null}
@ -565,7 +590,7 @@ export function ConfigSyncPage() {
<h3>{t("configSync.cyclesTitle")}</h3>
<div className="pt-list">
<div className="filter-inline">
<input
<Input
value={cycleKeyword}
placeholder={t("configSync.keywordPh")}
onChange={(e) => {
@ -573,7 +598,7 @@ export function ConfigSyncPage() {
setCyclePage(1);
}}
/>
<select
<FieldSelect
value={cycleStatus}
onChange={(e) => {
setCycleStatus(e.target.value);
@ -588,25 +613,27 @@ export function ConfigSyncPage() {
<option value="completed">completed</option>
<option value="failed">failed</option>
<option value="stopped">stopped</option>
</select>
<button
type="button"
disabled={!hasCycleFilters}
onClick={() => {
</FieldSelect>
<Button
size="sm"
variant="tertiary"
isDisabled={!hasCycleFilters}
onPress={() => {
setCycleKeyword("");
setCycleStatus("");
setCyclePage(1);
}}
>
{t("common.clearFilters")}
</button>
<button
type="button"
disabled={exportingCycles || cycleTotal === 0}
onClick={() => void exportCyclesCsv()}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={exportingCycles || cycleTotal === 0}
onPress={() => void exportCyclesCsv()}
>
{exportingCycles ? t("common.exporting") : t("common.exportCsv")}
</button>
</Button>
</div>
{cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -630,13 +657,9 @@ export function ConfigSyncPage() {
{cycles.map((c) => (
<tr key={c.id}>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => openCycleTasks(c.id)}
>
<Button size="sm" variant="ghost" onPress={() => openCycleTasks(c.id)}>
{t("configSync.expand")}
</button>
</Button>
</td>
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
<td>{c.trigger_mode}</td>
@ -665,25 +688,36 @@ export function ConfigSyncPage() {
<td>
<div className="btn-row">
{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")}
</button>
</Button>
) : null}
{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")}
</button>
</Button>
) : null}
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
<button
type="button"
onClick={() => {
<Button
size="sm"
variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
}}
disabled={stopMut.isPending}
>
{t("configSync.stop")}
</button>
</Button>
) : null}
</div>
</td>
@ -704,7 +738,7 @@ export function ConfigSyncPage() {
pages={cyclePages}
total={cycleTotal}
pageSize={cyclePageSize}
pageSizeOptions={[20, 50, 100]}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setCyclePage}
onPageSizeChange={(size) => {
setCyclePageSize(size);
@ -714,130 +748,117 @@ export function ConfigSyncPage() {
/>
</div>
{expandedCycleId ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setExpandedCycleId("")}
>
<div
className="modal modal--wide ops-detail-modal"
role="dialog"
aria-modal="true"
aria-label={t("configSync.tasksTitle")}
onClick={(e) => e.stopPropagation()}
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("configSync.tasksTitle")}</h3>
<p className="muted">
{expandedCycleId.slice(0, 8)}
{(() => {
const c = cycles.find((x) => x.id === expandedCycleId);
return c
? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}`
: "";
})()}
</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
disabled={exportingTasks || taskTotal === 0}
onClick={() => void exportTasksCsv()}
>
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" onClick={() => setExpandedCycleId("")}>
{t("networkConfigs.close")}
</button>
</div>
</div>
<div className="ops-detail-modal__toolbar filter-inline">
<input
value={taskKeyword}
placeholder={t("configSync.taskKeywordPh")}
onChange={(e) => {
setTaskKeyword(e.target.value);
setTaskPage(1);
}}
/>
<select
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>
<AppModalShell
open={Boolean(expandedCycleId)}
onClose={() => setExpandedCycleId("")}
size="lg"
>
<Modal.Header>
<Modal.Heading>{t("configSync.tasksTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">
{expandedCycleId.slice(0, 8)}
{(() => {
const c = cycles.find((x) => x.id === expandedCycleId);
return c
? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}`
: "";
})()}
</p>
<div className="filter-inline">
<Input
value={taskKeyword}
placeholder={t("configSync.taskKeywordPh")}
onChange={(e) => {
setTaskKeyword(e.target.value);
setTaskPage(1);
}}
/>
<FieldSelect
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>
</FieldSelect>
<Button
size="sm"
variant="secondary"
isDisabled={exportingTasks || taskTotal === 0}
onPress={() => void exportTasksCsv()}
>
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
</Button>
</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>
);
}

View file

@ -1,3 +1,4 @@
import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react";
import { useState, type FormEvent } from "react";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n";
@ -44,72 +45,79 @@ export function ForceChangePasswordPage() {
return (
<LoginShell>
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
<div className="login-card__head">
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
<Card className="login-card login-card--heroui" variant="transparent">
<Card.Header className="login-card__head">
<Card.Title className="login-card__title">{t("auth.forceChangeTitle")}</Card.Title>
<div className="login-card__brand" aria-label="NETX">
NETX
</div>
</div>
<p className="login-card__hint">
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
</p>
<label className="login-card__label">
<span className="login-card__sr">{t("auth.oldPassword")}</span>
<input
type="password"
autoComplete="current-password"
autoFocus
placeholder={t("auth.oldPassword")}
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
disabled={busy}
required
/>
</label>
<label className="login-card__label">
<span className="login-card__sr">{t("auth.newPassword")}</span>
<input
type="password"
autoComplete="new-password"
placeholder={t("auth.newPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={busy}
required
minLength={8}
/>
</label>
<label className="login-card__label">
<span className="login-card__sr">{t("auth.confirmPassword")}</span>
<input
type="password"
autoComplete="new-password"
placeholder={t("auth.confirmPassword")}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
disabled={busy}
required
minLength={8}
/>
</label>
{error ? (
<div className="login-card__error" role="alert">
{error}
</div>
) : null}
<button type="submit" className="login-card__submit" disabled={busy}>
{busy ? t("auth.savingPassword") : t("auth.savePassword")}
</button>
<button
type="button"
className="login-card__submit login-card__submit--ghost"
disabled={busy}
onClick={() => void logout()}
>
{t("auth.logout")}
</button>
</form>
</Card.Header>
<Card.Content>
<p className="login-card__hint">
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
</p>
<Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
<TextField
fullWidth
type="password"
autoComplete="current-password"
autoFocus
value={oldPassword}
onChange={setOldPassword}
isDisabled={busy}
isRequired
>
<Label className="login-card__sr">{t("auth.oldPassword")}</Label>
<Input placeholder={t("auth.oldPassword")} />
</TextField>
<TextField
fullWidth
type="password"
autoComplete="new-password"
value={newPassword}
onChange={setNewPassword}
isDisabled={busy}
isRequired
minLength={8}
>
<Label className="login-card__sr">{t("auth.newPassword")}</Label>
<Input placeholder={t("auth.newPassword")} />
</TextField>
<TextField
fullWidth
type="password"
autoComplete="new-password"
value={confirm}
onChange={setConfirm}
isDisabled={busy}
isRequired
minLength={8}
>
<Label className="login-card__sr">{t("auth.confirmPassword")}</Label>
<Input placeholder={t("auth.confirmPassword")} />
</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 isDisabled={busy}>
{busy ? t("auth.savingPassword") : t("auth.savePassword")}
</Button>
<Button
type="button"
variant="tertiary"
fullWidth
isDisabled={busy}
onPress={() => void logout()}
>
{t("auth.logout")}
</Button>
</Form>
</Card.Content>
</Card>
</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 { Navigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
@ -34,75 +35,96 @@ export function LoginPage() {
return (
<LoginShell>
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
<div className="login-card__head">
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
<Card className="login-card login-card--heroui" variant="transparent">
<Card.Header className="login-card__head">
<Card.Title className="login-card__title">{t("auth.loginTitle")}</Card.Title>
<div className="login-card__brand" aria-label="NETX">
NETX
</div>
</div>
<label className="login-card__label">
<span className="login-card__sr">{t("auth.username")}</span>
<input
autoFocus
autoComplete="username"
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={busy || !ready}
/>
</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)}
</Card.Header>
<Card.Content>
<Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
<TextField
fullWidth
name="username"
value={username}
onChange={setUsername}
isDisabled={busy || !ready}
autoFocus
autoComplete="username"
>
{showPassword ? (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
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"
strokeWidth="1.75"
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>
</span>
</label>
{error ? (
<div className="login-card__error" role="alert">
{error}
</div>
) : null}
<button type="submit" className="login-card__submit" disabled={busy || !ready || !password}>
{busy ? t("auth.loggingIn") : t("auth.login")}
</button>
</form>
<Label className="login-card__sr">{t("auth.username")}</Label>
<Input placeholder={t("auth.username")} />
</TextField>
<TextField
fullWidth
name="password"
value={password}
onChange={setPassword}
isDisabled={busy || !ready}
autoComplete="current-password"
>
<Label className="login-card__sr">{t("auth.password")}</Label>
<InputGroup fullWidth>
<InputGroup.Input
type={showPassword ? "text" : "password"}
placeholder={t("auth.password")}
/>
<InputGroup.Suffix>
<Button
isIconOnly
size="sm"
variant="ghost"
aria-label={showPassword ? "Hide password" : "Show password"}
onPress={() => setShowPassword((v) => !v)}
>
{showPassword ? (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
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"
strokeWidth="1.75"
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>
);
}

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

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
@ -15,6 +16,37 @@ type SessionRow = {
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() {
const { t } = useI18n();
const { showOk, showError } = useToast();
@ -54,16 +86,22 @@ export function SessionsPage() {
<div className="panel">
<div className="filter-inline" style={{ marginBottom: 12 }}>
<button
type="button"
disabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
onClick={() => revokeOthersMut.mutate()}
<Button
size="sm"
variant="secondary"
isDisabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
onPress={() => revokeOthersMut.mutate()}
>
{t("auth.revokeOtherSessions")}
</button>
<button type="button" onClick={() => void sessionsQuery.refetch()} disabled={sessionsQuery.isFetching}>
</Button>
<Button
size="sm"
variant="secondary"
onPress={() => void sessionsQuery.refetch()}
isDisabled={sessionsQuery.isFetching}
>
{t("common.refresh")}
</button>
</Button>
</div>
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -76,6 +114,7 @@ export function SessionsPage() {
<tr>
<th>{t("auth.colSession")}</th>
<th>{t("auth.colIp")}</th>
<th>{t("auth.colClient")}</th>
<th>{t("auth.colLastSeen")}</th>
<th>{t("auth.colCreated")}</th>
<th>{t("auth.actions")}</th>
@ -85,27 +124,31 @@ export function SessionsPage() {
{items.map((row) => (
<tr key={row.id}>
<td>
<code title={row.user_agent}>{row.id.slice(0, 10)}…</code>
<code title={row.id}>{row.id.slice(0, 10)}…</code>
{row.current ? (
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
{t("auth.sessionCurrent")}
</span>
) : null}
</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.created_at ? formatSystemTime(row.created_at) : "—"}</td>
<td>
<button
type="button"
disabled={revokeMut.isPending}
onClick={() => {
<Button
size="sm"
variant="secondary"
isDisabled={revokeMut.isPending}
onPress={() => {
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
revokeMut.mutate(row.id);
}}
>
{t("auth.revokeSession")}
</button>
</Button>
</td>
</tr>
))}

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import {
DISCOVER_AUTO_ADD_KEY,
DISCOVER_PROJECT_NEIGHBORS_KEY,
@ -189,7 +190,6 @@ export function TopologyPage() {
const searchHitTimerRef = useRef<number | null>(null);
const findBoxRef = useRef<HTMLDivElement | null>(null);
const displayMenuRef = useRef<HTMLDetailsElement | null>(null);
const exportMenuRef = useRef<HTMLDetailsElement | null>(null);
const [exporting, setExporting] = useState(false);
const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState<HTMLDivElement | null>(null);
const findJustLocatedRef = useRef(false);
@ -1780,7 +1780,6 @@ export function TopologyPage() {
return;
}
setExporting(true);
exportMenuRef.current?.removeAttribute("open");
const filenameBase = activeLeafName || view.name || "topology";
try {
if (format === "svg") {
@ -2027,7 +2026,6 @@ export function TopologyPage() {
savePending={saveMut.isPending}
onSave={() => saveMut.mutate()}
onFit={fitCanvas}
exportMenuRef={exportMenuRef}
exporting={exporting}
onExport={runTopologyExport}
mapId={mapId}
@ -2304,34 +2302,34 @@ export function TopologyPage() {
<div className="topo-canvas__empty">
<p className="muted">{t("topology.treeLoadFailed")}</p>
<div className="topo-browser__empty-actions">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => void treeQuery.refetch()}
<Button
size="sm"
variant="ghost"
onPress={() => void treeQuery.refetch()}
>
{t("topology.treeRetry")}
</button>
<button
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
disabled={createRegionMut.isPending}
</Button>
<Button
size="sm"
variant="secondary"
onPress={promptNewRegion}
isDisabled={createRegionMut.isPending}
>
{t("topology.newRegion")}
</button>
</Button>
</div>
</div>
) : (
<div className="topo-canvas__empty">
<p className="muted">{t("topology.emptyMaps")}</p>
<button
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
disabled={createRegionMut.isPending}
<Button
size="sm"
variant="secondary"
onPress={promptNewRegion}
isDisabled={createRegionMut.isPending}
>
{t("topology.newRegion")}
</button>
</Button>
</div>
)}
{treeRoot && canvasGraphError ? (
@ -2341,13 +2339,13 @@ export function TopologyPage() {
<p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p>
) : null}
<div className="topo-browser__empty-actions">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => void graphQuery.refetch()}
<Button
size="sm"
variant="ghost"
onPress={() => void graphQuery.refetch()}
>
{t("topology.treeRetry")}
</button>
</Button>
</div>
</div>
) : null}
@ -2374,16 +2372,16 @@ export function TopologyPage() {
</p>
{worldNeedsApply ? (
<div className="btn-row" style={{ marginTop: 10, justifyContent: "center" }}>
<button
type="button"
className="btn btn--sm"
disabled={applyWorldMut.isPending}
onClick={() => applyWorldMut.mutate()}
<Button
size="sm"
variant="secondary"
isDisabled={applyWorldMut.isPending}
onPress={() => applyWorldMut.mutate()}
>
{applyWorldMut.isPending
? t("topology.worldApplying")
: t("topology.worldApply")}
</button>
</Button>
</div>
) : null}
</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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { FieldSelect } from "../components/ui/FieldSelect";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { apiGet, apiPatch, apiPost } from "../services/api";
@ -71,13 +73,13 @@ export function UsersPage() {
<div className="pt-list">
<form className="filter-inline" onSubmit={onCreate}>
<input
<Input
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<input
<Input
type="password"
placeholder={t("auth.password")}
value={password}
@ -85,13 +87,13 @@ export function UsersPage() {
required
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="admin">{t("auth.roleAdmin")}</option>
</select>
<button type="submit" disabled={createMut.isPending}>
</FieldSelect>
<Button type="submit" size="sm" variant="primary" isDisabled={createMut.isPending}>
{t("auth.addUser")}
</button>
</Button>
</form>
{usersQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -127,15 +129,16 @@ export function UsersPage() {
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<button
type="button"
onClick={() =>
<Button
size="sm"
variant="secondary"
onPress={() =>
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
}
>
{u.is_active ? t("auth.disable") : t("auth.enable")}
</button>
<select
</Button>
<FieldSelect
value={u.role}
onChange={(e) =>
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="admin">{t("auth.roleAdmin")}</option>
</select>
<input
</FieldSelect>
<Input
type="password"
placeholder={t("auth.newPassword")}
value={resetPwd[u.id] || ""}
@ -153,17 +156,18 @@ export function UsersPage() {
}
style={{ width: 140, minWidth: 140, flex: "0 0 auto" }}
/>
<button
type="button"
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 8}
onClick={() => {
<Button
size="sm"
variant="primary"
isDisabled={!resetPwd[u.id] || resetPwd[u.id].length < 8}
onPress={() => {
const pwd = resetPwd[u.id];
patchMut.mutate({ id: u.id, body: { password: pwd } });
setResetPwd((m) => ({ ...m, [u.id]: "" }));
}}
>
{t("auth.resetPassword")}
</button>
</Button>
</div>
</td>
</tr>

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
@ -87,9 +88,14 @@ export function TaskOverviewPage() {
<section className="panel">
<div className="panel__toolbar">
<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")}
</button>
</Button>
</div>
<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 { connectTestManagedNe } from "../../services/api";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { useI18n } from "../../i18n";
import { formatSystemTime } from "../../utils/time";
import type { ManagedNeItem } from "../../types";
@ -12,6 +14,13 @@ export type ManagedNeConnectDetailDialogProps = {
onRetestSubmitted?: (rowId: string) => void;
};
function chipColor(level: string): "success" | "danger" | "warning" | "default" {
if (level === "up" || level === "pass") return "success";
if (level === "down" || level === "fail") return "danger";
if (level === "testing") return "warning";
return "default";
}
export function ManagedNeConnectDetailDialog({
row,
onClose,
@ -25,47 +34,61 @@ export function ManagedNeConnectDetailDialog({
},
});
if (!row) return null;
const open = Boolean(row);
const level = row ? connectPillLevel(row.connect_status) : "unknown";
return (
<div className="modal-backdrop" role="presentation" onClick={onClose}>
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("managedNe.connectDetailTitle")}</h3>
<p className="form-hint">
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
{row.connect_tested_at
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
: ""}
</p>
<p>
<span className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`}>
{row.connect_status}
</span>
{row.connect_message ? (
<span className="connect-detail-summary"> — {row.connect_message}</span>
) : null}
</p>
<pre className="connect-log">
{row.connect_detail?.trim() || row.connect_message?.trim() || t("managedNe.connectDetailEmpty")}
</pre>
<div className="modal__actions">
<button
type="button"
disabled={connectMutation.isPending}
onClick={() => connectMutation.mutate([row.id])}
>
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
</button>
<button type="button" onClick={onClose}>
{t("managedNe.form.cancel")}
</button>
</div>
{connectMutation.isError ? (
<p className="form-hint" role="alert">
{String(connectMutation.error)}
</p>
<AppModalShell open={open} onClose={onClose} size="lg">
<Modal.Header>
<Modal.Heading>{t("managedNe.connectDetailTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{row ? (
<>
<p className="text-sm text-muted">
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
{row.connect_tested_at
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
: ""}
</p>
<div className="flex flex-wrap items-center gap-2">
<Chip size="sm" color={chipColor(level)} variant="soft">
<Chip.Label>{row.connect_status}</Chip.Label>
</Chip>
{row.connect_message ? (
<span className="connect-detail-summary text-sm">— {row.connect_message}</span>
) : null}
</div>
<pre className="connect-log">
{row.connect_detail?.trim() ||
row.connect_message?.trim() ||
t("managedNe.connectDetailEmpty")}
</pre>
</>
) : null}
</div>
</div>
{connectMutation.isError ? (
<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 { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../services/api";
import { HopProxyFields } from "../../components/HopProxyFields";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { patchHopVendorChange } from "../../utils/hopProxy";
@ -14,20 +17,6 @@ import {
type ManagedNeFormState,
} from "./formState";
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
export type ManagedNeFormDialogProps = {
open: boolean;
/** null = create mode */
@ -55,8 +44,6 @@ export function ManagedNeFormDialog({
enabled: open,
});
// Reset only when the dialog opens or the edited row changes — avoid clobbering
// in-progress edits if the parent re-renders with a new initialValues object identity.
const editingId = editing?.id ?? "";
useEffect(() => {
if (!open) return;
@ -94,142 +81,148 @@ export function ManagedNeFormDialog({
},
});
if (!open) return null;
return (
<div
className="modal-backdrop"
role="presentation"
onClick={() => {
if (saveMutation.isPending) return;
onClose();
}}
>
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}</h3>
<p className="form-hint">{t("managedNe.form.requiredHint")}</p>
<AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">
<Modal.Header>
<Modal.Heading>
{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}
</Modal.Heading>
<Modal.CloseTrigger isDisabled={saveMutation.isPending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("managedNe.form.requiredHint")}</p>
<div className="form-grid">
<label>
<FormLabel>{t("managedNe.col.name")}</FormLabel>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<TextField
fullWidth
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>
</label>
<label>
<FormLabel required>{t("managedNe.col.vendor")}</FormLabel>
<select
required
value={form.vendor}
onChange={(e) => {
const vendor = e.target.value;
setForm((prev) => {
const next = { ...prev, vendor };
const dt = String(prev.device_type || "").trim().toLowerCase();
if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
if (vendor === "ZTE") next.device_type = "zte_zxros";
else if (vendor === "Huawei") next.device_type = "huawei";
else if (vendor === "Cisco") next.device_type = "cisco_ios";
else if (vendor === "Juniper") next.device_type = "juniper_junos";
else if (vendor === "Nokia") next.device_type = "nokia_sros";
}
return next;
});
}}
>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.deviceType")}</FormLabel>
<select
required
value={form.device_type}
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.ip")}</FormLabel>
<input
required
value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.port")}</FormLabel>
<input
type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 22 })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.protocol")}</FormLabel>
<select value={form.protocol} onChange={(e) => setForm({ ...form, protocol: e.target.value })}>
<option value="ssh">ssh</option>
<option value="telnet">telnet</option>
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.user")}</FormLabel>
<input
required
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</label>
<label>
<FormLabel>
</TextField>
<FieldSelect
label={t("managedNe.col.vendor")}
required
value={form.vendor}
onChange={(e) => {
const vendor = e.target.value;
setForm((prev) => {
const next = { ...prev, vendor };
const dt = String(prev.device_type || "").trim().toLowerCase();
if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
if (vendor === "ZTE") next.device_type = "zte_zxros";
else if (vendor === "Huawei") next.device_type = "huawei";
else if (vendor === "Cisco") next.device_type = "cisco_ios";
else if (vendor === "Juniper") next.device_type = "juniper_junos";
else if (vendor === "Nokia") next.device_type = "nokia_sros";
}
return next;
});
}}
>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</FieldSelect>
<FieldSelect
label={t("managedNe.col.deviceType")}
required
value={form.device_type}
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</FieldSelect>
<TextField
fullWidth
isRequired
value={form.ip_address}
onChange={(ip_address) => setForm({ ...form, ip_address })}
>
<Label>{t("managedNe.col.ip")}</Label>
<Input />
</TextField>
<TextField
fullWidth
type="number"
value={String(form.port)}
onChange={(port) => setForm({ ...form, port: Number(port) || 22 })}
>
<Label>{t("managedNe.col.port")}</Label>
<Input />
</TextField>
<FieldSelect
label={t("managedNe.col.protocol")}
value={form.protocol}
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
>
<option value="ssh">ssh</option>
<option value="telnet">telnet</option>
</FieldSelect>
<TextField
fullWidth
isRequired
value={form.username}
onChange={(username) => setForm({ ...form, username })}
>
<Label>{t("managedNe.col.user")}</Label>
<Input />
</TextField>
<TextField
fullWidth
type="password"
value={form.password}
onChange={(password) => setForm({ ...form, password })}
>
<Label>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
</FormLabel>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.tags")}</FormLabel>
<input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
</label>
<label className="form-grid__full">
<FormLabel>{t("managedNe.col.remark")}</FormLabel>
<input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} />
</label>
</Label>
<Input />
</TextField>
<TextField fullWidth value={form.tags} onChange={(tags) => setForm({ ...form, tags })}>
<Label>{t("managedNe.col.tags")}</Label>
<Input />
</TextField>
<TextField
fullWidth
className="form-grid__full"
value={form.remark}
onChange={(remark) => setForm({ ...form, remark })}
>
<Label>{t("managedNe.col.remark")}</Label>
<Input />
</TextField>
</div>
<fieldset className="form-fieldset form-grid__full">
<legend>{t("managedNe.hop.sectionTitle")}</legend>
<label className="form-check">
<input
type="checkbox"
checked={form.hop_enabled}
onChange={(e) => {
const hop_enabled = e.target.checked;
setForm((prev) => ({
...prev,
hop_enabled,
...(hop_enabled
? {
...patchHopVendorChange(prev.hop_vendor, prev),
...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true),
}
: {}),
}));
}}
/>
<span className="form-check__text">{t("managedNe.hop.enable")}</span>
</label>
<Checkbox
isSelected={form.hop_enabled}
onChange={(hop_enabled) => {
setForm((prev) => ({
...prev,
hop_enabled,
...(hop_enabled
? {
...patchHopVendorChange(prev.hop_vendor, prev),
...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true),
}
: {}),
}));
}}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
</Checkbox>
{form.hop_enabled ? (
<HopProxyFields
value={{
@ -250,24 +243,26 @@ export function ManagedNeFormDialog({
/>
) : null}
</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 ? (
<p className="form-hint" role="alert">
{String(saveMutation.error)}
</p>
<Alert status="danger">
<Alert.Content>
<Alert.Description>{String(saveMutation.error)}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</div>
</div>
</Modal.Body>
<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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
import { HelpHint } from "../../components/HelpHint";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
@ -32,7 +35,7 @@ import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 20;
const TARGET_PAGE_SIZE = 10;
const SEP = " · ";
function lldpJobStatusLabel(t: (k: string) => string, status: string): string {
@ -99,14 +102,14 @@ export function LldpLinksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [jobPage, setJobPage] = useState(1);
const [jobPageSize, setJobPageSize] = useState(20);
const [jobPageSize, setJobPageSize] = useState(10);
const [jobStatus, setJobStatus] = useState("");
const [jobKeyword, setJobKeyword] = useState("");
const [exportingJobs, setExportingJobs] = useState(false);
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
const [expandedJobId, setExpandedJobId] = useState("");
const [itemPage, setItemPage] = useState(1);
const [itemPageSize, setItemPageSize] = useState(20);
const [itemPageSize, setItemPageSize] = useState(10);
const [itemStatus, setItemStatus] = useState("");
const [itemKeyword, setItemKeyword] = useState("");
const [exportingItems, setExportingItems] = useState(false);
@ -117,7 +120,7 @@ export function LldpLinksPage() {
const [edgeSource, setEdgeSource] = useState("");
const [edgeKeyword, setEdgeKeyword] = useState("");
const [edgePage, setEdgePage] = useState(1);
const [edgePageSize, setEdgePageSize] = useState(50);
const [edgePageSize, setEdgePageSize] = useState(10);
const [exportingEdges, setExportingEdges] = useState(false);
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
@ -554,45 +557,62 @@ export function LldpLinksPage() {
<div className="panel__toolbar">
<h2>{t("lldpLinks.title")}</h2>
<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")}
</button>
<button
type="button"
className="btn-primary"
disabled={Boolean(running) || startMut.isPending}
onClick={() => startMut.mutate("full")}
</Button>
<Button
size="sm"
variant="primary"
isDisabled={Boolean(running) || startMut.isPending}
onPress={() => startMut.mutate("full")}
>
{t("lldpLinks.collectNow")}
</button>
<button
type="button"
disabled={Boolean(running) || startMut.isPending || !(last && (last.fail_count || 0) > 0)}
onClick={() => startMut.mutate("retry_failed")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={Boolean(running) || startMut.isPending || !(last && (last.fail_count || 0) > 0)}
onPress={() => startMut.mutate("retry_failed")}
>
{t("lldpLinks.retryFailed")}
</button>
</Button>
{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")}
</button>
</Button>
) : null}
{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")}
</button>
</Button>
) : null}
{running &&
(running.status === "running" || running.status === "paused" || running.status === "pending") ? (
<button
type="button"
onClick={() => {
<Button
size="sm"
variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(running.id);
}}
disabled={stopMut.isPending}
>
{t("lldpLinks.stop")}
</button>
</Button>
) : null}
</div>
</div>
@ -654,11 +674,11 @@ export function LldpLinksPage() {
</label>
<label className="config-sync-policy-field">
<span>{t("lldpLinks.interval")}</span>
<input
<Input
type="number"
min={1}
max={intervalUnit === "days" ? 365 : 8760}
value={intervalValue}
value={String(intervalValue)}
onChange={(e) => {
const max = intervalUnit === "days" ? 365 : 8760;
setIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
@ -711,14 +731,13 @@ export function LldpLinksPage() {
<option value="selected">{t("lldpLinks.scopeSelected")}</option>
</select>
</label>
<button
type="button"
className="btn-primary"
disabled={savePolicyMut.isPending}
onClick={() => savePolicyMut.mutate()}
<Button
variant="primary"
isDisabled={savePolicyMut.isPending}
onPress={() => savePolicyMut.mutate()}
>
{t("lldpLinks.savePolicy")}
</button>
</Button>
</div>
{scopeMode === "selected" ? (
@ -767,9 +786,14 @@ export function LldpLinksPage() {
</tbody>
</table>
<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")}
</button>
</Button>
<span className="muted">
{t("common.pagerMeta", {
total: String(targetsQuery.data?.total ?? 0),
@ -777,13 +801,14 @@ export function LldpLinksPage() {
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
})}
</span>
<button
type="button"
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onClick={() => setTargetPage((p) => p + 1)}
<Button
size="sm"
variant="ghost"
isDisabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onPress={() => setTargetPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</Button>
</div>
</div>
) : null}
@ -791,7 +816,7 @@ export function LldpLinksPage() {
<h3>{t("lldpLinks.edgesTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
<FieldSelect
value={edgeStatus}
onChange={(e) => {
const v = e.target.value;
@ -802,8 +827,8 @@ export function LldpLinksPage() {
<option value="all">{t("lldpLinks.edgeStatusAll")}</option>
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
</select>
<select
</FieldSelect>
<FieldSelect
value={edgeSource}
onChange={(e) => {
setEdgeSource(e.target.value);
@ -813,8 +838,8 @@ export function LldpLinksPage() {
<option value="">{t("lldpLinks.edgeSourceAll")}</option>
<option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option>
<option value="manual">{t("lldpLinks.edgeSource.manual")}</option>
</select>
<input
</FieldSelect>
<Input
value={edgeKeyword}
placeholder={t("lldpLinks.edgeKeywordPh")}
onChange={(e) => {
@ -822,13 +847,14 @@ export function LldpLinksPage() {
setEdgePage(1);
}}
/>
<button
type="button"
disabled={exportingEdges || edgeTotal === 0}
onClick={() => void exportEdgesCsv()}
<Button
size="sm"
variant="secondary"
isDisabled={exportingEdges || edgeTotal === 0}
onPress={() => void exportEdgesCsv()}
>
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
</button>
</Button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
@ -893,7 +919,7 @@ export function LldpLinksPage() {
pages={edgePages}
total={edgeTotal}
pageSize={edgePageSize}
pageSizeOptions={[20, 50, 100, 200]}
pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setEdgePage}
onPageSizeChange={(size) => {
setEdgePageSize(size);
@ -904,7 +930,7 @@ export function LldpLinksPage() {
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
<FieldSelect
value={jobStatus}
onChange={(e) => {
setJobStatus(e.target.value);
@ -919,8 +945,8 @@ export function LldpLinksPage() {
<option value="failed">{t("lldpLinks.jobStatus.failed")}</option>
<option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option>
<option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option>
</select>
<input
</FieldSelect>
<Input
value={jobKeyword}
placeholder={t("lldpLinks.jobKeywordPh")}
onChange={(e) => {
@ -928,13 +954,14 @@ export function LldpLinksPage() {
setJobPage(1);
}}
/>
<button
type="button"
disabled={exportingJobs || jobTotal === 0}
onClick={() => void exportJobsCsv()}
<Button
size="sm"
variant="secondary"
isDisabled={exportingJobs || jobTotal === 0}
onPress={() => void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
</button>
</Button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
@ -958,10 +985,10 @@ export function LldpLinksPage() {
return (
<tr key={job.id}>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
<Button
size="sm"
variant="ghost"
onPress={() => {
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
@ -970,7 +997,7 @@ export function LldpLinksPage() {
}}
>
{t("lldpLinks.expand")}
</button>
</Button>
</td>
<td className="mono">{job.id.slice(0, 8)}</td>
<td>{lldpTriggerLabel(t, job.trigger_mode)}</td>
@ -1005,33 +1032,36 @@ export function LldpLinksPage() {
<td>
<div className="btn-row">
{job.status === "running" || job.status === "pending" ? (
<button
type="button"
onClick={() => pauseMut.mutate(job.id)}
disabled={pauseMut.isPending}
<Button
size="sm"
variant="secondary"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(job.id)}
>
{t("lldpLinks.pause")}
</button>
</Button>
) : null}
{job.status === "paused" ? (
<button
type="button"
onClick={() => resumeMut.mutate(job.id)}
disabled={resumeMut.isPending}
<Button
size="sm"
variant="secondary"
isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(job.id)}
>
{t("lldpLinks.resume")}
</button>
</Button>
) : null}
{job.status === "running" || job.status === "paused" || job.status === "pending" ? (
<button
type="button"
onClick={() => {
<Button
size="sm"
variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(job.id);
}}
disabled={stopMut.isPending}
>
{t("lldpLinks.stop")}
</button>
</Button>
) : null}
</div>
</td>
@ -1062,265 +1092,238 @@ export function LldpLinksPage() {
disabled={jobsQuery.isLoading}
/>
{expandedJobId ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => {
setExpandedJobId("");
setItemDetail(null);
}}
>
<div
className="modal modal--wide ops-detail-modal"
role="dialog"
aria-modal="true"
aria-label={t("lldpLinks.jobDetailTitle")}
onClick={(e) => e.stopPropagation()}
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
<p className="muted">
{(() => {
const job = jobDetailQuery.data;
if (job) {
return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`;
}
if (jobDetailQuery.isLoading) {
return `${expandedJobId.slice(0, 8)} · …`;
}
return expandedJobId.slice(0, 8);
})()}
</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>
<AppModalShell
open={Boolean(expandedJobId)}
onClose={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}}
size="lg"
>
<Modal.Header>
<Modal.Heading>{t("lldpLinks.jobDetailTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">
{(() => {
const job = jobDetailQuery.data;
if (job) {
return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`;
}
if (jobDetailQuery.isLoading) {
return `${expandedJobId.slice(0, 8)} · …`;
}
return expandedJobId.slice(0, 8);
})()}
</p>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
value={itemStatus}
onChange={(e) => {
setItemStatus(e.target.value);
setItemPage(1);
}}
>
<option value="">{t("lldpLinks.itemResultAll")}</option>
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
</select>
<input
value={itemKeyword}
placeholder={t("lldpLinks.itemKeywordPh")}
onChange={(e) => {
setItemKeyword(e.target.value);
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 className="filter-inline">
<FieldSelect
value={itemStatus}
onChange={(e) => {
setItemStatus(e.target.value);
setItemPage(1);
}}
>
<option value="">{t("lldpLinks.itemResultAll")}</option>
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
</FieldSelect>
<Input
value={itemKeyword}
placeholder={t("lldpLinks.itemKeywordPh")}
onChange={(e) => {
setItemKeyword(e.target.value);
setItemPage(1);
}}
/>
</div>
</div>
) : null}
{itemDetail ? (
<div className="modal-backdrop" role="presentation" onClick={() => setItemDetail(null)}>
<div
className="modal modal--wide topo-discover-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lldp-item-detail-title"
onClick={(e) => e.stopPropagation()}
>
<div className="topo-discover-modal__head">
<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>
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobDetailQuery.data?.error ? (
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
) : null}
{!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}`}>
<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>
{(u.remote_name || u.remote_ip || "?").trim()}
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
+{it.edges_added} / ~{it.edges_updated}
{unmatchedCount > 0
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
: ""}
</td>
<td className="muted">{it.error || "—"}</td>
<td>
<Button size="sm" variant="tertiary" onPress={() => setItemDetail(it)}>
{t("topology.discoverViewDetail")}
</Button>
</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}
<div className="modal__actions">
<button type="button" className="btn btn--sm" onClick={() => setItemDetail(null)}>
{t("topology.discoverClose")}
</button>
);
})}
{!detailItems.length && !jobDetailQuery.isLoading ? (
<tr>
<td colSpan={7} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</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>
);
}

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createPortTrafficBoard,
@ -52,90 +53,91 @@ export function PortTrafficBoardListPage() {
<div className="panel__toolbar">
<h2>{t("portTraffic.wallTitle")}</h2>
<div className="btn-row">
<button
type="button"
className="btn-primary"
disabled={createMut.isPending}
onClick={() => createMut.mutate()}
<Button
size="sm"
variant="primary"
isDisabled={createMut.isPending}
onPress={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</button>
</Button>
</div>
</div>
<div className="pt-list">
{boardsQuery.isLoading ? (
<p className="muted">…</p>
) : boardsQuery.isError ? (
<p className="error">{(boardsQuery.error as Error)?.message || t("common.opFailed")}</p>
) : !boards.length ? (
<div className="pt-list-empty">
<p>{t("portTraffic.boardEmpty")}</p>
<button
type="button"
className="btn-primary"
disabled={createMut.isPending}
onClick={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</button>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("portTraffic.boardName")}</th>
<th>{t("portTraffic.boardPanelCount")}</th>
<th>{t("portTraffic.boardCols")}</th>
<th>{t("portTraffic.boardUpdated")}</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>
{boardsQuery.isLoading ? (
<p className="muted">…</p>
) : boardsQuery.isError ? (
<p className="error">{(boardsQuery.error as Error)?.message || t("common.opFailed")}</p>
) : !boards.length ? (
<div className="pt-list-empty">
<p>{t("portTraffic.boardEmpty")}</p>
<Button
size="sm"
variant="primary"
isDisabled={createMut.isPending}
onPress={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</Button>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("portTraffic.boardName")}</th>
<th>{t("portTraffic.boardPanelCount")}</th>
<th>{t("portTraffic.boardCols")}</th>
<th>{t("portTraffic.boardUpdated")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
))}
</tbody>
</table>
</div>
)}
</thead>
<tbody>
{boards.map((b) => (
<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>
</section>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import { Button, Input } from "@heroui/react";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
import { useI18n } from "../../i18n";
import { WORLD_MAP_ENABLED } from "./constants";
@ -71,24 +72,24 @@ export function TopologySidebar({
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
{collapsed ? (
<div className="topo-sidebar__rail-wrap">
<button
type="button"
<Button
isIconOnly
variant="ghost"
className="topo-sidebar__rail"
title={t("topology.expandSidebar")}
aria-label={t("topology.expandSidebar")}
onClick={onExpand}
onPress={onExpand}
>
<span className="topo-sidebar__rail-icon" aria-hidden="true">
<SidebarFoldIcon expand />
</span>
</button>
</Button>
</div>
) : (
<>
<div className="topo-sidebar__section">
<div className="topo-tree-search" ref={treeSearchRef}>
<div className="topo-tree-search__bar">
<input
<Input
className="input"
type="search"
value={treeNeQuery}
@ -100,25 +101,27 @@ export function TopologySidebar({
}}
onFocus={() => onTreeSearchOpen(true)}
/>
<button
type="button"
<Button
isIconOnly
size="sm"
variant="ghost"
className="topo-sidebar__icon-btn"
onClick={promptNewRegion}
disabled={createRegionPending}
title={t("topology.newRegion")}
onPress={promptNewRegion}
isDisabled={createRegionPending}
aria-label={t("topology.newRegion")}
>
<PlusIcon />
</button>
<button
type="button"
</Button>
<Button
isIconOnly
size="sm"
variant="ghost"
className="topo-sidebar__icon-btn"
title={t("topology.collapseSidebar")}
aria-label={t("topology.collapseSidebar")}
onClick={onCollapse}
onPress={onCollapse}
>
<SidebarFoldIcon />
</button>
</Button>
</div>
{treeSearchOpen && treeNeQuery.trim() ? (
<div className="topo-tree-search__panel" role="listbox">
@ -206,9 +209,9 @@ export function TopologySidebar({
<>
{t("topology.treeLoadFailed")}
{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")}
</button>
</Button>
</>
) : (
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 { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types";
import { HelpHint } from "../../components/HelpHint";
@ -31,7 +32,6 @@ export type TopologyToolbarProps = {
savePending: boolean;
onSave: () => void;
onFit: () => void;
exportMenuRef: RefObject<HTMLDetailsElement | null>;
exporting: boolean;
onExport: (kind: "svg" | "xml") => void;
mapId: string;
@ -51,6 +51,16 @@ export type TopologyToolbarProps = {
) => TopologyTreeViewItem | null | undefined;
};
type MoreActionKey =
| "live-sync"
| "back"
| "undo"
| "redo"
| "fit"
| "export-svg"
| "export-xml"
| "remove-stale";
export function TopologyToolbar({
readOnly = false,
breadcrumbFolders,
@ -75,7 +85,6 @@ export function TopologyToolbar({
savePending,
onSave,
onFit,
exportMenuRef,
exporting,
onExport,
mapId,
@ -94,6 +103,27 @@ export function TopologyToolbar({
}: TopologyToolbarProps) {
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 = () => {
let flowX = 80 + nodes.length * 24;
let flowY = 80 + nodes.length * 24;
@ -112,14 +142,51 @@ export function TopologyToolbar({
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 (
<div className="topo-toolbar">
<div className="topo-toolbar__row">
<div className="topo-toolbar__title">
<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")}
</button>
</Button>
{breadcrumbFolders.map((folder, idx) => {
const isLast = idx === breadcrumbFolders.length - 1;
const primaryView = primaryViewOfFolder(folder);
@ -140,13 +207,15 @@ export function TopologyToolbar({
{dirty ? " *" : ""}
</span>
) : (
<button
<Button
type="button"
variant="ghost"
size="sm"
className="topo-breadcrumb__link"
onClick={() => goRegion(folder.id)}
onPress={() => goRegion(folder.id)}
>
{regionDisplayName(folder, t)}
</button>
</Button>
)}
</Fragment>
);
@ -175,116 +244,81 @@ export function TopologyToolbar({
) : null}
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
</div>
<div className="topo-toolbar__actions">
<button
<div className="topo-toolbar__actions topo-toolbar__primary">
<Button
type="button"
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
aria-pressed={liveSync}
title={t("topology.liveSyncHint")}
onClick={onLiveSyncToggle}
>
{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}
size="sm"
variant="secondary"
isDisabled={readOnly || isWorldFlatCanvas}
onPress={onAddNe}
>
{t("topology.addNe")}
</button>
<button
</Button>
<Button
type="button"
className="btn btn--sm"
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={handleCreateNe}
size="sm"
variant="secondary"
isDisabled={readOnly || isWorldFlatCanvas}
onPress={handleCreateNe}
>
{t("topology.createNe")}
</button>
<button type="button" className="btn btn--sm btn--ghost" onClick={onBackUp}>
{t("topology.backUp")}
</button>
<button
</Button>
<Button
type="button"
className="btn btn--sm btn--ghost"
disabled={readOnly || !canUndo}
onClick={onUndo}
title="Ctrl+Z"
>
{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"}
size="sm"
className={dirty ? "topo-toolbar__save topo-toolbar__save--dirty" : "topo-toolbar__save"}
variant={dirty ? "primary" : "ghost"}
isDisabled={readOnly || savePending || !dirty}
onPress={onSave}
>
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!isWorldFlatCanvas && nodes.length === 0}
onClick={onFit}
>
{t("topology.fit")}
</button>
<details className="topo-toolbar__export" ref={exportMenuRef}>
<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")}
</Button>
<Dropdown>
<Button type="button" size="sm" variant="ghost" aria-label={t("topology.more")}>
{t("topology.more")}
</Button>
<Dropdown.Popover className="topo-toolbar__more-popover" placement="bottom end">
<Dropdown.Menu
aria-label={t("topology.more")}
disabledKeys={moreDisabledKeys}
onAction={handleMoreAction}
>
{t("topology.exportSvg")}
</button>
<button
type="button"
className="topo-export-menu__btn"
role="menuitem"
disabled={exporting || !mapId}
onClick={() => void onExport("xml")}
>
{t("topology.exportXml")}
</button>
</div>
</details>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={readOnly || !staleEdgeCount}
title={readOnly ? t("topology.readOnlyHint") : t("topology.removeStaleHint")}
onClick={() => void onRemoveStale()}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
</button>
<Dropdown.Item id="live-sync" textValue={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</Dropdown.Item>
<Dropdown.Item id="back" textValue={t("topology.backUp")}>
{t("topology.backUp")}
</Dropdown.Item>
<Dropdown.Item id="undo" textValue={t("topology.undo")}>
{t("topology.undo")}
</Dropdown.Item>
<Dropdown.Item id="redo" textValue={t("topology.redo")}>
{t("topology.redo")}
</Dropdown.Item>
<Dropdown.Item id="fit" textValue={t("topology.fit")}>
{t("topology.fit")}
</Dropdown.Item>
<Dropdown.Item
id="export-svg"
textValue={exporting ? t("topology.exporting") : t("topology.exportSvg")}
>
{exporting ? t("topology.exporting") : t("topology.exportSvg")}
</Dropdown.Item>
<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 className="topo-toolbar__row topo-toolbar__row--tools">
@ -295,23 +329,27 @@ export function TopologyToolbar({
["pan", t("topology.toolPan"), "H"],
["connect", t("topology.toolConnect"), "C"],
] as const
).map(([mode, label, key]) => (
<button
key={mode}
type="button"
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
title={`${label} (${key})${readOnly && mode === "connect" ? ` — ${t("topology.readOnlyHint")}` : ""}`}
aria-pressed={toolMode === mode}
disabled={readOnly && mode === "connect"}
onClick={() => {
onToolModeChange(mode);
onConnectClickReset();
}}
>
<span className="topo-tools__label">{label}</span>
<kbd className="topo-tools__key">{key}</kbd>
</button>
))}
).map(([mode, label, key]) => {
const active = toolMode === mode;
return (
<Button
key={mode}
type="button"
className={`topo-tools__btn${active ? " is-active" : ""}`}
variant={active ? "primary" : "ghost"}
size="sm"
aria-pressed={active}
isDisabled={readOnly && mode === "connect"}
onPress={() => {
onToolModeChange(mode);
onConnectClickReset();
}}
>
<span className="topo-tools__label">{label}</span>
<kbd className="topo-tools__key">{key}</kbd>
</Button>
);
})}
</div>
{!fullscreen ? (
<div
@ -322,14 +360,15 @@ export function TopologyToolbar({
/>
) : null}
{outsidePeerCount > 0 ? (
<button
<Button
type="button"
className="btn btn--sm btn--ghost topo-toolbar__outside-peers"
title={t("topology.outsidePeers").replace("{{count}}", String(outsidePeerCount))}
onClick={onOpenOutsidePeers}
size="sm"
variant="ghost"
className="topo-toolbar__outside-peers"
onPress={onOpenOutsidePeers}
>
{t("topology.outsidePeersView").replace("{{count}}", String(outsidePeerCount))}
</button>
</Button>
) : null}
</div>
</div>

View file

@ -1,3 +1,4 @@
import { Button, Input } from "@heroui/react";
import { useQueryClient } from "@tanstack/react-query";
import type { RefObject } from "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__head">
<strong>{t("topology.canvasBg")}</strong>
<button
<Button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
size="sm"
variant="ghost"
onPress={() => {
onCanvasBgChange(DEFAULT_CANVAS_BG);
persistCanvasBg(DEFAULT_CANVAS_BG);
}}
>
{t("topology.canvasBgReset")}
</button>
</Button>
</div>
<div className="topo-display-defaults__row">
<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__head">
<strong>{t("topology.textColors")}</strong>
<button
<Button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
size="sm"
variant="ghost"
onPress={() => {
onLabelColorsChange({ ...DEFAULT_LABEL_COLORS });
persistLabelColors({ ...DEFAULT_LABEL_COLORS });
}}
>
{t("topology.textColorsReset")}
</button>
</Button>
</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__head">
<strong>{t("topology.vendorColors")}</strong>
<button
<Button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
size="sm"
variant="ghost"
onPress={() => {
onVendorColorsChange({ ...DEFAULT_VENDOR_COLORS });
persistVendorColors({ ...DEFAULT_VENDOR_COLORS });
}}
>
{t("topology.vendorColorsReset")}
</button>
</Button>
</div>
{VENDOR_TONE_KEYS.map((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__head">
<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")}
</button>
</Button>
</div>
{(
[
@ -528,7 +532,7 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
aria-label={t("topology.findNode")}
ref={findBoxRef}
>
<input
<Input
className="topo-toolbar__find"
value={canvasQuery}
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>
) : (
canvasHits.slice(0, 12).map((n, idx) => (
<button
<Button
key={n.id}
type="button"
role="option"
aria-selected={idx === findActiveIdx}
variant="ghost"
size="sm"
className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
onMouseEnter={() => onFindActiveIdxChange(idx)}
onClick={() => onFindOnCanvas(n.id)}
onHoverStart={() => onFindActiveIdxChange(idx)}
onPress={() => onFindOnCanvas(n.id)}
>
<span className="topo-find-suggest__name">{n.data.label || n.id}</span>
<span className="topo-find-suggest__meta">
{[n.data.ne_ip, n.data.vendor].filter(Boolean).join(SEP)}
</span>
</button>
</Button>
))
)}
{canvasHits.length > 12 ? (

View file

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

View file

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

View file

@ -1,3 +1,5 @@
import { Button, Modal } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import { useI18n } from "../../../i18n";
export type CreateNeModeDialogProps = {
@ -15,34 +17,34 @@ export function CreateNeModeDialog({
onPickPlaceholder,
}: CreateNeModeDialogProps) {
const { t } = useI18n();
if (!open) return null;
return (
<div className="topo-modal" role="dialog" aria-modal="true" aria-labelledby="topo-create-ne-mode-title">
<div className="topo-modal__backdrop" onClick={onClose} />
<div className="topo-modal__panel" style={{ maxWidth: 440 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-mode-title">{t("topology.createNe")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}>
{t("topology.discoverClose")}
</button>
<TopoModalShell open={open} onClose={onClose} size="sm">
<Modal.Header>
<Modal.Heading>{t("topology.createNe")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.createNeModeHint")}</p>
<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>
<p className="panel__hint topo-modal__hint">{t("topology.createNeModeHint")}</p>
<div className="topo-create-ne-mode" style={{ padding: "0 16px 16px", display: "grid", gap: 10 }}>
<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>
</Modal.Body>
</TopoModalShell>
);
}

View file

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

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

View file

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

View file

@ -1,7 +1,7 @@
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
export const TOPO_NODE_W = 80;
export const TOPO_NODE_H = 25;
export const TOPO_ICON = 25;
export const TOPO_NODE_W = 88;
export const TOPO_NODE_H = 28;
export const TOPO_ICON = 28;
/** 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_HANDLE_X = TOPO_NODE_W / 2;

View file

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