Unify NetX module pages on a classic slate chrome UI.

Align NE, UME, system, topology, and WebCRT with shared list styling, and set the browser tab title to NetX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 17:47:08 +08:00
parent a0bef49e1e
commit fce6e2cc9e
11 changed files with 1439 additions and 743 deletions

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetX · UME</title>
<title>NetX</title>
</head>
<body>
<div id="root"></div>

View file

@ -31,7 +31,7 @@ export function HeaderMenu() {
<div className="header-menu header-menu--on-brand" ref={rootRef}>
<button
type="button"
className="header-menu__trigger header-menu__trigger--on-brand"
className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--icon"
aria-label={t("layout.moreMenu")}
aria-haspopup="menu"
aria-expanded={open}

File diff suppressed because it is too large Load diff

View file

@ -77,32 +77,42 @@ export function AppLayout({ connections, children }: Props) {
) : null}
</div>
<div className="app-brand__actions">
<span className={`conn-pill conn-pill--on-brand conn-pill--${connections.netxApi}`}>
{t("layout.netxApi")}: {connections.netxApi}
{netxSuffix}
</span>
<span
className={`conn-pill conn-pill--on-brand conn-pill--${connections.oclawBridge}`}
title={oclawTitle}
>
{t("layout.oclawBridge")}: {connections.oclawBridge}
{oclawSuffix}
</span>
{user ? (
<span className="conn-pill conn-pill--on-brand conn-pill--up" title={user.role}>
{user.username}
<div className="app-brand__actions-group">
<span className={`conn-pill conn-pill--on-brand conn-pill--${connections.netxApi}`}>
{t("layout.netxApi")}: {connections.netxApi}
{netxSuffix}
</span>
) : null}
{user ? (
<button
type="button"
className="header-menu__trigger header-menu__trigger--on-brand"
onClick={() => void logout()}
<span
className={`conn-pill conn-pill--on-brand conn-pill--${connections.oclawBridge}`}
title={oclawTitle}
>
{t("auth.logout")}
</button>
) : null}
<HeaderMenu />
{t("layout.oclawBridge")}: {connections.oclawBridge}
{oclawSuffix}
</span>
</div>
{user ? (
<>
<span className="app-brand__actions-sep" aria-hidden />
<div className="app-brand__actions-group">
<span
className="conn-pill conn-pill--on-brand conn-pill--user"
title={user.role}
>
{user.username}
</span>
<button
type="button"
className="header-menu__trigger header-menu__trigger--on-brand"
onClick={() => void logout()}
>
{t("auth.logout")}
</button>
<HeaderMenu />
</div>
</>
) : (
<HeaderMenu />
)}
</div>
</header>

View file

@ -4,6 +4,7 @@ import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { apiDelete, apiGet, apiPost } from "../services/api";
import { formatSystemTime } from "../utils/time";
type TokenRow = {
id: string;
@ -33,6 +34,12 @@ const EXPIRY_OPTIONS = [
{ value: 0, labelKey: "auth.expireNever" },
] as const;
function tokenStatusClass(row: TokenRow): string {
if (row.revoked) return "pt-list-status--failed";
if (row.expired) return "pt-list-status--warning";
return "pt-list-status--ok";
}
export function ApiTokensPage() {
const { t } = useI18n();
const { ready, user, isAdmin } = useAuth();
@ -98,102 +105,130 @@ export function ApiTokensPage() {
};
return (
<div className="panel">
<h2 className="panel__title">{t("auth.apiKeysTitle")}</h2>
<p className="panel__hint">{t("auth.apiKeysHint")}</p>
<div className="page-stack system-page">
<section className="panel">
<div className="panel__toolbar">
<h2>{t("auth.apiKeysTitle")}</h2>
</div>
<p className="panel__hint">{t("auth.apiKeysHint")}</p>
<form className="form-row" onSubmit={onCreate} style={{ gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<input
placeholder={t("auth.tokenName")}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<select
value={expiresInDays}
onChange={(e) => setExpiresInDays(Number(e.target.value))}
aria-label={t("auth.expiresIn")}
>
{EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
{isAdmin ? (
<select
value={ownerUserId}
onChange={(e) => setOwnerUserId(e.target.value)}
aria-label={t("auth.tokenOwner")}
>
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
{users
.filter((u) => u.is_active)
.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.role})
<div className="pt-list">
<form className="filter-inline" onSubmit={onCreate}>
<input
placeholder={t("auth.tokenName")}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<select
value={expiresInDays}
onChange={(e) => setExpiresInDays(Number(e.target.value))}
aria-label={t("auth.expiresIn")}
>
{EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
) : null}
<button type="submit" disabled={createMut.isPending}>
{t("auth.createToken")}
</button>
</form>
</select>
{isAdmin ? (
<select
value={ownerUserId}
onChange={(e) => setOwnerUserId(e.target.value)}
aria-label={t("auth.tokenOwner")}
>
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
{users
.filter((u) => u.is_active)
.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.role})
</option>
))}
</select>
) : null}
<button type="submit" disabled={createMut.isPending}>
{t("auth.createToken")}
</button>
</form>
{createdPlain ? (
<div className="panel" style={{ marginBottom: 16, background: "#f8fafc" }}>
<div className="panel__hint">{t("auth.tokenOnceHint")}</div>
<code style={{ wordBreak: "break-all", display: "block", margin: "8px 0" }}>{createdPlain}</code>
<button type="button" onClick={() => void copyToken()}>
{t("auth.copyToken")}
</button>
{createdPlain ? (
<div className="token-once">
<div className="panel__hint" style={{ margin: 0 }}>
{t("auth.tokenOnceHint")}
</div>
<code>{createdPlain}</code>
<button type="button" onClick={() => void copyToken()}>
{t("auth.copyToken")}
</button>
</div>
) : null}
{tokensQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !tokensQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("auth.tokenName")}</th>
<th>{t("auth.tokenOwner")}</th>
<th>{t("auth.colTime")}</th>
<th>{t("auth.expiresAt")}</th>
<th>{t("auth.lastUsed")}</th>
<th>{t("auth.status")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id}>
<td className="pt-list-task-name">{row.name}</td>
<td>{row.username || row.user_id}</td>
<td className="pt-list-time">
{row.created_at ? formatSystemTime(row.created_at) : t("common.empty")}
</td>
<td className="pt-list-time">
{row.expires_at ? formatSystemTime(row.expires_at) : t("auth.expireNever")}
</td>
<td className="pt-list-time">
{row.last_used_at ? formatSystemTime(row.last_used_at) : t("common.empty")}
</td>
<td>
<span className={`pt-list-status ${tokenStatusClass(row)}`}>
{row.revoked
? t("auth.tokenStatusRevoked")
: row.expired
? t("auth.tokenStatusExpired")
: t("auth.tokenStatusActive")}
</span>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<button
type="button"
className="btn--danger"
disabled={row.revoked || revokeMut.isPending}
onClick={() => {
if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id);
}}
>
{t("auth.revokeToken")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
) : null}
{tokensQuery.isLoading ? <div>{t("common.refreshing")}</div> : null}
<table className="data-table">
<thead>
<tr>
<th>{t("auth.tokenName")}</th>
<th>{t("auth.tokenOwner")}</th>
<th>{t("auth.colTime")}</th>
<th>{t("auth.expiresAt")}</th>
<th>{t("auth.lastUsed")}</th>
<th>{t("auth.status")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.username || row.user_id}</td>
<td>{row.created_at || "-"}</td>
<td>{row.expires_at || t("auth.expireNever")}</td>
<td>{row.last_used_at || "-"}</td>
<td>
{row.revoked
? t("auth.tokenStatusRevoked")
: row.expired
? t("auth.tokenStatusExpired")
: t("auth.tokenStatusActive")}
</td>
<td>
<button
type="button"
disabled={row.revoked || revokeMut.isPending}
onClick={() => {
if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id);
}}
>
{t("auth.revokeToken")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
);
}

View file

@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n";
import { apiGet } from "../services/api";
import { formatSystemTime } from "../utils/time";
type AuditItem = {
id: string;
@ -16,6 +17,13 @@ type AuditItem = {
detail: Record<string, unknown>;
};
function statusClass(code: number): string {
if (code >= 500) return "pt-list-status--failed";
if (code >= 400) return "pt-list-status--warning";
if (code >= 200 && code < 300) return "pt-list-status--ok";
return "pt-list-status--other";
}
export function AuditPage() {
const { t } = useI18n();
const { ready, isAdmin } = useAuth();
@ -41,79 +49,123 @@ 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 hasFilters = Boolean(username.trim() || action.trim());
return (
<div className="panel">
<h2 className="panel__title">{t("auth.auditTitle")}</h2>
<p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
<div className="page-stack system-page">
<section className="panel">
<div className="panel__toolbar">
<h2>{t("auth.auditTitle")}</h2>
</div>
<p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
<div className="form-row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
{isAdmin ? (
<input
placeholder={t("auth.filterUsername")}
value={username}
onChange={(e) => {
setPage(1);
setUsername(e.target.value);
}}
/>
) : null}
<input
placeholder={t("auth.filterAction")}
value={action}
onChange={(e) => {
setPage(1);
setAction(e.target.value);
}}
/>
<button type="button" onClick={() => void query.refetch()}>
{t("common.refresh")}
</button>
</div>
<div className="pt-list">
<div className="filter-inline">
{isAdmin ? (
<input
placeholder={t("auth.filterUsername")}
value={username}
onChange={(e) => {
setPage(1);
setUsername(e.target.value);
}}
/>
) : null}
<input
placeholder={t("auth.filterAction")}
value={action}
onChange={(e) => {
setPage(1);
setAction(e.target.value);
}}
/>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}>
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
<button
type="button"
disabled={!hasFilters}
onClick={() => {
setUsername("");
setAction("");
setPage(1);
}}
>
{t("common.clearFilters")}
</button>
</div>
{query.isLoading ? <div>{t("common.refreshing")}</div> : null}
<table className="data-table">
<thead>
<tr>
<th>{t("auth.colTime")}</th>
<th>{t("auth.colUser")}</th>
<th>{t("auth.colAction")}</th>
<th>{t("auth.colMethod")}</th>
<th>{t("auth.colPath")}</th>
<th>{t("auth.colStatus")}</th>
<th>{t("auth.colIp")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id} title={JSON.stringify(row.detail || {})}>
<td>{row.ts || "-"}</td>
<td>{row.actor_username || "-"}</td>
<td>{row.action}</td>
<td>{row.method}</td>
<td style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}>{row.path}</td>
<td>{row.status_code}</td>
<td>{row.client_ip || "-"}</td>
</tr>
))}
</tbody>
</table>
{query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<div className="form-row" style={{ gap: 8, marginTop: 12 }}>
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
{t("common.prevPage")}
</button>
<span>
{t("common.pagerMeta", { total, page, pages })}
</span>
<button
type="button"
disabled={page >= pages}
onClick={() => setPage((p) => Math.min(pages, p + 1))}
>
{t("common.nextPage")}
</button>
</div>
{!items.length && !query.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("auth.colTime")}</th>
<th>{t("auth.colUser")}</th>
<th>{t("auth.colAction")}</th>
<th>{t("auth.colMethod")}</th>
<th>{t("auth.colPath")}</th>
<th>{t("auth.colStatus")}</th>
<th>{t("auth.colIp")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id} title={JSON.stringify(row.detail || {})}>
<td className="pt-list-time">
{row.ts ? formatSystemTime(row.ts) : t("common.empty")}
</td>
<td className="pt-list-task-name">{row.actor_username || t("common.empty")}</td>
<td>{row.action}</td>
<td className="pt-list-num">{row.method}</td>
<td
className="pt-list-num"
style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}
>
{row.path}
</td>
<td>
<span className={`pt-list-status ${statusClass(row.status_code)}`}>
{row.status_code}
</span>
</td>
<td className="pt-list-num">{row.client_ip || t("common.empty")}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="pager pt-list-pager">
<span className="muted">
{t("common.pagerMeta", {
total: String(total),
page: String(page),
pages: String(pages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={page <= 1} onClick={() => 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))}
>
{t("common.nextPage")}
</button>
</div>
</div>
</div>
</section>
</div>
);
}

View file

