feat(health): System Health in three groups, a score that sees lag; admin user detail

Dashboard → System Health is grouped into AgentHub, Machine and Agent
activity. The machine (GET /api/host, every signed-in user) is drawn by
components/health/HostHealth.tsx: host card beside the CPU/memory history,
then CPU (threads as compact cells + temperatures/GPUs), disks & network,
busiest processes, and per-user tiles. Concurrency bars each show their own
agent type on hover.

The Health Score (client/src/lib/healthScore.ts) is now 100 minus what each
check costs, between an amber and a red threshold: event-loop p99
(server/lib/loop-delay.js, shared with /api/metrics), Linux PSI pressure for
CPU, memory and disk I/O (/api/host `pressure`; load per core without PSI),
RAM, the database's disk, V8 heap, hooks silent while agents work, and agent
errors in the last 24 h. All-time success rate, API errors and the transcript
cache hit rate no longer count. /api/settings/info adds server.event_loop and
activity {last_event_at, working_agents, agents_24h}.

Administration: a user row opens its detail in place (GET
/api/users/:id/details — spend, account pool share, live panes, workspaces,
newest sessions); the server panel is gone, member tmux folded into the table;
the Sessions list shows each row's owner in server mode.
This commit is contained in:
2026-10-27 16:39:38 +07:00
parent df15789982
commit 1c6dfea5b5
49 changed files with 5154 additions and 1971 deletions
+16 -37
View File
@@ -1,42 +1,32 @@
/**
* @file Administration.tsx
* @description The Administration page (`/admin`, a sidebar item; `/users`
* redirects here), server mode only and only for an admin or analyst: a tab
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the
* accounts, the account pool and the audit log, `Users.tsx`) and **Server**
* (the machine's hardware and each user's share of it, `ServerPanel`).
* Local mode and a developer are sent home, like the Users page always did.
* redirects here), server mode only and only for an admin or analyst — ONE
* page, no tabs: ONE users table holding every per-user account fact (account,
* spend, home, tmux; each row expands in
* place into that user's detail), the account pool, then the audit log — all
* from `Users.tsx`. The machine and each user's live CPU / RAM live on the
* Dashboard's System Health tab (`HostHealth.tsx`). Local mode and a developer are sent home, like the
* Users page always did.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { ShieldCheck } from "lucide-react";
import { useAuth } from "../components/AuthGate";
import { ServerPanel } from "../components/admin/ServerPanel";
import { useHostStats } from "../components/health/HostHealth";
import { Users } from "./Users";
interface AdminTab {
id: string;
label: string;
Icon: ComponentType<{ className?: string }>;
}
export function Administration() {
const { t } = useTranslation("admin");
const auth = useAuth();
const [params, setParams] = useSearchParams();
const me = auth.state.mode === "server" ? auth.state.user : null;
const staff = !!me && me.role !== "developer";
// Read once, for each home's share of the homes disk. The machine and each
// user's live CPU / RAM are on the Dashboard's System Health tab.
const host = useHostStats(staff, 0);
if (!me || me.role === "developer") return <Navigate to="/" replace />;
const tabs: AdminTab[] = [
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
{ id: "server", label: t("tabs.server"), Icon: Server },
];
const first = tabs[0]!;
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
if (!staff) return <Navigate to="/" replace />;
return (
<div className="animate-fade-in space-y-6">
@@ -49,18 +39,7 @@ export function Administration() {
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
<PageTabs
ariaLabel={t("title")}
activeId={active.id}
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
tabs={tabs}
>
{active.id === "server" ? (
<ServerPanel isAdmin={me.role === "admin"} />
) : (
<Users embedded />
)}
</PageTabs>
<Users embedded host={host} />
</div>
);
}
File diff suppressed because it is too large Load Diff
+175 -96
View File
@@ -1,6 +1,6 @@
/**
* @file Sessions.tsx
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; a row's right-click opens `SessionRowMenu`. Sessions are updated in real-time based on events received from the event bus.
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; columns include the AI tool and (server-mode admins) the owning user, and the last column holds the row's actions (open in workspace, `SessionRowMenu` — also on right-click — and details). Sessions are updated in real-time based on events received from the event bus.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -66,6 +66,8 @@ import {
Server,
Download,
AlertCircle,
MoreHorizontal,
SquareArrowOutUpRight,
} from "lucide-react";
import { api } from "../lib/api";
import type { RemoteSource, RunHandle } from "../lib/api";
@@ -80,7 +82,8 @@ import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { SessionStatusBadge } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { AiToolIcon } from "../components/AiToolIcon";
import { UserAvatar } from "../components/users/UserAvatar";
import { EmptyState } from "../components/EmptyState";
import { TableRowSkeleton } from "../components/Skeleton";
import { useContextMenu } from "../components/ContextMenu";
@@ -91,12 +94,16 @@ import type { Session, DashboardEvent } from "../lib/types";
import { usePermissions } from "../components/AuthGate";
const PAGE_SIZE = 10;
const TH =
"px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider whitespace-nowrap";
const ACTION_ICON =
"inline-flex items-center justify-center w-7 h-7 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent";
export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate();
const rowCtx = useContextMenu<Session>();
const { t } = useTranslation("sessions");
const { tools } = useAiTools();
const { tools, label: toolLabel } = useAiTools();
const { lanes } = useLanes();
// Filters, sort and page live in the URL (replace, no history spam), so a
// reload, the back button and a shared link all land on the same view.
@@ -185,7 +192,11 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// Load remote-source labels so remote-origin rows can show a friendly badge
// instead of a raw `src_…` id. Refreshed when a source's status changes.
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
const { seesAll } = usePermissions();
const { seesAll, isAdmin, local } = usePermissions();
// Who owns each session: an admin's column only, and only where there are
// accounts (server mode; the API sends no owner in local mode).
const showUser = isAdmin && !local;
const columns = showUser ? 10 : 9;
const loadSourceLabels = useCallback(() => {
if (!seesAll) return;
api.remoteSources
@@ -581,31 +592,19 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
role="region"
aria-label={t("tableRegion")}
>
<table className="w-full min-w-[800px]">
<table className="w-full min-w-[960px]">
<thead>
<tr className="border-b border-border text-left">
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableSession")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableStatus")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableLastActive")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDuration")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableAgents")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableCost")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDirectory")}
</th>
<th className="w-10"></th>
<tr className="border-b border-border text-left bg-surface-2/40">
<th className={TH}>{t("tableSession")}</th>
{showUser && <th className={TH}>{t("tableUser")}</th>}
<th className={TH}>{t("tableTool")}</th>
<th className={TH}>{t("tableStatus")}</th>
<th className={TH}>{t("tableLastActive")}</th>
<th className={`${TH} text-right`}>{t("tableDuration")}</th>
<th className={`${TH} text-right`}>{t("tableAgents")}</th>
<th className={`${TH} text-right`}>{t("tableCost")}</th>
<th className={TH}>{t("tableDirectory")}</th>
<th className={`${TH} text-right`}>{t("tableActions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -613,94 +612,174 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
? Array.from({ length: 8 }).map((_, i) => (
<TableRowSkeleton
key={`sk-${i}`}
columns={8}
widths={["w-40", "w-20", "w-28", "w-20", "w-10", "w-16", "w-44", "w-4"]}
columns={columns}
widths={[
"w-40",
...(showUser ? ["w-24"] : []),
"w-20",
"w-20",
"w-28",
"w-16",
"w-8",
"w-14",
"w-40",
"w-20",
]}
/>
))
: null}
{paged.map((session) => (
<tr
key={session.id}
// Mouse convenience; the session name below is the real,
// focusable link (a click on a link or button inside wins).
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
navigate(`/sessions/${session.id}`);
}}
onContextMenu={(e) => rowCtx.open(e, session)}
className="hover:bg-surface-4 transition-colors cursor-pointer group"
>
<td className="px-5 py-4">
<div>
<div className="flex items-center gap-2">
{paged.map((session) => {
const toolId = session.tool || "claude";
// A live dashboard pane holds it: open that pane; else its
// workspace's replay, when a workspace owns it.
const live = runs.some((r) => r.sessionId === session.id);
const href = workspaceHref(session, runs);
const owner = session.owner_display_name || session.owner_name;
return (
<tr
key={session.id}
// Mouse convenience; the session name below is the real,
// focusable link (a click on a link or button inside wins).
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
navigate(`/sessions/${session.id}`);
}}
onContextMenu={(e) => rowCtx.open(e, session)}
className="hover:bg-surface-3/60 transition-colors cursor-pointer group"
>
<td className="px-4 py-3 max-w-[22rem]">
<div className="flex items-center gap-2 min-w-0">
<Link
to={`/sessions/${session.id}`}
className="text-sm font-medium text-fg-secondary hover:text-fg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
title={session.name || undefined}
className="truncate text-sm font-medium text-fg-primary hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
>
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</Link>
<SessionToolBadge tool={session.tool} />
{session.source && session.source !== "local" && (
<span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
className="inline-flex flex-shrink-0 items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
>
<Server className="w-2.5 h-2.5" />
{sourceLabels.get(session.source) || session.source}
</span>
)}
{(() => {
// A live dashboard pane holds it: open that pane.
const run = runs.find((r) => r.sessionId === session.id);
const href = run ? workspaceHref(session, runs) : null;
return href ? (
<Link
to={href}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
title={t("openInWorkspaceTitle")}
>
<Play className="w-2.5 h-2.5" />
{t("openInWorkspace")}
</Link>
) : null;
})()}
</div>
<p className="text-[11px] text-fg-muted font-mono">
{session.id.slice(0, 12)}
</p>
</div>
</td>
<td className="px-5 py-4">
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</td>
<td className="px-5 py-4 text-sm text-fg-secondary">
{formatDateTime(session.last_activity || session.started_at)}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.ended_at
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary">
{session.agent_count ?? "-"}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td>
<td
className="px-5 py-4 text-[11px] text-fg-muted font-mono"
title={session.cwd || undefined}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<td className="px-3 py-4">
<ChevronRight className="w-4 h-4 text-fg-muted group-hover:text-fg-secondary transition-colors" />
</td>
</tr>
))}
</td>
{showUser && (
<td className="px-4 py-3">
{owner ? (
<div
className="flex items-center gap-2 min-w-0"
title={session.owner_name || undefined}
>
<UserAvatar
name={owner}
userId={session.owner_id ?? undefined}
avatarAt={session.owner_avatar_at}
className="h-6 w-6 text-[10px]"
/>
<span className="truncate text-sm text-fg-secondary max-w-[9rem]">
{owner}
</span>
</div>
) : (
<span className="text-xs text-fg-muted" title={t("noOwner")}>
-
</span>
)}
</td>
)}
<td className="px-4 py-3">
<div
className="flex items-center gap-2"
title={t("toolBadgeTitle", { tool: toolLabel(toolId) })}
>
<AiToolIcon
tool={{ id: toolId, label: toolLabel(toolId) }}
className="h-6 w-6"
/>
<span className="text-sm text-fg-secondary whitespace-nowrap">
{toolLabel(toolId)}
</span>
</div>
</td>
<td className="px-4 py-3">
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</td>
<td className="px-4 py-3 text-sm text-fg-secondary whitespace-nowrap">
{formatDateTime(session.last_activity || session.started_at)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right whitespace-nowrap">
{session.ended_at
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right tabular-nums">
{session.agent_count ?? "-"}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td>
<td
className="px-4 py-3 text-[11px] text-fg-muted font-mono"
title={session.cwd || undefined}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{href &&
(live ? (
<Link
to={href}
className="inline-flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-2 py-1 rounded-md transition-colors"
title={t("openInWorkspaceTitle")}
>
<Play className="w-3 h-3" />
{t("openInWorkspace")}
</Link>
) : (
<Link
to={href}
className={ACTION_ICON}
title={t("menu.inWorkspace")}
aria-label={t("menu.inWorkspace")}
>
<SquareArrowOutUpRight className="w-4 h-4" />
</Link>
))}
<button
type="button"
onClick={(e) => rowCtx.open(e, session)}
className={ACTION_ICON}
title={t("moreActions")}
aria-label={t("moreActions")}
aria-haspopup="menu"
>
<MoreHorizontal className="w-4 h-4" />
</button>
<Link
to={`/sessions/${session.id}`}
className={ACTION_ICON}
title={t("viewDetails")}
aria-label={t("viewDetails")}
tabIndex={-1}
>
<ChevronRight className="w-4 h-4" />
</Link>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
+252 -111
View File
@@ -1,38 +1,56 @@
/**
* @file Users.tsx
* @description The Users tab of the Administration page (`/admin`, which
* @description The users section of the Administration page (`/admin`, which
* `/users` redirects to — `Administration.tsx`), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions, the tokens/$ it used today (the
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes
* role, state, workspaces, sessions, the $ it used today (the browser's local
* day) with 7 and 30 days under it (`GET /api/users`),
* how much disk its home folder takes and its share of the homes disk (from
* `host`, the page's one `GET /api/host` read; each user's live CPU / RAM is on
* the Dashboard's System Health tab)
* (`GET /api/users/storage`, measured in the background — polled while the
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
* server says `measuring`; Refresh re-measures), and — one Linux user per
* account — its own tmux server (`MemberTmux.tsx`: version, outdated badge,
* sessions, agents; an admin's restart in the row's actions and "Restart idle"
* above the table): every per-user fact in ONE table. Also the audit log
* (`GET /api/users/audit`), and — for
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run
* on (`AccountPoolCard`). (Each member's own tmux server — one Linux user per
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
* on (`AccountPoolCard`).
* Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display
* name and email under the username when set, and "New password due" while
* the account still has a password an admin gave; right-clicking a row offers
* Edit, Open home (in /files), and copying the username or email.
* Edit, Open home (in /files), and copying the username or email. Clicking a
* row (or its name) expands that account in full right under it —
* `UserDetail`, one at a time, kept in `?user=<id>` so a reload or a shared
* link lands on it. `children` go between the account pool and the audit log.
* Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import {
Fragment,
useCallback,
useEffect,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronRight,
DollarSign,
Eye,
FolderOpen,
MessagesSquare,
Pencil,
RefreshCw,
RotateCcw,
ScrollText,
UserPlus,
Users as UsersIcon,
@@ -41,6 +59,7 @@ import {
api,
type AuditEntry,
type AuthUser,
type MemberTmuxRow,
type ServerUserUsage,
type UserStorage,
} from "../lib/api";
@@ -53,6 +72,15 @@ import { ConfirmModal } from "../components/ConfirmModal";
import { StatCard } from "../components/StatCard";
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { UserDetail } from "../components/users/UserDetail";
import { UserAvatar } from "../components/users/UserAvatar";
import {
MemberTmuxBar,
TmuxCell,
TmuxRestartDialog,
useMemberTmux,
} from "../components/users/MemberTmux";
import { Meter, fmtPct, pct, type HostState } from "../components/health/HostHealth";
import { TableRowSkeleton } from "../components/Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { copyText } from "../lib/clipboard";
@@ -60,7 +88,16 @@ import { copyText } from "../lib/clipboard";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
export function Users({ embedded = false }: { embedded?: boolean } = {}) {
export function Users({
embedded = false,
host,
children,
}: {
embedded?: boolean;
/** The Administration page's one `GET /api/host` read: each user's CPU/RAM now. */
host?: HostState;
children?: ReactNode;
} = {}) {
const { t } = useTranslation("users");
const auth = useAuth();
const me = auth.state.mode === "server" ? auth.state.user : null;
@@ -75,6 +112,24 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate();
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [measuring, setMeasuring] = useState(false);
const tmux = useMemberTmux(!!me && me.role !== "developer");
// A member's tmux restart: one member, or every idle outdated one.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const [params, setParams] = useSearchParams();
const detailId = Number(params.get("user")) || null;
const openDetail = useCallback(
(id: number | null) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (id == null) next.delete("user");
else next.set("user", String(id));
return next;
},
{ replace: true }
),
[setParams]
);
const loadStorage = useCallback(
(remeasure = false) =>
@@ -108,7 +163,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
);
// The Refresh button also re-measures every home; a plain load reuses the
// server's cached numbers.
const [spinning, refresh] = useSpin(useCallback(() => load(true), [load]));
const reloadHost = host?.reload;
const reloadTmux = tmux.reload;
const [spinning, refresh] = useSpin(
useCallback(
() => Promise.all([load(true), reloadTmux(), reloadHost?.()]),
[load, reloadTmux, reloadHost]
)
);
useEffect(() => {
if (me && me.role !== "developer") void load();
@@ -127,7 +189,12 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
const sum = (pick: (u: ServerUserUsage) => number) =>
(users ?? []).reduce((n, u) => n + pick(u), 0);
const active = (users ?? []).filter((u) => !u.disabled).length;
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
const th = "px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
const stats = host?.stats ?? null;
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
const showTmux = !!tmux.data?.available;
const tmuxById = new Map((tmux.data?.users ?? []).map((r) => [r.id, r]));
const columns = 5 + (showTmux ? 1 : 0) + (isAdmin ? 1 : 0);
return (
<div className="animate-fade-in space-y-6">
@@ -139,10 +206,12 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
</div>
)}
<div>
{/* Under Administration's tab bar the tab already names the page. */}
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
{t("title")}
</h1>
{/* On the Administration page this is one section of it: a section heading. */}
{embedded ? (
<h2 className="text-base font-semibold text-fg-primary">{t("title")}</h2>
) : (
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
)}
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
@@ -213,18 +282,18 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
</p>
)}
<MemberTmuxBar data={tmux.data} isAdmin={isAdmin} onRestart={setRestarting} />
<div className="card overflow-x-auto">
<table className="w-full min-w-[960px]" data-testid="users-table">
<table className="w-full min-w-[1040px]" data-testid="users-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th>
<th className={th}>{t("role")}</th>
<th className={`${th} text-right`}>{t("workspaces")}</th>
<th className={`${th} text-right`}>{t("sessions")}</th>
<th className={`${th} text-right`}>{t("today")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th>
<th className={`${th} text-right`}>{t("last30d")}</th>
<th className={`${th} text-right`}>{t("storage")}</th>
<th className={th}>{t("activity")}</th>
<th className={`${th} text-right`}>{t("spend")}</th>
<th className={`${th} w-[12%]`}>{t("storage")}</th>
{showTmux && <th className={th}>{t("tmuxCol")}</th>}
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
@@ -234,87 +303,140 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
Array.from({ length: 4 }).map((_, i) => (
<TableRowSkeleton
key={i}
columns={isAdmin ? 8 : 7}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-14", "w-28"]}
columns={columns}
widths={["w-36", "w-20", "w-24", "w-16", "w-24", "w-24", "w-16", "w-10"].slice(
0,
columns
)}
/>
))}
{users?.map((u) => {
const self = u.id === me.id;
const open = detailId === u.id;
const toggle = () => openDetail(open ? null : u.id);
const tmuxRow = tmuxById.get(u.id);
const home = storage ? (storage[u.id] ?? null) : undefined;
return (
<tr
key={u.id}
onContextMenu={(e) => rowMenu.open(e, u)}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-5 py-3">
<div className="flex items-center gap-3 min-w-0">
<span
aria-hidden
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
>
{(u.displayName || u.username).slice(0, 2)}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">
{u.displayName || u.username}
{u.displayName && (
<span className="ml-1.5 font-normal text-fg-muted">@{u.username}</span>
<Fragment key={u.id}>
<tr
onContextMenu={(e) => rowMenu.open(e, u)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
toggle();
}}
className={`cursor-pointer hover:bg-surface-4/40 transition-colors ${open ? "bg-surface-3/50" : ""} ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<ChevronRight
aria-hidden
className={`w-4 h-4 flex-shrink-0 text-fg-muted transition-transform ${open ? "rotate-90" : ""}`}
/>
<UserAvatar
name={u.displayName || u.username}
userId={u.id}
avatarAt={u.avatarAt}
className="h-8 w-8 text-xs"
initialsClassName="bg-surface-4 text-fg-secondary"
/>
<div className="min-w-0">
<button
type="button"
onClick={toggle}
aria-expanded={open}
title={t("detail.open", { name: u.displayName || u.username })}
className="block max-w-full truncate text-left text-sm font-medium text-fg-primary hover:text-accent"
>
{u.displayName || u.username}
{u.displayName && (
<span className="ml-1.5 font-normal text-fg-muted">
@{u.username}
</span>
)}
</button>
{u.email && (
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
)}
</div>
{u.email && (
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
{!u.disabled && u.mustChangePassword && (
<span
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={t("mustChangeHint")}
data-testid="user-must-change"
>
{t("mustChange")}
</span>
)}
</div>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
{!u.disabled && u.mustChangePassword && (
<span
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={t("mustChangeHint")}
data-testid="user-must-change"
>
{t("mustChange")}
</span>
)}
</div>
</td>
<td className="px-5 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.workspaces}
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions}
</td>
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
<StorageCell
storage={storage ? (storage[u.id] ?? null) : undefined}
measuring={measuring}
/>
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
<Pencil className="h-4 w-4" />
</RowAction>
</td>
<td className="px-4 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-4 py-3 text-sm tabular-nums">
<div className="text-fg-primary">{t("sessionsN", { n: u.sessions })}</div>
<div className="text-[11px] text-fg-muted">
{t("workspacesN", { n: u.workspaces })}
</div>
</td>
<td
className="px-4 py-3 text-right tabular-nums"
title={t("tokens", { n: fmt((u.today ?? { tokens: 0 }).tokens) })}
>
<div className="text-sm text-fg-primary">
{fmtCost((u.today ?? { cost: 0 }).cost)}
</div>
<div className="whitespace-nowrap text-[11px] text-fg-muted">
{t("spendSub", {
week: fmtCost(u.last7d.cost),
month: fmtCost(u.last30d.cost),
})}
</div>
</td>
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
{showTmux && (
<td className="px-4 py-3">
<TmuxCell row={tmuxRow} />
</td>
)}
{isAdmin && (
<td className="px-4 py-2 text-right whitespace-nowrap">
{tmuxRow?.running && (
<RowAction
label={t("tmux.restart", { name: u.username })}
onClick={() => setRestarting(tmuxRow)}
>
<RotateCcw className="h-4 w-4" />
</RowAction>
)}
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
<Pencil className="h-4 w-4" />
</RowAction>
</td>
)}
</tr>
{open && (
<tr className="bg-surface-2/40">
<td colSpan={columns} className="px-5 py-4 cursor-default">
<UserDetail userId={u.id} />
</td>
</tr>
)}
</tr>
</Fragment>
);
})}
</tbody>
@@ -335,6 +457,9 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
onClose={rowMenu.close}
testId="users-row-menu"
>
<MenuItem onSelect={pick(() => openDetail(detailId === u.id ? null : u.id))}>
{t("detail.open", { name: u.displayName || u.username })}
</MenuItem>
{isAdmin && (
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
)}
@@ -364,6 +489,8 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
<AccountPoolCard isAdmin={isAdmin} />
{children}
<section className="card">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<ScrollText className="w-4 h-4 text-fg-muted" />
@@ -417,6 +544,18 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
}}
/>
)}
{restarting && (
<TmuxRestartDialog
target={restarting}
data={tmux.data}
onClose={() => setRestarting(null)}
onDone={(message) => {
toast.success(message);
setRestarting(null);
void tmux.reload();
}}
/>
)}
{editing && (
<UserEditDialog
user={editing}
@@ -434,47 +573,49 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
);
}
/** A period's cost, with its tokens underneath. */
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
const { t } = useTranslation("users");
return (
<td className="px-5 py-3 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
</td>
);
}
/**
* A user's home folder size (`undefined` = not loaded yet). `null` while the
* server is still measuring reads "Measuring…", otherwise "—" (no home of
* their own).
*/
function StorageCell({ storage, measuring }: { storage?: UserStorage; measuring: boolean }) {
function StorageCell({
storage,
measuring,
disk,
}: {
storage?: UserStorage;
measuring: boolean;
/** The disk holding the homes: the home is also shown as a share of it. */
disk: { total: number } | null;
}) {
const { t } = useTranslation("users");
if (!storage) {
const pending = storage === undefined || measuring;
return (
<td
className="px-5 py-3 text-right text-sm tabular-nums text-fg-muted"
className="px-4 py-3 text-sm tabular-nums text-fg-muted"
title={pending ? undefined : t("storageNone")}
>
{pending ? t("storageMeasuring") : "—"}
</td>
);
}
const share = disk ? pct(storage.bytes, disk.total) : null;
return (
<td
className="px-5 py-3 text-right tabular-nums"
title={storage.partial ? t("storagePartial") : undefined}
className="px-4 py-3 tabular-nums"
title={
storage.partial
? t("storagePartial")
: t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })
}
>
<div className="text-sm text-fg-primary">
{storage.partial ? "≥ " : ""}
{formatBytes(storage.bytes)}
{share != null && <span className="text-[11px] text-fg-muted"> ({fmtPct(share)})</span>}
</div>
<div className="text-[11px] text-fg-muted">
{t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })}
</div>
{share != null && <Meter value={share} label={t("storage")} />}
</td>
);
}
@@ -1,14 +1,14 @@
/**
* @file Administration.test.tsx
* @description The Administration page (`/admin`): local mode and a developer
* are sent home; staff land on the Users tab; the Server tab shows the
* machine's hardware, every disk, and each user's CPU/memory/home share from
* `GET /api/host`, with a user's busiest processes on click.
* are sent home; staff see ONE page, no tabs, and no machine section (that is
* the Dashboard's System Health tab now, each user's live CPU / RAM included)
* — the users table carries each user's home share (`GET /api/host`) and tmux.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { AuthState, HostStats } from "../../lib/api";
@@ -73,7 +73,7 @@ const api = vi.hoisted(() => ({
list: vi.fn(),
audit: vi.fn(),
storage: vi.fn(),
tmux: vi.fn(async () => ({ available: false, users: [] })),
tmux: vi.fn(),
avatarUrl: () => null,
},
}));
@@ -137,6 +137,7 @@ beforeEach(() => {
],
});
api.users.audit.mockResolvedValue({ entries: [] });
api.users.tmux.mockResolvedValue({ available: false, users: [] });
api.users.storage.mockResolvedValue({
storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } },
measuring: false,
@@ -153,27 +154,40 @@ describe("Administration", () => {
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
});
it("opens on the Users tab", async () => {
it("shows the users on one page, with no tabs and no machine section", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin");
expect(await screen.findByTestId("users-table")).toBeTruthy();
expect(screen.getByRole("tab", { name: /users/i }).getAttribute("aria-selected")).toBe("true");
expect(screen.queryByTestId("server-facts")).toBeNull();
expect(screen.queryByRole("tab", { name: "Server" })).toBeNull();
expect(screen.queryByRole("tab", { name: "Users" })).toBeNull();
});
it("shows hardware and each user's share on the Server tab", async () => {
it("puts each user's home and tmux in the users table, but not their CPU", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin?tab=server");
const table = await screen.findByTestId("server-usage-table");
expect(await screen.findByText("box")).toBeTruthy();
expect(screen.getByTestId("server-disks-table").textContent).toContain("HDD");
const mia = await within(table).findByText("Mia");
const row = mia.closest("tr")!;
expect(row.textContent).toContain("25%");
expect(row.textContent).toContain("(13%)");
// 20 GB home on the 200 GB homes disk.
expect(row.textContent).toContain("10%");
expect(within(table).queryByText("claude")).toBeNull();
fireEvent.click(mia.closest("button")!);
expect(within(table).getByText("claude")).toBeTruthy();
api.users.tmux.mockResolvedValue({
available: true,
installed: "3.8",
matched: true,
users: [
{
id: 2,
username: "mia",
running: true,
version: "3.6",
sessions: 2,
busy: 1,
outdated: true,
},
],
});
renderAt("/admin");
const users = await screen.findByTestId("users-table");
const row = (await within(users).findByRole("button", { name: /^Mia/ })).closest("tr")!;
// 20 GB home on the 200 GB homes disk (the host read, once).
await waitFor(() => expect(row.textContent).toContain("(10%)"));
expect(row.textContent).not.toContain("25%");
expect(row.textContent).toContain("older than installed");
expect(within(row).getByRole("button", { name: "Restart mia's tmux" })).toBeTruthy();
});
});
+74 -1
View File
@@ -6,7 +6,9 @@
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
* separate role/password/workspace/disable buttons any more), and sees the
* display name and email under a username; right-clicking a row opens that
* user's home in Files (an admin) — an analyst's menu only copies.
* user's home in Files (an admin) — an analyst's menu only copies; clicking a
* user's name expands their detail under the row (`?user=`), with spend and
* pool share, and a second click folds it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -41,6 +43,8 @@ const users = vi.hoisted(() => ({
setPassword: vi.fn(),
assignWorkspace: vi.fn(),
get: vi.fn(),
details: vi.fn(),
avatarUrl: vi.fn(),
// No S3 here: the members' tmux card stays hidden.
tmux: vi.fn(async () => ({ available: false, users: [] })),
}));
@@ -96,7 +100,76 @@ beforeEach(() => {
}));
});
const details = {
user: {
id: 2,
username: "mia",
role: "developer",
disabled: false,
displayName: "Mia Tran",
logins: 1,
lastLoginAt: null,
},
sessions: { total: 3, active: 1, firstAt: null, lastAt: null },
usage: {
today: { cost: 1, tokens: 10 },
last7d: { cost: 2, tokens: 20 },
last30d: { cost: 4, tokens: 40 },
daily: [{ date: "2026-10-27", cost: 4, tokens: 40 }],
byTool: [{ key: "codex", cost: 4, tokens: 40, sessions: 1 }],
byModel: [{ key: "gpt-5.5", cost: 4, tokens: 40, sessions: 1 }],
},
workspaces: [],
panes: [],
recentSessions: [],
pool: {
claude: {
pin: "team-1",
group: null,
accounts: [
{
name: "team-1",
kind: "token",
email: null,
expired: false,
allowed: true,
pinned: true,
quota: null,
quotaPercent: null,
full: false,
livePanes: 0,
spend5h: 0,
spend7d: 10,
userSpend5h: 0,
userSpend7d: 2.5,
userTokens5h: 0,
userTokens7d: 99,
userRuns7d: 2,
userLivePanes: 0,
userLastAt: null,
},
],
},
},
audit: [],
};
describe("Users page", () => {
it("expands a user's detail under their row, and folds it again", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.details.mockResolvedValue(details);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "mia" }));
const pool = await screen.findByTestId("user-pool-account");
expect(users.details).toHaveBeenCalledWith(2);
const page = screen.getByTestId("user-detail");
expect(within(screen.getByTestId("users-table")).getByTestId("user-detail")).toBe(page);
expect(within(pool).getByText("team-1")).toBeTruthy();
expect(within(pool).getByText(/25%/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "mia" }));
await waitFor(() => expect(screen.queryByTestId("user-detail")).toBeNull());
});
it("sends local mode and a member home", async () => {
authState = { mode: "local" };
renderPage();