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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user