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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -77,6 +77,7 @@ export function AppLayout({ connections, children }: Props) {
) : null} ) : null}
</div> </div>
<div className="app-brand__actions"> <div className="app-brand__actions">
<div className="app-brand__actions-group">
<span className={`conn-pill conn-pill--on-brand conn-pill--${connections.netxApi}`}> <span className={`conn-pill conn-pill--on-brand conn-pill--${connections.netxApi}`}>
{t("layout.netxApi")}: {connections.netxApi} {t("layout.netxApi")}: {connections.netxApi}
{netxSuffix} {netxSuffix}
@ -88,12 +89,17 @@ export function AppLayout({ connections, children }: Props) {
{t("layout.oclawBridge")}: {connections.oclawBridge} {t("layout.oclawBridge")}: {connections.oclawBridge}
{oclawSuffix} {oclawSuffix}
</span> </span>
</div>
{user ? ( {user ? (
<span className="conn-pill conn-pill--on-brand conn-pill--up" title={user.role}> <>
<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} {user.username}
</span> </span>
) : null}
{user ? (
<button <button
type="button" type="button"
className="header-menu__trigger header-menu__trigger--on-brand" className="header-menu__trigger header-menu__trigger--on-brand"
@ -101,9 +107,13 @@ export function AppLayout({ connections, children }: Props) {
> >
{t("auth.logout")} {t("auth.logout")}
</button> </button>
) : null}
<HeaderMenu /> <HeaderMenu />
</div> </div>
</>
) : (
<HeaderMenu />
)}
</div>
</header> </header>
<main className="app-main">{children}</main> <main className="app-main">{children}</main>

View file

@ -4,6 +4,7 @@ import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import { apiDelete, apiGet, apiPost } from "../services/api"; import { apiDelete, apiGet, apiPost } from "../services/api";
import { formatSystemTime } from "../utils/time";
type TokenRow = { type TokenRow = {
id: string; id: string;
@ -33,6 +34,12 @@ const EXPIRY_OPTIONS = [
{ value: 0, labelKey: "auth.expireNever" }, { value: 0, labelKey: "auth.expireNever" },
] as const; ] 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() { export function ApiTokensPage() {
const { t } = useI18n(); const { t } = useI18n();
const { ready, user, isAdmin } = useAuth(); const { ready, user, isAdmin } = useAuth();
@ -98,11 +105,15 @@ export function ApiTokensPage() {
}; };
return ( return (
<div className="panel"> <div className="page-stack system-page">
<h2 className="panel__title">{t("auth.apiKeysTitle")}</h2> <section className="panel">
<div className="panel__toolbar">
<h2>{t("auth.apiKeysTitle")}</h2>
</div>
<p className="panel__hint">{t("auth.apiKeysHint")}</p> <p className="panel__hint">{t("auth.apiKeysHint")}</p>
<form className="form-row" onSubmit={onCreate} style={{ gap: 8, flexWrap: "wrap", marginBottom: 16 }}> <div className="pt-list">
<form className="filter-inline" onSubmit={onCreate}>
<input <input
placeholder={t("auth.tokenName")} placeholder={t("auth.tokenName")}
value={name} value={name}
@ -142,17 +153,26 @@ export function ApiTokensPage() {
</form> </form>
{createdPlain ? ( {createdPlain ? (
<div className="panel" style={{ marginBottom: 16, background: "#f8fafc" }}> <div className="token-once">
<div className="panel__hint">{t("auth.tokenOnceHint")}</div> <div className="panel__hint" style={{ margin: 0 }}>
<code style={{ wordBreak: "break-all", display: "block", margin: "8px 0" }}>{createdPlain}</code> {t("auth.tokenOnceHint")}
</div>
<code>{createdPlain}</code>
<button type="button" onClick={() => void copyToken()}> <button type="button" onClick={() => void copyToken()}>
{t("auth.copyToken")} {t("auth.copyToken")}
</button> </button>
</div> </div>
) : null} ) : null}
{tokensQuery.isLoading ? <div>{t("common.refreshing")}</div> : null} {tokensQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<table className="data-table">
{!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> <thead>
<tr> <tr>
<th>{t("auth.tokenName")}</th> <th>{t("auth.tokenName")}</th>
@ -167,21 +187,31 @@ export function ApiTokensPage() {
<tbody> <tbody>
{items.map((row) => ( {items.map((row) => (
<tr key={row.id}> <tr key={row.id}>
<td>{row.name}</td> <td className="pt-list-task-name">{row.name}</td>
<td>{row.username || row.user_id}</td> <td>{row.username || row.user_id}</td>
<td>{row.created_at || "-"}</td> <td className="pt-list-time">
<td>{row.expires_at || t("auth.expireNever")}</td> {row.created_at ? formatSystemTime(row.created_at) : t("common.empty")}
<td>{row.last_used_at || "-"}</td> </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> <td>
<span className={`pt-list-status ${tokenStatusClass(row)}`}>
{row.revoked {row.revoked
? t("auth.tokenStatusRevoked") ? t("auth.tokenStatusRevoked")
: row.expired : row.expired
? t("auth.tokenStatusExpired") ? t("auth.tokenStatusExpired")
: t("auth.tokenStatusActive")} : t("auth.tokenStatusActive")}
</span>
</td> </td>
<td> <td>
<div className="btn-row pt-list-actions table-actions">
<button <button
type="button" type="button"
className="btn--danger"
disabled={row.revoked || revokeMut.isPending} disabled={row.revoked || revokeMut.isPending}
onClick={() => { onClick={() => {
if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id); if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id);
@ -189,11 +219,16 @@ export function ApiTokensPage() {
> >
{t("auth.revokeToken")} {t("auth.revokeToken")}
</button> </button>
</div>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div>
</section>
</div>
); );
} }

View file

@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { apiGet } from "../services/api"; import { apiGet } from "../services/api";
import { formatSystemTime } from "../utils/time";
type AuditItem = { type AuditItem = {
id: string; id: string;
@ -16,6 +17,13 @@ type AuditItem = {
detail: Record<string, unknown>; 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() { export function AuditPage() {
const { t } = useI18n(); const { t } = useI18n();
const { ready, isAdmin } = useAuth(); const { ready, isAdmin } = useAuth();
@ -41,13 +49,18 @@ export function AuditPage() {
const items = useMemo(() => query.data?.items || [], [query.data]); const items = useMemo(() => query.data?.items || [], [query.data]);
const total = query.data?.total || 0; const total = query.data?.total || 0;
const pages = Math.max(1, Math.ceil(total / 50)); const pages = Math.max(1, Math.ceil(total / 50));
const hasFilters = Boolean(username.trim() || action.trim());
return ( return (
<div className="panel"> <div className="page-stack system-page">
<h2 className="panel__title">{t("auth.auditTitle")}</h2> <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> <p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
<div className="form-row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 12 }}> <div className="pt-list">
<div className="filter-inline">
{isAdmin ? ( {isAdmin ? (
<input <input
placeholder={t("auth.filterUsername")} placeholder={t("auth.filterUsername")}
@ -66,13 +79,31 @@ export function AuditPage() {
setAction(e.target.value); setAction(e.target.value);
}} }}
/> />
<button type="button" onClick={() => void query.refetch()}> <button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}>
{t("common.refresh")} {query.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
<button
type="button"
disabled={!hasFilters}
onClick={() => {
setUsername("");
setAction("");
setPage(1);
}}
>
{t("common.clearFilters")}
</button> </button>
</div> </div>
{query.isLoading ? <div>{t("common.refreshing")}</div> : null} {query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<table className="data-table">
{!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> <thead>
<tr> <tr>
<th>{t("auth.colTime")}</th> <th>{t("auth.colTime")}</th>
@ -87,25 +118,43 @@ export function AuditPage() {
<tbody> <tbody>
{items.map((row) => ( {items.map((row) => (
<tr key={row.id} title={JSON.stringify(row.detail || {})}> <tr key={row.id} title={JSON.stringify(row.detail || {})}>
<td>{row.ts || "-"}</td> <td className="pt-list-time">
<td>{row.actor_username || "-"}</td> {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>{row.action}</td>
<td>{row.method}</td> <td className="pt-list-num">{row.method}</td>
<td style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}>{row.path}</td> <td
<td>{row.status_code}</td> className="pt-list-num"
<td>{row.client_ip || "-"}</td> 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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)}
<div className="form-row" style={{ gap: 8, marginTop: 12 }}> <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))}> <button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
{t("common.prevPage")} {t("common.prevPage")}
</button> </button>
<span>
{t("common.pagerMeta", { total, page, pages })}
</span>
<button <button
type="button" type="button"
disabled={page >= pages} disabled={page >= pages}
@ -115,5 +164,8 @@ export function AuditPage() {
</button> </button>
</div> </div>
</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" { function connectStatusClass(status: string): string {
if (status === "pass") return "up"; if (status === "pass") return "pt-list-status--ok";
if (status === "fail") return "down"; if (status === "fail") return "pt-list-status--failed";
if (status === "testing") return "warn"; if (status === "testing") return "pt-list-status--running";
return "unknown"; return "pt-list-status--unknown";
} }
export function NePage() { export function NePage() {
@ -491,21 +491,22 @@ export function NePage() {
return items; return items;
}, [stats, perTag, t]); }, [stats, perTag, t]);
const listItems = listQuery.data?.items || [];
const hasFilters = Boolean(keyword || vendorFilter || statusFilter);
return ( return (
<div className="page-stack"> <div className="page-stack ne-page">
{!credsOk ? ( {!credsOk ? (
<section className="panel panel--warn"> <section className="panel panel--warn">
<p>{t("managedNe.credsNotConfigured")}</p> <p>{t("managedNe.credsNotConfigured")}</p>
</section> </section>
) : null} ) : null}
{/* ── statistics card ── */} <section className="panel ne-stats-panel">
<section className="card ne-stats-card"> <div className="panel__toolbar">
<div className="ne-stats-card__header"> <h2>{t("managedNe.stats.title")}</h2>
<div className="ne-stats-card__header-left"> <div className="panel__toolbar-end">
<span className="ne-stats-card__title">{t("managedNe.stats.title")}</span> <div className="panel__actions">
</div>
<div className="ne-stats-card__header-actions">
<button <button
type="button" type="button"
disabled={bulkByTagMutation.isPending} disabled={bulkByTagMutation.isPending}
@ -540,6 +541,7 @@ export function NePage() {
</button> </button>
</div> </div>
</div> </div>
</div>
{stats ? ( {stats ? (
<div className="ne-stats-card__tag-cards"> <div className="ne-stats-card__tag-cards">
{tagCardItems.map((x) => ( {tagCardItems.map((x) => (
@ -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__total">{t("managedNe.stats.total", { n: x.total })}</div>
<div className="ne-tag-card__pills"> <div className="ne-tag-card__pills">
{(["pass", "fail", "testing", "unknown"] as const).map((s) => ( {(["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} {t(`managedNe.stats.${s}`)} {x.by_status[s] ?? 0}
</span> </span>
))} ))}
@ -671,13 +673,23 @@ export function NePage() {
</div> </div>
</div> </div>
<div className="pt-list">
<div className="filter-inline"> <div className="filter-inline">
<input <input
value={keyword} value={keyword}
placeholder={t("managedNe.keywordPh")} placeholder={t("managedNe.keywordPh")}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/> />
<select value={vendorFilter} onChange={(e) => setVendorFilter(e.target.value)}> <select
value={vendorFilter}
onChange={(e) => {
setVendorFilter(e.target.value);
setPage(1);
}}
>
<option value="">{t("managedNe.allVendors")}</option> <option value="">{t("managedNe.allVendors")}</option>
{vendors.map((v) => ( {vendors.map((v) => (
<option key={v} value={v}> <option key={v} value={v}>
@ -685,18 +697,22 @@ export function NePage() {
</option> </option>
))} ))}
</select> </select>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}> <select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
>
<option value="">{t("managedNe.allConnectStatus")}</option> <option value="">{t("managedNe.allConnectStatus")}</option>
<option value="unknown">unknown</option> <option value="unknown">unknown</option>
<option value="testing">testing</option> <option value="testing">testing</option>
<option value="pass">pass</option> <option value="pass">pass</option>
<option value="fail">fail</option> <option value="fail">fail</option>
</select> </select>
<button type="button" onClick={() => setPage(1)}>
{t("common.query")}
</button>
<button <button
type="button" type="button"
disabled={!hasFilters}
onClick={() => { onClick={() => {
setKeyword(""); setKeyword("");
setVendorFilter(""); setVendorFilter("");
@ -708,11 +724,24 @@ export function NePage() {
</button> </button>
</div> </div>
<table> {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> <thead>
<tr> <tr>
<th> <th>
<input type="checkbox" checked={allSelected} onChange={toggleSelectAll} aria-label="select all" /> <input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
aria-label="select all"
/>
</th> </th>
<th>{t("managedNe.col.name")}</th> <th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.vendor")}</th> <th>{t("managedNe.col.vendor")}</th>
@ -726,7 +755,7 @@ export function NePage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(listQuery.data?.items || []).map((row) => ( {listItems.map((row) => (
<tr key={row.id}> <tr key={row.id}>
<td> <td>
<input <input
@ -735,7 +764,7 @@ export function NePage() {
onChange={() => toggleSelect(row.id)} onChange={() => toggleSelect(row.id)}
/> />
</td> </td>
<td>{row.name || row.ip_address}</td> <td className="pt-list-task-name">{row.name || row.ip_address}</td>
<td>{row.vendor}</td> <td>{row.vendor}</td>
<td>{row.device_type}</td> <td>{row.device_type}</td>
<td> <td>
@ -747,7 +776,7 @@ export function NePage() {
)) ))
: t("common.empty")} : t("common.empty")}
</td> </td>
<td> <td className="pt-list-num">
{row.ip_address}:{row.port}/{row.protocol} {row.ip_address}:{row.port}/{row.protocol}
{row.hop_enabled ? ( {row.hop_enabled ? (
<span <span
@ -763,21 +792,21 @@ export function NePage() {
<td>{row.username}</td> <td>{row.username}</td>
<td> <td>
<span <span
className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`} className={`pt-list-status ${connectStatusClass(row.connect_status)}`}
title={row.connect_message || undefined} title={row.connect_message || undefined}
> >
{row.connect_status} {row.connect_status}
</span> </span>
</td> </td>
<td> <td className="pt-list-time">
{row.connect_tested_at {row.connect_tested_at
? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true }) ? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })
: t("common.empty")} : t("common.empty")}
</td> </td>
<td className="table-actions"> <td>
<div className="btn-row pt-list-actions table-actions">
<button <button
type="button" type="button"
className="link-btn"
onClick={() => onClick={() =>
openOrFocusModule({ openOrFocusModule({
moduleId: "webcrt", moduleId: "webcrt",
@ -790,41 +819,48 @@ export function NePage() {
</button> </button>
<button <button
type="button" type="button"
className="link-btn"
onClick={() => setConnectDetailRow(row)} onClick={() => setConnectDetailRow(row)}
disabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail} disabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail}
> >
{t("managedNe.connectDetail")} {t("managedNe.connectDetail")}
</button> </button>
<button type="button" className="link-btn" onClick={() => openEdit(row)}> <button type="button" onClick={() => openEdit(row)}>
{t("managedNe.edit")} {t("managedNe.edit")}
</button> </button>
<button <button
type="button" type="button"
className="link-btn link-btn--danger" className="btn--danger"
onClick={() => { onClick={() => {
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id); if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
}} }}
> >
{t("managedNe.delete")} {t("managedNe.delete")}
</button> </button>
</div>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)}
<div className="pager"> <div className="pager pt-list-pager">
<div className="pager__meta">{t("common.pagerMeta", { total, page, pages })}</div> <span className="muted">
<div className="pager__controls"> {t("common.pagerMeta", {
<button className="pager__btn" onClick={() => setPage(Math.max(1, page - 1))} disabled={page <= 1}> 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")} {t("common.prevPage")}
</button> </button>
<button className="pager__btn" onClick={() => setPage(page + 1)} disabled={page >= pages}> <button type="button" disabled={page >= pages} onClick={() => setPage(page + 1)}>
{t("common.nextPage")} {t("common.nextPage")}
</button> </button>
<select <select
className="pager__size"
value={String(pageSize)} value={String(pageSize)}
onChange={(e) => { onChange={(e) => {
setPageSize(Number(e.target.value) || 50); setPageSize(Number(e.target.value) || 50);
@ -837,6 +873,7 @@ export function NePage() {
</select> </select>
</div> </div>
</div> </div>
</div>
</section> </section>
{modalOpen ? ( {modalOpen ? (

View file

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

View file

@ -62,11 +62,15 @@ export function UsersPage() {
}; };
return ( return (
<div className="panel"> <div className="page-stack system-page">
<h2 className="panel__title">{t("auth.usersTitle")}</h2> <section className="panel">
<div className="panel__toolbar">
<h2>{t("auth.usersTitle")}</h2>
</div>
<p className="panel__hint">{t("auth.usersHint")}</p> <p className="panel__hint">{t("auth.usersHint")}</p>
<form className="form-row" onSubmit={onCreate} style={{ gap: 8, flexWrap: "wrap", marginBottom: 16 }}> <div className="pt-list">
<form className="filter-inline" onSubmit={onCreate}>
<input <input
placeholder={t("auth.username")} placeholder={t("auth.username")}
value={username} value={username}
@ -90,8 +94,15 @@ export function UsersPage() {
</button> </button>
</form> </form>
{usersQuery.isLoading ? <div>{t("common.refreshing")}</div> : null} {usersQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<table className="data-table">
{!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> <thead>
<tr> <tr>
<th>{t("auth.username")}</th> <th>{t("auth.username")}</th>
@ -103,11 +114,19 @@ export function UsersPage() {
<tbody> <tbody>
{items.map((u) => ( {items.map((u) => (
<tr key={u.id}> <tr key={u.id}>
<td>{u.username}</td> <td className="pt-list-task-name">{u.username}</td>
<td>{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}</td> <td>{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}</td>
<td>{u.is_active ? t("auth.active") : t("auth.disabled")}</td>
<td> <td>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}> <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 <button
type="button" type="button"
onClick={() => onClick={() =>
@ -118,7 +137,9 @@ export function UsersPage() {
</button> </button>
<select <select
value={u.role} value={u.role}
onChange={(e) => patchMut.mutate({ id: u.id, body: { role: e.target.value } })} onChange={(e) =>
patchMut.mutate({ id: u.id, body: { role: e.target.value } })
}
> >
<option value="user">{t("auth.roleUser")}</option> <option value="user">{t("auth.roleUser")}</option>
<option value="admin">{t("auth.roleAdmin")}</option> <option value="admin">{t("auth.roleAdmin")}</option>
@ -127,8 +148,10 @@ export function UsersPage() {
type="password" type="password"
placeholder={t("auth.newPassword")} placeholder={t("auth.newPassword")}
value={resetPwd[u.id] || ""} value={resetPwd[u.id] || ""}
onChange={(e) => setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))} onChange={(e) =>
style={{ width: 140 }} setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))
}
style={{ width: 140, minWidth: 140, flex: "0 0 auto" }}
/> />
<button <button
type="button" type="button"
@ -148,5 +171,9 @@ export function UsersPage() {
</tbody> </tbody>
</table> </table>
</div> </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() || "-"; 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 { function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string, string | number>) => string): string {
const raw = String(err); const raw = String(err);
if (raw.includes("webcrt_session_limit")) return t("webcrt.err.sessionLimit"); 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}`} title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
> >
<span className="webcrt-tree__icon" aria-hidden> <span className="webcrt-tree__icon" aria-hidden>
▣ <ComputerIcon />
</span> </span>
<span className="webcrt-tree__label"> <span className="webcrt-tree__label">
<span className="webcrt-tree__name">{deviceLabel(row)}</span> <span className="webcrt-tree__name">{deviceLabel(row)}</span>

View file

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