mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 06:30:44 +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" />
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -77,32 +77,42 @@ export function AppLayout({ connections, children }: Props) {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="app-brand__actions">
|
<div className="app-brand__actions">
|
||||||
<span className={`conn-pill conn-pill--on-brand conn-pill--${connections.netxApi}`}>
|
<div className="app-brand__actions-group">
|
||||||
{t("layout.netxApi")}: {connections.netxApi}
|
<span className={`conn-pill conn-pill--on-brand conn-pill--${connections.netxApi}`}>
|
||||||
{netxSuffix}
|
{t("layout.netxApi")}: {connections.netxApi}
|
||||||
</span>
|
{netxSuffix}
|
||||||
<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}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
<span
|
||||||
{user ? (
|
className={`conn-pill conn-pill--on-brand conn-pill--${connections.oclawBridge}`}
|
||||||
<button
|
title={oclawTitle}
|
||||||
type="button"
|
|
||||||
className="header-menu__trigger header-menu__trigger--on-brand"
|
|
||||||
onClick={() => void logout()}
|
|
||||||
>
|
>
|
||||||
{t("auth.logout")}
|
{t("layout.oclawBridge")}: {connections.oclawBridge}
|
||||||
</button>
|
{oclawSuffix}
|
||||||
) : null}
|
</span>
|
||||||
<HeaderMenu />
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,102 +105,130 @@ 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">
|
||||||
<p className="panel__hint">{t("auth.apiKeysHint")}</p>
|
<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 }}>
|
<div className="pt-list">
|
||||||
<input
|
<form className="filter-inline" onSubmit={onCreate}>
|
||||||
placeholder={t("auth.tokenName")}
|
<input
|
||||||
value={name}
|
placeholder={t("auth.tokenName")}
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={name}
|
||||||
required
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
required
|
||||||
<select
|
/>
|
||||||
value={expiresInDays}
|
<select
|
||||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
value={expiresInDays}
|
||||||
aria-label={t("auth.expiresIn")}
|
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||||
>
|
aria-label={t("auth.expiresIn")}
|
||||||
{EXPIRY_OPTIONS.map((opt) => (
|
>
|
||||||
<option key={opt.value} value={opt.value}>
|
{EXPIRY_OPTIONS.map((opt) => (
|
||||||
{t(opt.labelKey)}
|
<option key={opt.value} value={opt.value}>
|
||||||
</option>
|
{t(opt.labelKey)}
|
||||||
))}
|
|
||||||
</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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : null}
|
{isAdmin ? (
|
||||||
<button type="submit" disabled={createMut.isPending}>
|
<select
|
||||||
{t("auth.createToken")}
|
value={ownerUserId}
|
||||||
</button>
|
onChange={(e) => setOwnerUserId(e.target.value)}
|
||||||
</form>
|
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 ? (
|
{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")}
|
||||||
<button type="button" onClick={() => void copyToken()}>
|
</div>
|
||||||
{t("auth.copyToken")}
|
<code>{createdPlain}</code>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
) : null}
|
</section>
|
||||||
|
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,79 +49,123 @@ 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">
|
||||||
<p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
|
<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 }}>
|
<div className="pt-list">
|
||||||
{isAdmin ? (
|
<div className="filter-inline">
|
||||||
<input
|
{isAdmin ? (
|
||||||
placeholder={t("auth.filterUsername")}
|
<input
|
||||||
value={username}
|
placeholder={t("auth.filterUsername")}
|
||||||
onChange={(e) => {
|
value={username}
|
||||||
setPage(1);
|
onChange={(e) => {
|
||||||
setUsername(e.target.value);
|
setPage(1);
|
||||||
}}
|
setUsername(e.target.value);
|
||||||
/>
|
}}
|
||||||
) : null}
|
/>
|
||||||
<input
|
) : null}
|
||||||
placeholder={t("auth.filterAction")}
|
<input
|
||||||
value={action}
|
placeholder={t("auth.filterAction")}
|
||||||
onChange={(e) => {
|
value={action}
|
||||||
setPage(1);
|
onChange={(e) => {
|
||||||
setAction(e.target.value);
|
setPage(1);
|
||||||
}}
|
setAction(e.target.value);
|
||||||
/>
|
}}
|
||||||
<button type="button" onClick={() => void query.refetch()}>
|
/>
|
||||||
{t("common.refresh")}
|
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}>
|
||||||
</button>
|
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||||
</div>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasFilters}
|
||||||
|
onClick={() => {
|
||||||
|
setUsername("");
|
||||||
|
setAction("");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.clearFilters")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{query.isLoading ? <div>{t("common.refreshing")}</div> : null}
|
{query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : 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>
|
|
||||||
|
|
||||||
<div className="form-row" style={{ gap: 8, marginTop: 12 }}>
|
{!items.length && !query.isLoading ? (
|
||||||
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
<div className="pt-list-empty">
|
||||||
{t("common.prevPage")}
|
<p>{t("common.empty")}</p>
|
||||||
</button>
|
</div>
|
||||||
<span>
|
) : (
|
||||||
{t("common.pagerMeta", { total, page, pages })}
|
<div className="pt-list-table-wrap">
|
||||||
</span>
|
<table className="data-table pt-list-table">
|
||||||
<button
|
<thead>
|
||||||
type="button"
|
<tr>
|
||||||
disabled={page >= pages}
|
<th>{t("auth.colTime")}</th>
|
||||||
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
<th>{t("auth.colUser")}</th>
|
||||||
>
|
<th>{t("auth.colAction")}</th>
|
||||||
{t("common.nextPage")}
|
<th>{t("auth.colMethod")}</th>
|
||||||
</button>
|
<th>{t("auth.colPath")}</th>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,53 +491,55 @@ 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>
|
<button
|
||||||
<div className="ne-stats-card__header-actions">
|
type="button"
|
||||||
<button
|
disabled={bulkByTagMutation.isPending}
|
||||||
type="button"
|
onClick={() => {
|
||||||
disabled={bulkByTagMutation.isPending}
|
setBulkTagAction("proxy");
|
||||||
onClick={() => {
|
setBulkHop(emptyHopProxyFields());
|
||||||
setBulkTagAction("proxy");
|
setBulkTagModalOpen(true);
|
||||||
setBulkHop(emptyHopProxyFields());
|
}}
|
||||||
setBulkTagModalOpen(true);
|
>
|
||||||
}}
|
{t("managedNe.stats.batchProxy")}
|
||||||
>
|
</button>
|
||||||
{t("managedNe.stats.batchProxy")}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
disabled={bulkByTagMutation.isPending}
|
||||||
type="button"
|
onClick={() => {
|
||||||
disabled={bulkByTagMutation.isPending}
|
setBulkTagAction("account");
|
||||||
onClick={() => {
|
setBulkAccount(emptyAccount());
|
||||||
setBulkTagAction("account");
|
setBulkTagModalOpen(true);
|
||||||
setBulkAccount(emptyAccount());
|
}}
|
||||||
setBulkTagModalOpen(true);
|
>
|
||||||
}}
|
{t("managedNe.account.batchByTag")}
|
||||||
>
|
</button>
|
||||||
{t("managedNe.account.batchByTag")}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
disabled={bulkByTagMutation.isPending}
|
||||||
type="button"
|
onClick={() => {
|
||||||
disabled={bulkByTagMutation.isPending}
|
setBulkTagAction("test");
|
||||||
onClick={() => {
|
setBulkTagModalOpen(true);
|
||||||
setBulkTagAction("test");
|
}}
|
||||||
setBulkTagModalOpen(true);
|
>
|
||||||
}}
|
{t("managedNe.stats.batchTest")}
|
||||||
>
|
</button>
|
||||||
{t("managedNe.stats.batchTest")}
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{stats ? (
|
{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__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,170 +673,205 @@ export function NePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-inline">
|
<div className="pt-list">
|
||||||
<input
|
<div className="filter-inline">
|
||||||
value={keyword}
|
<input
|
||||||
placeholder={t("managedNe.keywordPh")}
|
value={keyword}
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
placeholder={t("managedNe.keywordPh")}
|
||||||
/>
|
|
||||||
<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)}
|
|
||||||
onChange={(e) => {
|
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);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="20">{perPage(20)}</option>
|
<option value="">{t("managedNe.allVendors")}</option>
|
||||||
<option value="50">{perPage(50)}</option>
|
{vendors.map((v) => (
|
||||||
<option value="100">{perPage(100)}</option>
|
<option key={v} value={v}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
|
<div className="panel__toolbar-end">
|
||||||
{t("ume.tasks.running")}: {runningTasks.length}
|
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
|
||||||
</span>
|
{t("ume.tasks.running")}: {runningTasks.length}
|
||||||
<button onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
|
</span>
|
||||||
{t("common.refresh")}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
|
||||||
|
disabled={syncStatusQuery.isFetching}
|
||||||
|
>
|
||||||
|
{t("common.refresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<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">
|
||||||
{t("ume.subscription.title")}
|
<h2 className="card-title-with-hint">
|
||||||
<HelpHint text={t("ume.subscription.help")} ariaLabel={t("common.help")} />
|
{t("ume.subscription.title")}
|
||||||
</h3>
|
<HelpHint text={t("ume.subscription.help")} ariaLabel={t("common.help")} />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,91 +62,118 @@ 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">
|
||||||
<p className="panel__hint">{t("auth.usersHint")}</p>
|
<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 }}>
|
<div className="pt-list">
|
||||||
<input
|
<form className="filter-inline" onSubmit={onCreate}>
|
||||||
placeholder={t("auth.username")}
|
<input
|
||||||
value={username}
|
placeholder={t("auth.username")}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
value={username}
|
||||||
required
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
required
|
||||||
<input
|
/>
|
||||||
type="password"
|
<input
|
||||||
placeholder={t("auth.password")}
|
type="password"
|
||||||
value={password}
|
placeholder={t("auth.password")}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
value={password}
|
||||||
required
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
minLength={6}
|
required
|
||||||
/>
|
minLength={6}
|
||||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
/>
|
||||||
<option value="user">{t("auth.roleUser")}</option>
|
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||||
<option value="admin">{t("auth.roleAdmin")}</option>
|
<option value="user">{t("auth.roleUser")}</option>
|
||||||
</select>
|
<option value="admin">{t("auth.roleAdmin")}</option>
|
||||||
<button type="submit" disabled={createMut.isPending}>
|
</select>
|
||||||
{t("auth.addUser")}
|
<button type="submit" disabled={createMut.isPending}>
|
||||||
</button>
|
{t("auth.addUser")}
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{usersQuery.isLoading ? <div>{t("common.refreshing")}</div> : null}
|
{usersQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
{!items.length && !usersQuery.isLoading ? (
|
||||||
<tr>
|
<div className="pt-list-empty">
|
||||||
<th>{t("auth.username")}</th>
|
<p>{t("common.empty")}</p>
|
||||||
<th>{t("auth.role")}</th>
|
</div>
|
||||||
<th>{t("auth.status")}</th>
|
) : (
|
||||||
<th>{t("auth.actions")}</th>
|
<div className="pt-list-table-wrap">
|
||||||
</tr>
|
<table className="data-table pt-list-table">
|
||||||
</thead>
|
<thead>
|
||||||
<tbody>
|
<tr>
|
||||||
{items.map((u) => (
|
<th>{t("auth.username")}</th>
|
||||||
<tr key={u.id}>
|
<th>{t("auth.role")}</th>
|
||||||
<td>{u.username}</td>
|
<th>{t("auth.status")}</th>
|
||||||
<td>{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}</td>
|
<th>{t("auth.actions")}</th>
|
||||||
<td>{u.is_active ? t("auth.active") : t("auth.disabled")}</td>
|
</tr>
|
||||||
<td>
|
</thead>
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
|
<tbody>
|
||||||
<button
|
{items.map((u) => (
|
||||||
type="button"
|
<tr key={u.id}>
|
||||||
onClick={() =>
|
<td className="pt-list-task-name">{u.username}</td>
|
||||||
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
|
<td>{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}</td>
|
||||||
}
|
<td>
|
||||||
>
|
<span
|
||||||
{u.is_active ? t("auth.disable") : t("auth.enable")}
|
className={`pt-list-status ${
|
||||||
</button>
|
u.is_active ? "pt-list-status--ok" : "pt-list-status--other"
|
||||||
<select
|
}`}
|
||||||
value={u.role}
|
>
|
||||||
onChange={(e) => patchMut.mutate({ id: u.id, body: { role: e.target.value } })}
|
{u.is_active ? t("auth.active") : t("auth.disabled")}
|
||||||
>
|
</span>
|
||||||
<option value="user">{t("auth.roleUser")}</option>
|
</td>
|
||||||
<option value="admin">{t("auth.roleAdmin")}</option>
|
<td>
|
||||||
</select>
|
<div className="btn-row pt-list-actions table-actions">
|
||||||
<input
|
<button
|
||||||
type="password"
|
type="button"
|
||||||
placeholder={t("auth.newPassword")}
|
onClick={() =>
|
||||||
value={resetPwd[u.id] || ""}
|
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
|
||||||
onChange={(e) => setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))}
|
}
|
||||||
style={{ width: 140 }}
|
>
|
||||||
/>
|
{u.is_active ? t("auth.disable") : t("auth.enable")}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<select
|
||||||
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 6}
|
value={u.role}
|
||||||
onClick={() => {
|
onChange={(e) =>
|
||||||
const pwd = resetPwd[u.id];
|
patchMut.mutate({ id: u.id, body: { role: e.target.value } })
|
||||||
patchMut.mutate({ id: u.id, body: { password: pwd } });
|
}
|
||||||
setResetPwd((m) => ({ ...m, [u.id]: "" }));
|
>
|
||||||
}}
|
<option value="user">{t("auth.roleUser")}</option>
|
||||||
>
|
<option value="admin">{t("auth.roleAdmin")}</option>
|
||||||
{t("auth.resetPassword")}
|
</select>
|
||||||
</button>
|
<input
|
||||||
</div>
|
type="password"
|
||||||
</td>
|
placeholder={t("auth.newPassword")}
|
||||||
</tr>
|
value={resetPwd[u.id] || ""}
|
||||||
))}
|
onChange={(e) =>
|
||||||
</tbody>
|
setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))
|
||||||
</table>
|
}
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue