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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+798
-791
File diff suppressed because it is too large
Load Diff
+175
-96
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user