diff --git a/client/src/components/health/HostHealth.tsx b/client/src/components/health/HostHealth.tsx index 56c9e57a..9fa70b3c 100644 --- a/client/src/components/health/HostHealth.tsx +++ b/client/src/components/health/HostHealth.tsx @@ -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(null); // The user tile whose busiest processes are shown. const [open, setOpen] = useState(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>(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 ( -
- - - - {isOpen && ( -
    - {g.top.map((p) => ( -
  • - - {p.name} #{p.pid} - - - {fmtPct(p.cpu)} · {formatBytes(p.memory)} - -
  • - ))} -
- )} -
+ +
+ + + + {isOpen && ( +
    + {g.top.map((p) => ( +
  • + + {p.name} #{p.pid} + + + {fmtPct(p.cpu)} · {formatBytes(p.memory)} + +
  • + ))} +
+ )} +
+
); })} diff --git a/client/src/components/health/__tests__/HostHealth.test.tsx b/client/src/components/health/__tests__/HostHealth.test.tsx index 35c714ce..b7dfcabf 100644 --- a/client/src/components/health/__tests__/HostHealth.test.tsx +++ b/client/src/components/health/__tests__/HostHealth.test.tsx @@ -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ĩ */ @@ -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>(); 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(); + 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(); + 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, diff --git a/client/src/i18n/locales/en/dashboard.json b/client/src/i18n/locales/en/dashboard.json index 95ea49f7..ada89554 100644 --- a/client/src/i18n/locales/en/dashboard.json +++ b/client/src/i18n/locales/en/dashboard.json @@ -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" } } diff --git a/client/src/i18n/locales/vi/dashboard.json b/client/src/i18n/locales/vi/dashboard.json index a292ff53..32b884eb 100644 --- a/client/src/i18n/locales/vi/dashboard.json +++ b/client/src/i18n/locales/vi/dashboard.json @@ -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" } } diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 40fe4283..bdd5e87b 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -188,7 +188,9 @@ Workspaces made before server mode, or by the host account, have none. load, memory and swap, every real disk (HDD or SSD, how full; the one holding the users' homes is marked), any NVIDIA GPU. Each user's live share of it is a **Users · CPU / RAM now** card on the same tab (a tile per - user, their busiest processes on click; their home share stays in + user, their busiest processes on click; on hover their login name, and for an + admin or analyst — from `GET /api/users` — their role, $ spent today, + workspaces, sessions and last sign-in; their home share stays in Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and resident memory over a 1 s `/proc` sample, a process count, the busiest processes by their kernel name (never argv or env), and their home's size