mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
a0bef49e1e
commit
fce6e2cc9e
11 changed files with 1439 additions and 743 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ export function PortTrafficWallPage() {
|
|||
useEffect(() => {
|
||||
if (!board?.name) return;
|
||||
try {
|
||||
document.title = `${board.name} · NetX`;
|
||||
document.title = "NetX";
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue