feat(health): hovering a user tile shows their login, role and today's spend

System Health's Users · CPU / RAM card: each tile's hover card names the login
name; for an admin or analyst it adds role, $ spent today, workspaces,
sessions and last sign-in from GET /api/users (staff-only, re-read every 60 s).
A developer's card shows the login name alone and never calls /api/users.
This commit is contained in:
2026-11-03 08:38:48 +07:00
parent cd6a1d36ff
commit 75a4693f27
5 changed files with 154 additions and 48 deletions
+91 -43
View File
@@ -11,7 +11,9 @@
* GPUs), disks (how full, read / write rate) with network interfaces, and the
* busiest processes with who each counts for (history, CPU threads and
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
* for their login name and — admin/analyst, from `GET /api/users` — role, $ spent
* today, workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
* Administration users table shares for each home's share of the homes disk.
* Read-only.
@@ -38,14 +40,17 @@ import {
type HostHistoryRange,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
} from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
import { usePermissions } from "../AuthGate";
import { Tip } from "../Tip";
import { MiniChart } from "../admin/MiniChart";
import { UserAvatar } from "../users/UserAvatar";
const POLL_MS = 5000;
const HISTORY_POLL_MS = 30000;
const USERS_POLL_MS = 60000;
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
@@ -324,6 +329,25 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const [history, setHistory] = useState<HostHistory | null>(null);
// The user tile whose busiest processes are shown.
const [open, setOpen] = useState<string | null>(null);
// Each account's role and spend for the tiles' hover card. `GET /api/users`
// is admin/analyst only, so a developer's card shows the login name alone.
const { seesAll, local } = usePermissions();
const { t: tu } = useTranslation("users");
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
useEffect(() => {
if (!seesAll || local || !hasPeople) return;
const load = () =>
api.users
.list()
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
.catch(() => {});
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, USERS_POLL_MS);
return () => clearInterval(id);
}, [seesAll, local, hasPeople]);
const loadHistory = useCallback(
() =>
@@ -365,6 +389,28 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
if (key === "system") return t("host.systemGroup");
return key;
};
const userTip = (g: HostUsageGroup) => {
const lines = [groupName(g)];
if (g.username) lines.push(t("host.userTipLogin", { v: g.username }));
const u = g.userId != null ? accounts.get(g.userId) : undefined;
if (u) {
lines.push(
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
);
if (u.today)
lines.push(
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
);
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
lines.push(
t("host.userTipLastLogin", {
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
})
);
}
return lines.join("\n");
};
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
const timeLabel = (iso: string) =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
@@ -675,47 +721,49 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const name = groupName(g);
const isOpen = open === g.key;
return (
<div key={g.key} className="space-y-2" data-testid="server-user">
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
{isOpen && (
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
{g.top.map((p) => (
<li
key={p.pid}
className="flex justify-between gap-2 text-[10px] font-mono"
>
<span className="truncate text-fg-secondary">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</span>
<span className="flex-shrink-0 text-fg-muted">
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
</span>
</li>
))}
</ul>
)}
</div>
<Tip key={g.key} raw={userTip(g)} block>
<div className="space-y-2" data-testid="server-user">
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
{isOpen && (
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
{g.top.map((p) => (
<li
key={p.pid}
className="flex justify-between gap-2 text-[10px] font-mono"
>
<span className="truncate text-fg-secondary">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</span>
<span className="flex-shrink-0 text-fg-muted">
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
</span>
</li>
))}
</ul>
)}
</div>
</Tip>
);
})}
</div>
@@ -7,7 +7,8 @@
* rate and each network interface, the CPU / memory history with its range
* switch (`GET /api/host/history`), and the busiest processes with who each
* counts for, the processes of no user in a short list of their own, and
* each user's live CPU / RAM as a tile (their busiest processes on click);
* each user's live CPU / RAM as a tile (their busiest processes on click; on
* hover their login name, and for staff their role and $ spent today);
* off Linux the cards it can never fill are left out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -69,8 +70,10 @@ const stats: HostStats = {
const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null },
users: { avatarUrl: () => null },
users: { avatarUrl: () => null, list: vi.fn() },
}));
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api };
@@ -105,6 +108,7 @@ const full: HostStats = {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
api.host.stats.mockResolvedValue(full);
api.host.history.mockResolvedValue({
range: "1h",
@@ -182,6 +186,44 @@ describe("HostHealth", () => {
expect(within(tile).getByText("claude")).toBeTruthy();
});
it("names the login, role and today's spend when a user tile is hovered", async () => {
Object.assign(perms, { local: false });
api.users.list.mockResolvedValue({
users: [
{
id: 2,
username: "mia",
role: "developer",
disabled: false,
workspaces: 2,
sessions: 5,
today: { cost: 1.5, tokens: 12000 },
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
lastLoginAt: null,
},
],
});
render(<Tab />);
const tile = await screen.findByTestId("server-user");
await vi.waitFor(() => expect(api.users.list).toHaveBeenCalled());
fireEvent.mouseEnter(tile.parentElement!);
const tip = await screen.findByText(/Login: mia/);
expect(tip.textContent).toContain("Role: Developer");
expect(tip.textContent).toMatch(/Today: \$1\.5/);
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
});
it("shows a developer only the login name, without reading the users list", async () => {
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
render(<Tab />);
const tile = await screen.findByTestId("server-user");
fireEvent.mouseEnter(tile.parentElement!);
const tip = await screen.findByText(/Login: mia/);
expect(tip.textContent).not.toContain("Today");
expect(api.users.list).not.toHaveBeenCalled();
});
it("leaves out what Windows cannot report instead of empty cards", async () => {
api.host.stats.mockResolvedValue({
...stats,
+8 -1
View File
@@ -182,6 +182,13 @@
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
"agenthub": "AgentHub",
"usersTitle": "Users · CPU / RAM now",
"usersCount": "{{n}} users"
"usersCount": "{{n}} users",
"userTipLogin": "Login: {{v}}",
"userTipRole": "Role: {{v}}",
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
"userTipWork": "{{w}} workspaces · {{s}} sessions",
"userTipLastLogin": "Last sign-in: {{v}}",
"userTipNever": "never",
"userTipDisabled": "Disabled"
}
}
+8 -1
View File
@@ -182,6 +182,13 @@
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
"agenthub": "AgentHub",
"usersTitle": "Người dùng · CPU / RAM hiện tại",
"usersCount": "{{n}} người dùng"
"usersCount": "{{n}} người dùng",
"userTipLogin": "Tên đăng nhập: {{v}}",
"userTipRole": "Vai trò: {{v}}",
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
"userTipWork": "{{w}} workspace · {{s}} phiên",
"userTipLastLogin": "Đăng nhập gần nhất: {{v}}",
"userTipNever": "chưa bao giờ",
"userTipDisabled": "Đã vô hiệu hóa"
}
}