1c6dfea5b5
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.
194 lines
5.8 KiB
TypeScript
194 lines
5.8 KiB
TypeScript
/**
|
|
* @file Administration.test.tsx
|
|
* @description The Administration page (`/admin`): local mode and a developer
|
|
* 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 { render, screen, waitFor, within } from "@testing-library/react";
|
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
|
import type { AuthState, HostStats } from "../../lib/api";
|
|
|
|
let authState: AuthState = { mode: "local" };
|
|
vi.mock("../../components/AuthGate", () => ({
|
|
useAuth: () => ({ state: authState, logout: async () => {} }),
|
|
}));
|
|
|
|
const GB = 1024 ** 3;
|
|
const stats: HostStats = {
|
|
at: "2026-10-15T00:00:00Z",
|
|
hostname: "box",
|
|
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
|
|
uptime: 90000,
|
|
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
|
|
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
|
|
disks: [
|
|
{
|
|
device: "/dev/sdb2",
|
|
mount: "/",
|
|
type: "ext4",
|
|
kind: "SSD",
|
|
total: 200 * GB,
|
|
used: 50 * GB,
|
|
free: 150 * GB,
|
|
},
|
|
{
|
|
device: "/dev/sda1",
|
|
mount: "/data",
|
|
type: "ext4",
|
|
kind: "HDD",
|
|
total: 500 * GB,
|
|
used: 0,
|
|
free: 500 * GB,
|
|
},
|
|
],
|
|
homesMount: "/",
|
|
gpus: [],
|
|
usage: {
|
|
cpu: 40,
|
|
sampleMs: 1000,
|
|
groups: [
|
|
{
|
|
key: "user:2",
|
|
kind: "user",
|
|
userId: 2,
|
|
username: "mia",
|
|
cpu: 25,
|
|
memory: 2 * GB,
|
|
memoryPercent: 12.5,
|
|
processes: 3,
|
|
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
|
|
},
|
|
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
|
|
],
|
|
},
|
|
};
|
|
|
|
const api = vi.hoisted(() => ({
|
|
host: { stats: vi.fn() },
|
|
users: {
|
|
list: vi.fn(),
|
|
audit: vi.fn(),
|
|
storage: vi.fn(),
|
|
tmux: vi.fn(),
|
|
avatarUrl: () => null,
|
|
},
|
|
}));
|
|
vi.mock("../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
...api,
|
|
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
|
accountPool: {
|
|
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
|
profiles: vi.fn(async () => ({ profiles: [] })),
|
|
},
|
|
aiTools: {
|
|
list: vi.fn(async () => ({ tools: [], default: null })),
|
|
ompSharing: vi.fn(async () => ({ available: false })),
|
|
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
import { Administration } from "../Administration";
|
|
|
|
const renderAt = (url: string) =>
|
|
render(
|
|
<MemoryRouter initialEntries={[url]}>
|
|
<Routes>
|
|
<Route path="/admin" element={<Administration />} />
|
|
<Route path="/" element={<div data-testid="home" />} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
);
|
|
|
|
beforeEach(() => {
|
|
api.host.stats.mockResolvedValue(stats);
|
|
api.users.list.mockResolvedValue({
|
|
users: [
|
|
{
|
|
id: 1,
|
|
username: "boss",
|
|
role: "admin",
|
|
disabled: false,
|
|
workspaces: 0,
|
|
sessions: 0,
|
|
last7d: { cost: 0, tokens: 0 },
|
|
last30d: { cost: 0, tokens: 0 },
|
|
},
|
|
{
|
|
id: 2,
|
|
username: "mia",
|
|
role: "developer",
|
|
displayName: "Mia",
|
|
disabled: false,
|
|
workspaces: 1,
|
|
sessions: 2,
|
|
last7d: { cost: 0, tokens: 0 },
|
|
last30d: { cost: 0, tokens: 0 },
|
|
},
|
|
],
|
|
});
|
|
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,
|
|
});
|
|
});
|
|
|
|
describe("Administration", () => {
|
|
it("sends local mode and a developer home", async () => {
|
|
authState = { mode: "local" };
|
|
renderAt("/admin");
|
|
expect(await screen.findByTestId("home")).toBeTruthy();
|
|
authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
|
|
renderAt("/admin");
|
|
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
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.queryByTestId("server-facts")).toBeNull();
|
|
expect(screen.queryByRole("tab", { name: "Server" })).toBeNull();
|
|
expect(screen.queryByRole("tab", { name: "Users" })).toBeNull();
|
|
});
|
|
|
|
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" } };
|
|
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();
|
|
});
|
|
});
|