@ -111,11 +111,11 @@ function FormLabel({ children, required }: { children: ReactNode; required?: boo
);
}
function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" {
if (status === "pass") return "up";
if (status === "fail") return "down";
if (status === "testing") return "warn";
return "unknown";
function connectStatusClass(status: string): string {
if (status === "pass") return "pt-list-status--ok";
if (status === "fail") return "pt-list-status--failed";
if (status === "testing") return "pt-list-status--running";
return "pt-list-status--unknown";
}
export function NePage() {
@ -491,53 +491,55 @@ export function NePage() {
return items;
}, [stats, perTag, t]);
const listItems = listQuery.data?.items || [];
const hasFilters = Boolean(keyword || vendorFilter || statusFilter);
return (
<div className="page-stack">
<div className="page-stack ne-page">
{!credsOk ? (
<section className="panel panel--warn">
<p>{t("managedNe.credsNotConfigured")}</p>
</section>
) : null}
{/* ── statistics card ── */}
<section className="card ne-stats-card">
<div className="ne-stats-card__header">
<div className="ne-stats-card__header-left">
<span className="ne-stats-card__title">{t("managedNe.stats.title")}</span>
</div>
<div className="ne-stats-card__header-actions">
<button
type="button"
disabled={bulkByTagMutation.isPending}
onClick={() => {
setBulkTagAction("proxy");
setBulkHop(emptyHopProxyFields());
setBulkTagModalOpen(true);
}}
>
{t("managedNe.stats.batchProxy")}
</button>
<button
type="button"
disabled={bulkByTagMutation.isPending}
onClick={() => {
setBulkTagAction("account");
setBulkAccount(emptyAccount());
setBulkTagModalOpen(true);
}}
>
{t("managedNe.account.batchByTag")}
</button>
<button
type="button"
disabled={bulkByTagMutation.isPending}
onClick={() => {
setBulkTagAction("test");
setBulkTagModalOpen(true);
}}
>
{t("managedNe.stats.batchTest")}
</button>
<section className="panel ne-stats-panel">
<div className="panel__toolbar">
<h2>{t("managedNe.stats.title")}</h2>
<div className="panel__toolbar-end">
<div className="panel__actions">
<button
type="button"
disabled={bulkByTagMutation.isPending}
onClick={() => {
setBulkTagAction("proxy");
setBulkHop(emptyHopProxyFields());
setBulkTagModalOpen(true);
}}
>
{t("managedNe.stats.batchProxy")}
</button>
<button
type="button"
disabled={bulkByTagMutation.isPending}
onClick={() => {
setBulkTagAction("account");
setBulkAccount(emptyAccount());
setBulkTagModalOpen(true);
}}
>
{t("managedNe.account.batchByTag")}
</button>
<button
type="button"
disabled={bulkByTagMutation.isPending}
onClick={() => {
setBulkTagAction("test");
setBulkTagModalOpen(true);
}}
>
{t("managedNe.stats.batchTest")}
</button>
</div>
</div>
</div>
{stats ? (
@ -548,7 +550,7 @@ export function NePage() {
<div className="ne-tag-card__total">{t("managedNe.stats.total", { n: x.total })}</div>
<div className="ne-tag-card__pills">
{(["pass", "fail", "testing", "unknown"] as const).map((s) => (
<span key={s} className={`ne-stats-pill ne-stats-pill--${s}`}>
<span key={s} className={`pt-list-status ${connectStatusClass(s)}`}>
{t(`managedNe.stats.${s}`)} {x.by_status[s] ?? 0}
</span>
))}
@ -671,170 +673,205 @@ export function NePage() {
</div>
</div>
<div className="filter-inline">
<input
value={keyword}
placeholder={t("managedNe.keywordPh")}
onChange={(e) => setKeyword(e.target.value)}
/>
<select value={vendorFilter} onChange={(e) => setVendorFilter(e.target.value)}>
<option value="">{t("managedNe.allVendors")}</option>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">{t("managedNe.allConnectStatus")}</option>
<option value="unknown">unknown</option>
<option value="testing">testing</option>
<option value="pass">pass</option>
<option value="fail">fail</option>
</select>
<button type="button" onClick={() => setPage(1)}>
{t("common.query")}
</button>
<button
type="button"
onClick={() => {
setKeyword("");
setVendorFilter("");
setStatusFilter("");
setPage(1);
}}
>
{t("common.clearFilters")}
</button>
</div>
<table>
<thead>
<tr>
<th>
<input type="checkbox" checked={allSelected} onChange={toggleSelectAll} aria-label="select all" />
</th>
<th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.vendor")}</th>
<th>{t("managedNe.col.deviceType")}</th>
<th>{t("managedNe.col.tags")}</th>
<th>{t("managedNe.col.ip")}</th>
<th>{t("managedNe.col.user")}</th>
<th>{t("managedNe.col.connect")}</th>
<th>{t("managedNe.col.testedAt")}</th>
<th>{t("managedNe.col.actions")}</th>
</tr>
</thead>
<tbody>
{(listQuery.data?.items || []).map((row) => (
<tr key={row.id}>
<td>
<input
type="checkbox"
checked={selected.includes(row.id)}
onChange={() => toggleSelect(row.id)}
/>
</td>
<td>{row.name || row.ip_address}</td>
<td>{row.vendor}</td>
<td>{row.device_type}</td>
<td>
{row.tags
? row.tags.split(/\s+/).map((tag) => (
<span key={tag} className="table-tag">
{tag}
</span>
))
: t("common.empty")}
</td>
<td>
{row.ip_address}:{row.port}/{row.protocol}
{row.hop_enabled ? (
<span
className="table-tag"
title={`${row.hop_host}:${row.hop_port} (${row.hop_vendor})`}
>
{t(
`managedNe.hop.badge.${["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor) ? row.hop_vendor : "zte"}`,
)}
</span>
) : null}
</td>
<td>{row.username}</td>
<td>
<span
className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`}
title={row.connect_message || undefined}
>
{row.connect_status}
</span>
</td>
<td>
{row.connect_tested_at
? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })
: t("common.empty")}
</td>
<td className="table-actions">
<button
type="button"
className="link-btn"
onClick={() =>
openOrFocusModule({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`,
})
}
title={t("managedNe.openTerminal")}
>
{t("managedNe.openTerminal")}
</button>
<button
type="button"
className="link-btn"
onClick={() => setConnectDetailRow(row)}
disabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail}
>
{t("managedNe.connectDetail")}
</button>
<button type="button" className="link-btn" onClick={() => openEdit(row)}>
{t("managedNe.edit")}
</button>
<button
type="button"
className="link-btn link-btn--danger"
onClick={() => {
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
}}
>
{t("managedNe.delete")}
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="pager">
<div className="pager__meta">{t("common.pagerMeta", { total, page, pages })}</div>
<div className="pager__controls">
<button className="pager__btn" onClick={() => setPage(Math.max(1, page - 1))} disabled={page <= 1}>
{t("common.prevPage")}
</button>
<button className="pager__btn" onClick={() => setPage(page + 1)} disabled={page >= pages}>
{t("common.nextPage")}
</button>
<select
className="pager__size"
value={String(pageSize)}
<div className="pt-list">
<div className="filter-inline">
<input
value={keyword}
placeholder={t("managedNe.keywordPh")}
onChange={(e) => {
setPageSize(Number(e.target.value) || 50);
setKeyword(e.target.value);
setPage(1);
}}
/>
<select
value={vendorFilter}
onChange={(e) => {
setVendorFilter(e.target.value);
setPage(1);
}}
>
<option value="20">{perPage(20)}</option>
<option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option>
<option value="">{t("managedNe.allVendors")}</option>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
>
<option value="">{t("managedNe.allConnectStatus")}</option>
<option value="unknown">unknown</option>
<option value="testing">testing</option>
<option value="pass">pass</option>
<option value="fail">fail</option>
</select>
<button
type="button"
disabled={!hasFilters}
onClick={() => {
setKeyword("");
setVendorFilter("");
setStatusFilter("");
setPage(1);
}}
>
{t("common.clearFilters")}
</button>
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!listItems.length && !listQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>
<input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
aria-label="select all"
/>
</th>
<th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.vendor")}</th>
<th>{t("managedNe.col.deviceType")}</th>
<th>{t("managedNe.col.tags")}</th>
<th>{t("managedNe.col.ip")}</th>
<th>{t("managedNe.col.user")}</th>
<th>{t("managedNe.col.connect")}</th>
<th>{t("managedNe.col.testedAt")}</th>
<th>{t("managedNe.col.actions")}</th>
</tr>
</thead>
<tbody>
{listItems.map((row) => (
<tr key={row.id}>
<td>
<input
type="checkbox"
checked={selected.includes(row.id)}
onChange={() => toggleSelect(row.id)}
/>
</td>
<td className="pt-list-task-name">{row.name || row.ip_address}</td>
<td>{row.vendor}</td>
<td>{row.device_type}</td>
<td>
{row.tags
? row.tags.split(/\s+/).map((tag) => (
<span key={tag} className="table-tag">
{tag}
</span>
))
: t("common.empty")}
</td>
<td className="pt-list-num">
{row.ip_address}:{row.port}/{row.protocol}
{row.hop_enabled ? (
<span
className="table-tag"
title={`${row.hop_host}:${row.hop_port} (${row.hop_vendor})`}
>
{t(
`managedNe.hop.badge.${["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor) ? row.hop_vendor : "zte"}`,
)}
</span>
) : null}
</td>
<td>{row.username}</td>
<td>
<span
className={`pt-list-status ${connectStatusClass(row.connect_status)}`}
title={row.connect_message || undefined}
>
{row.connect_status}
</span>
</td>
<td className="pt-list-time">
{row.connect_tested_at
? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })
: t("common.empty")}
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<button
type="button"
onClick={() =>
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}
>
{t("managedNe.connectDetail")}
</button>
<button type="button" onClick={() => openEdit(row)}>
{t("managedNe.edit")}
</button>
<button
type="button"
className="btn--danger"
onClick={() => {
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
}}
>
{t("managedNe.delete")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="pager pt-list-pager">
<span className="muted">
{t("common.pagerMeta", {
total: String(total),
page: String(page),
pages: String(pages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={page <= 1} onClick={() => setPage(Math.max(1, page - 1))}>
{t("common.prevPage")}
</button>
<button type="button" disabled={page >= pages} onClick={() => setPage(page + 1)}>
{t("common.nextPage")}
</button>
<select
value={String(pageSize)}
onChange={(e) => {
setPageSize(Number(e.target.value) || 50);
setPage(1);
}}
>
<option value="20">{perPage(20)}</option>
<option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option>
</select>
</div>
</div>
</div>
</section>

View file

@ -430,10 +430,12 @@ export function UmePage() {
return (
<>
<div className="page-stack">
<section className="cards">
<article className="card card--full">
<h3>{t("ume.token.title")}</h3>
<div className="page-stack ume-page">
<section className="cards ume-cards">
<article className="panel">
<div className="panel__toolbar">
<h2>{t("ume.token.title")}</h2>
</div>
<div className="actions-row actions-row--inline">
<span
className={`conn-pill conn-pill--${!hasToken ? "down" : tokenNeedsRenewal ? "warn" : "up"}`}
@ -510,17 +512,25 @@ export function UmePage() {
)}
</article>
<article className="card card--full">
<h3>{t("ume.tasks.currentTitle")}</h3>
<div className="actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
{t("ume.tasks.running")}: {runningTasks.length}
</span>
<button onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
{t("common.refresh")}
</button>
<article className="panel">
<div className="panel__toolbar">
<h2>{t("ume.tasks.currentTitle")}</h2>
<div className="panel__toolbar-end">
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
{t("ume.tasks.running")}: {runningTasks.length}
</span>
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
disabled={syncStatusQuery.isFetching}
>
{t("common.refresh")}
</button>
</div>
</div>
<table>
<div className="pt-list">
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>ID</th>
@ -549,6 +559,7 @@ export function UmePage() {
)}
</tbody>
</table>
</div>
<h3 className="card__section-title">{t("ume.tasks.runtimeTitle")}</h3>
{runtimeTaskError ? (
@ -556,7 +567,8 @@ export function UmePage() {
{t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError}
</div>
) : null}
<table>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>task</th>
@ -607,13 +619,17 @@ export function UmePage() {
)}
</tbody>
</table>
</div>
</div>
</article>
<article className="card card--full">
<h3 className="card-title-with-hint">
{t("ume.subscription.title")}
<HelpHint text={t("ume.subscription.help")} ariaLabel={t("common.help")} />
</h3>
<article className="panel">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("ume.subscription.title")}
<HelpHint text={t("ume.subscription.help")} ariaLabel={t("common.help")} />
</h2>
</div>
<div className="actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${subscriptionActive ? "up" : "down"}`}>
{t("ume.subscription.sub")}: {subscriptionActive ? t("ume.subscription.subActive") : t("ume.subscription.subInactive")}
@ -757,12 +773,12 @@ export function UmePage() {
</div>
</article>
<article className="card card--full">
<div className="panel__toolbar" style={{ marginBottom: cliPanelOpen ? 12 : 0 }}>
<h3 className="card-title-with-hint">
<article className="panel">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("ume.cli.title")}
<HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} />
</h3>
</h2>
<button type="button" className="link-btn" onClick={() => setCliPanelOpen((x) => !x)}>
{cliPanelOpen ? t("ume.cli.hidePanel") : t("ume.cli.showPanel")}
</button>
@ -770,12 +786,12 @@ export function UmePage() {
{cliPanelOpen ? <UmeCliConnectPanel embedded enabled /> : null}
</article>
<article className="card card--full">
<div className="panel__toolbar" style={{ marginBottom: keyAlertPanelOpen ? 12 : 0 }}>
<h3 className="card-title-with-hint">
<article className="panel">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("ume.keyAlert.title")}
<HelpHint text={t("ume.keyAlert.help")} ariaLabel={t("common.help")} />
</h3>
</h2>
<button type="button" className="link-btn" onClick={() => setKeyAlertPanelOpen((x) => !x)}>
{keyAlertPanelOpen ? t("ume.keyAlert.hidePanel") : t("ume.keyAlert.showPanel")}
</button>
@ -974,7 +990,7 @@ export function UmePage() {
{t("common.opFailed")}: {keyAlertOpError}
</div>
) : null}
<div className="actions-row actions-row--inline" style={{ marginTop: 12, flexWrap: "wrap", gap: 8 }}>
<div className="filter-inline" style={{ marginTop: 12 }}>
<input
type="search"
placeholder={t("ume.keyAlert.filterKeywordPh")}
@ -1007,7 +1023,8 @@ export function UmePage() {
<option value="false">{t("ume.keyAlert.filterEnabledOff")}</option>
</select>
</div>
<table style={{ marginTop: 12 }}>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("ume.keyAlert.colMonitor")}</th>
@ -1093,11 +1110,12 @@ export function UmePage() {
) : null}
</tbody>
</table>
<div className="pager">
<div className="pager__meta">
</div>
<div className="pager pt-list-pager">
<div className="muted">
{t("common.pagerMeta", { total: keyAlertTotal, page: keyAlertPage, pages: keyAlertPages })}
</div>
<div className="pager__controls">
<div className="btn-row">
<button
className="pager__btn"
onClick={() => setKeyAlertPage(Math.max(1, keyAlertPage - 1))}
@ -1139,13 +1157,14 @@ export function UmePage() {
</button>
</div>
{syncStatusPanelOpen ? (
<>
<div className="pt-list">
<div className="actions-row actions-row--inline">
<button onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
<button type="button" onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
{t("common.refresh")}
</button>
</div>
<table>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>ID</th>
@ -1182,9 +1201,10 @@ export function UmePage() {
)}
</tbody>
</table>
<div className="pager">
<div className="pager__meta">{t("common.pagerMeta", { total: syncTotal, page: syncPage, pages: syncPages })}</div>
<div className="pager__controls">
</div>
<div className="pager pt-list-pager">
<div className="muted">{t("common.pagerMeta", { total: syncTotal, page: syncPage, pages: syncPages })}</div>
<div className="btn-row">
<button className="pager__btn" onClick={() => setSyncPage(Math.max(1, syncPage - 1))} disabled={syncPage <= 1}>
{t("common.prevPage")}
</button>
@ -1206,7 +1226,7 @@ export function UmePage() {
</select>
</div>
</div>
</>
</div>
) : null}
</section>
@ -1218,7 +1238,7 @@ export function UmePage() {
</button>
</div>
{nePanelOpen ? (
<>
<div className="pt-list">
<div className="filter-inline">
<input value={neKeyword} placeholder={t("ume.ne.keywordPh")} onChange={(e) => setNeKeyword(e.target.value)} />
<button type="button" onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll })}>
@ -1236,7 +1256,8 @@ export function UmePage() {
{t("common.clearFilters")}
</button>
</div>
<table>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>ne_id</th>
@ -1296,9 +1317,10 @@ export function UmePage() {
))}
</tbody>
</table>
<div className="pager">
<div className="pager__meta">{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}</div>
<div className="pager__controls">
</div>
<div className="pager pt-list-pager">
<div className="muted">{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}</div>
<div className="btn-row">
<button className="pager__btn" onClick={() => setNePage(Math.max(1, nePage - 1))} disabled={nePage <= 1}>
{t("common.prevPage")}
</button>
@ -1321,7 +1343,7 @@ export function UmePage() {
</select>
</div>
</div>
</>
</div>
) : null}
</section>

View file

@ -62,91 +62,118 @@ export function UsersPage() {
};
return (
<div className="panel">
<h2 className="panel__title">{t("auth.usersTitle")}</h2>
<p className="panel__hint">{t("auth.usersHint")}</p>
<div className="page-stack system-page">
<section className="panel">
<div className="panel__toolbar">
<h2>{t("auth.usersTitle")}</h2>
</div>
<p className="panel__hint">{t("auth.usersHint")}</p>
<form className="form-row" onSubmit={onCreate} style={{ gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<input
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<input
type="password"
placeholder={t("auth.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<select 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}>
{t("auth.addUser")}
</button>
</form>
<div className="pt-list">
<form className="filter-inline" onSubmit={onCreate}>
<input
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<input
type="password"
placeholder={t("auth.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<select 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}>
{t("auth.addUser")}
</button>
</form>
{usersQuery.isLoading ? <div>{t("common.refreshing")}</div> : null}
<table className="data-table">
<thead>
<tr>
<th>{t("auth.username")}</th>
<th>{t("auth.role")}</th>
<th>{t("auth.status")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((u) => (
<tr key={u.id}>
<td>{u.username}</td>
<td>{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}</td>
<td>{u.is_active ? t("auth.active") : t("auth.disabled")}</td>
<td>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
<button
type="button"
onClick={() =>
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
}
>
{u.is_active ? t("auth.disable") : t("auth.enable")}
</button>
<select
value={u.role}
onChange={(e) => patchMut.mutate({ id: u.id, body: { role: e.target.value } })}
>
<option value="user">{t("auth.roleUser")}</option>
<option value="admin">{t("auth.roleAdmin")}</option>
</select>
<input
type="password"
placeholder={t("auth.newPassword")}
value={resetPwd[u.id] || ""}
onChange={(e) => setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))}
style={{ width: 140 }}
/>
<button
type="button"
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 6}
onClick={() => {
const pwd = resetPwd[u.id];
patchMut.mutate({ id: u.id, body: { password: pwd } });
setResetPwd((m) => ({ ...m, [u.id]: "" }));
}}
>
{t("auth.resetPassword")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{usersQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !usersQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("auth.username")}</th>
<th>{t("auth.role")}</th>
<th>{t("auth.status")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((u) => (
<tr key={u.id}>
<td className="pt-list-task-name">{u.username}</td>
<td>{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}</td>
<td>
<span
className={`pt-list-status ${
u.is_active ? "pt-list-status--ok" : "pt-list-status--other"
}`}
>
{u.is_active ? t("auth.active") : t("auth.disabled")}
</span>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<button
type="button"
onClick={() =>
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
}
>
{u.is_active ? t("auth.disable") : t("auth.enable")}
</button>
<select
value={u.role}
onChange={(e) =>
patchMut.mutate({ id: u.id, body: { role: e.target.value } })
}
>
<option value="user">{t("auth.roleUser")}</option>
<option value="admin">{t("auth.roleAdmin")}</option>
</select>
<input
type="password"
placeholder={t("auth.newPassword")}
value={resetPwd[u.id] || ""}
onChange={(e) =>
setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))
}
style={{ width: 140, minWidth: 140, flex: "0 0 auto" }}
/>
<button
type="button"
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 6}
onClick={() => {
const pwd = resetPwd[u.id];
patchMut.mutate({ id: u.id, body: { password: pwd } });
setResetPwd((m) => ({ ...m, [u.id]: "" }));
}}
>
{t("auth.resetPassword")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
</div>
);
}

View file

@ -35,6 +35,14 @@ function deviceLabel(t: Pick<CliTargetItem, "name" | "ip_address">): string {
return String(t.name || t.ip_address || "").trim() || "-";
}
function ComputerIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden>
<path d="M4 3h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1h-5v2h2v2H7v-2h2v-2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v9h14V5H5z" />
</svg>
);
}
function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string, string | number>) => string): string {
const raw = String(err);
if (raw.includes("webcrt_session_limit")) return t("webcrt.err.sessionLimit");
@ -336,7 +344,7 @@ export function WebcrtPage() {
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
>
<span className="webcrt-tree__icon" aria-hidden>
▣
<ComputerIcon />
</span>
<span className="webcrt-tree__label">
<span className="webcrt-tree__name">{deviceLabel(row)}</span>

View file

@ -144,7 +144,7 @@ export function PortTrafficWallPage() {
useEffect(() => {
if (!board?.name) return;
try {
document.title = `${board.name} · NetX`;
document.title = "NetX";
} catch {
/* ignore */
}