Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33d30f5e1f | |||
| 14e979837e | |||
| fa27b299e7 | |||
| 9e3d6f4ca1 | |||
| 26aabd6660 | |||
| 9d07f64154 | |||
| 0f17efe3b4 | |||
| 4999325210 | |||
| 1c6dfea5b5 |
@@ -327,6 +327,7 @@ graph TD
|
||||
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
||||
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
|
||||
| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
|
||||
| `lib/user-quota.js` | Server mode's spend quota per user (`user_quotas`, row 0 = the default; `PUT /api/users/quota-default`, `PATCH /api/users/:id {quota}`, `GET /api/me/quota`): `limit_usd` per local `day` or sliding `5h`, spend = the users page's `hourlyTokenRows` + `calculateCost` over owned sessions. Over it: `spawnRun` throws `EQUOTA` (429); a 60 s `sweep` writes each pane's `AGENTHUB_QUOTA_FILE` (the hook handler then exits 2 on `UserPromptSubmit`, from the file alone, fail-open), leaves the owner's omp keys out of the gateway's clients file and ends panes no hook can stop |
|
||||
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
|
||||
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
|
||||
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the session
|
||||
* header looks as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* its fullest window, live account only — and, on hover or focus, a panel
|
||||
* above grouping every account per tool (saved Claude profiles too): name,
|
||||
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||
* Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the
|
||||
* costs) as "Quota N%" — amber from 80 %, red once used up, when the user can
|
||||
* start nothing and their prompts are refused until it opens again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -30,9 +33,9 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
@@ -369,6 +372,8 @@ export function StatusBar({
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
// The budget furthest past one of its thresholds, if any.
|
||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||
// Server mode: this user's own spend quota (null: none, or local mode).
|
||||
const [myQuota, setMyQuota] = useState<UserQuotaStatus | null>(null);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
@@ -414,6 +419,10 @@ export function StatusBar({
|
||||
if (live()) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
api.me
|
||||
.quota()
|
||||
.then((r) => live() && setMyQuota(r?.quota ?? null))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
@@ -524,6 +533,26 @@ export function StatusBar({
|
||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||
</Link>
|
||||
)}
|
||||
{myQuota && (
|
||||
<span
|
||||
data-testid="status-bar-user-quota"
|
||||
title={t(myQuota.exceeded ? "statusBar.userQuotaBlocked" : "statusBar.userQuotaTitle", {
|
||||
spend: usd(myQuota.spend),
|
||||
limit: usd(myQuota.limit_usd),
|
||||
period: t(`statusBar.userQuotaPeriod.${myQuota.period}`),
|
||||
at: myQuota.resetsAt ? formatDateTime(myQuota.resetsAt) : "",
|
||||
})}
|
||||
className={`tabular-nums ${
|
||||
myQuota.exceeded
|
||||
? "text-status-danger"
|
||||
: myQuota.pct >= 80
|
||||
? "text-status-warning"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })}
|
||||
</span>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
me: {
|
||||
// Server mode: this user's own spend quota, used up.
|
||||
quota: vi.fn().mockResolvedValue({
|
||||
quota: {
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
spend: 12,
|
||||
pct: 120,
|
||||
exceeded: true,
|
||||
resetsAt: "2026-10-29T17:00:00.000Z",
|
||||
since: "2026-10-28T17:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
},
|
||||
budgets: {
|
||||
status: vi.fn().mockResolvedValue({
|
||||
budgets: [
|
||||
@@ -183,6 +197,16 @@ describe("StatusBar", () => {
|
||||
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
||||
});
|
||||
|
||||
it("shows the user's own spend quota, red once used up", async () => {
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chip = await screen.findByTestId("status-bar-user-quota");
|
||||
expect(chip.textContent).toBe("Quota 120%");
|
||||
expect(chip.className).toContain("text-status-danger");
|
||||
expect(chip.getAttribute("title")).toMatch(
|
||||
/^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/
|
||||
);
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @file MiniChart.tsx
|
||||
* @description The Administration page's small single-series charts: a bar
|
||||
* chart (a user's daily spend) and a line chart (System Health's CPU and
|
||||
* memory history), plain SVG in the app's theme tokens. One series each — the
|
||||
* card's title names it, so there is no legend — thin marks, rounded bar ends
|
||||
* on the baseline, a recessive grid with a max label, and a hover tooltip
|
||||
* (crosshair for the line, per bar for the bars) whose text is the caller's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
|
||||
export type ChartPoint = { x: string; y: number };
|
||||
|
||||
const H = 120;
|
||||
const PAD_TOP = 14;
|
||||
const PAD_BOTTOM = 18;
|
||||
|
||||
/**
|
||||
* @param props.points - In x order; `x` is the axis/tooltip label.
|
||||
* @param props.kind - `bar` or `line`.
|
||||
* @param props.format - Formats a value for the max label and the tooltip.
|
||||
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
|
||||
* @param props.label - The chart's accessible name.
|
||||
*/
|
||||
export function MiniChart({
|
||||
points,
|
||||
kind,
|
||||
format,
|
||||
max,
|
||||
label,
|
||||
emptyText,
|
||||
}: {
|
||||
points: ChartPoint[];
|
||||
kind: "bar" | "line";
|
||||
format: (v: number) => string;
|
||||
max?: number;
|
||||
label: string;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
if (points.length === 0)
|
||||
return (
|
||||
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
|
||||
{emptyText ?? "—"}
|
||||
</div>
|
||||
);
|
||||
|
||||
const W = 600;
|
||||
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
|
||||
const plotH = H - PAD_TOP - PAD_BOTTOM;
|
||||
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
|
||||
const step = W / points.length;
|
||||
const xOf = (i: number) =>
|
||||
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
|
||||
|
||||
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r || r.width === 0) return;
|
||||
const fx = ((e.clientX - r.left) / r.width) * W;
|
||||
const i =
|
||||
kind === "bar"
|
||||
? Math.floor(fx / step)
|
||||
: Math.round((fx / W) * Math.max(1, points.length - 1));
|
||||
setHover(Math.max(0, Math.min(points.length - 1, i)));
|
||||
};
|
||||
|
||||
const h = hover != null ? points[hover] : null;
|
||||
const barW = Math.max(2, Math.min(24, step - 2));
|
||||
const line = points
|
||||
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="block h-[120px] w-full"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 0.5, 1].map((f) => (
|
||||
<line
|
||||
key={f}
|
||||
x1={0}
|
||||
x2={W}
|
||||
y1={yOf(top * f)}
|
||||
y2={yOf(top * f)}
|
||||
className="stroke-border"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeDasharray={f === 0 ? undefined : "3 3"}
|
||||
/>
|
||||
))}
|
||||
{kind === "bar" ? (
|
||||
points.map((p, i) => {
|
||||
const y = yOf(p.y);
|
||||
const bh = Math.max(0, PAD_TOP + plotH - y);
|
||||
const r = Math.min(4, barW / 2, bh);
|
||||
const x = xOf(i) - barW / 2;
|
||||
const base = PAD_TOP + plotH;
|
||||
// Rounded top only; the base sits flat on the axis.
|
||||
const d =
|
||||
bh <= 0
|
||||
? ""
|
||||
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
|
||||
return (
|
||||
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
|
||||
className="fill-accent-solid"
|
||||
opacity={0.12}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
className="stroke-accent-solid"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{hover != null && (
|
||||
<line
|
||||
x1={xOf(hover)}
|
||||
x2={xOf(hover)}
|
||||
y1={PAD_TOP}
|
||||
y2={PAD_TOP + plotH}
|
||||
className="stroke-fg-muted"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
|
||||
{format(top)}
|
||||
</span>
|
||||
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
|
||||
<span>{points[0]!.x}</span>
|
||||
<span>{points[points.length - 1]!.x}</span>
|
||||
</div>
|
||||
{h && hover != null && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
|
||||
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
|
||||
role="status"
|
||||
>
|
||||
<div className="text-fg-muted">{h.x}</div>
|
||||
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* @file ServerPanel.tsx
|
||||
* @description The Administration page's Server tab: the machine AgentHub
|
||||
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
||||
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
||||
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
||||
* machine, resident memory, process count, the busiest processes by name, and
|
||||
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
||||
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
||||
* at once. Read-only — the only action here is the members' tmux card
|
||||
* (`MemberTmuxCard`, an admin's restart).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
MonitorCog,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { StatCard } from "../StatCard";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Names, pictures and home sizes: read once, they change slowly.
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
api.users
|
||||
.storage()
|
||||
.then((r) => setStorage(r.storage))
|
||||
.catch(() => setStorage({}));
|
||||
}, []);
|
||||
|
||||
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||
const loading = !stats && !error;
|
||||
const mem = stats?.memory;
|
||||
const toggle = (key: string) =>
|
||||
setOpen((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("server.hostGroup");
|
||||
if (g.kind === "system") return t("server.systemGroup");
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
return u?.displayName || g.username || "";
|
||||
};
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="server-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
||||
</h2>
|
||||
{stats && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
||||
{" · "}
|
||||
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("server.cpu")}
|
||||
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
||||
icon={Cpu}
|
||||
trend={
|
||||
stats
|
||||
? t("server.cores", {
|
||||
n: stats.cpu.cores,
|
||||
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
raw={stats?.cpu.model ?? undefined}
|
||||
help={t("server.cpuHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.memory")}
|
||||
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
||||
icon={MemoryStick}
|
||||
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.swap")}
|
||||
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
||||
icon={MonitorCog}
|
||||
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.homesDisk")}
|
||||
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
||||
icon={HardDrive}
|
||||
trend={
|
||||
homesDisk
|
||||
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
||||
: undefined
|
||||
}
|
||||
help={t("server.homesDiskHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</p>
|
||||
</div>
|
||||
{stats && !stats.usage ? (
|
||||
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.who")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
||||
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
||||
{t("server.sampling")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{stats?.usage?.groups.map((g) => {
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
||||
const isOpen = open.has(g.key);
|
||||
const name = groupName(g);
|
||||
return (
|
||||
<Fragment key={g.key}>
|
||||
<tr className="hover:bg-surface-4/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
{g.top.length ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
{g.kind === "user" ? (
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={u?.avatarAt}
|
||||
userId={g.userId}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
||||
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
||||
{g.kind === "user" && u?.displayName && (
|
||||
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
||||
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(g.memory)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
||||
</div>
|
||||
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{g.processes}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{home ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(home.bytes)}
|
||||
{homesDisk && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
({fmtPct(pct(home.bytes, homesDisk.total))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{homesDisk && (
|
||||
<Meter
|
||||
value={pct(home.bytes, homesDisk.total)}
|
||||
label={`${name} ${t("server.home")}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-fg-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen &&
|
||||
g.top.map((p) => (
|
||||
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
||||
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{fmtPct(p.cpu)}
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{formatBytes(p.memory)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.disks.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("server.disks")}
|
||||
</h3>
|
||||
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.mount")}</th>
|
||||
<th className={th}>{t("server.device")}</th>
|
||||
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.free")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{stats.disks.map((d) => (
|
||||
<tr key={d.device}>
|
||||
<td className="px-5 py-3 text-sm text-fg-primary">
|
||||
<span className="font-mono">{d.mount}</span>
|
||||
{d.mount === stats.homesMount && (
|
||||
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-fg-secondary">
|
||||
<span className="font-mono">{d.device}</span> · {d.type}
|
||||
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
||||
</div>
|
||||
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{formatBytes(d.free)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.gpus.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
||||
{stats.gpus.map((g, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-fg-primary">{g.name}</span>
|
||||
<span className="text-fg-muted tabular-nums">
|
||||
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
||||
{formatBytes(g.memoryTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<Meter value={g.utilization} label={g.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* @file HostHealth.tsx
|
||||
* @description The machine AgentHub runs on, as part of the Dashboard's
|
||||
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
|
||||
* signed-in user sees all of it). In the tab's own card style:
|
||||
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
|
||||
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
|
||||
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
|
||||
* CPU / memory history (`GET /api/host/history`, kept in memory since the
|
||||
* server started); then CPU (every thread as a compact cell, temperatures,
|
||||
* 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
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Activity,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
ListTree,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostHistory,
|
||||
type HostHistoryRange,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { Tip } from "../Tip";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const HISTORY_POLL_MS = 30000;
|
||||
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||
|
||||
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
|
||||
export type HostState = {
|
||||
stats: HostStats | null;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void reload();
|
||||
if (!pollMs) return;
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void reload();
|
||||
}, pollMs);
|
||||
return () => clearInterval(id);
|
||||
}, [enabled, pollMs, reload]);
|
||||
return { stats, error, reload };
|
||||
}
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
export function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The System Health tab's own bar: h-2, green / amber / red by level. */
|
||||
function HealthBar({
|
||||
label,
|
||||
value,
|
||||
tip,
|
||||
warn = 70,
|
||||
bad = 90,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
tip?: string;
|
||||
warn?: number;
|
||||
bad?: number;
|
||||
}) {
|
||||
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
|
||||
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
|
||||
return (
|
||||
<Tip block raw={tip}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-fg-muted">{label}</span>
|
||||
<span className="text-fg-secondary font-mono">
|
||||
{value == null ? "—" : `${Math.round(v)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="w-full bg-surface-3 rounded-full h-2"
|
||||
>
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
|
||||
style={{ width: `${v}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
|
||||
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
|
||||
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-fg-muted">{n}</span>
|
||||
<span className={tone}>{Math.round(v)}%</span>
|
||||
</div>
|
||||
<Meter value={v} label={label} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHead({
|
||||
icon: Icon,
|
||||
tone,
|
||||
title,
|
||||
right,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
tone: string;
|
||||
title: string;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
|
||||
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
|
||||
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
|
||||
export type HeapInfo = {
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
heapLimit?: number;
|
||||
external: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The tab's first card: the machine and the AgentHub process on it.
|
||||
* `heap` comes from the tab's settings read.
|
||||
*/
|
||||
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const s = host.stats;
|
||||
const mem = s?.memory;
|
||||
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
|
||||
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
|
||||
return (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
|
||||
<CardHead
|
||||
icon={Server}
|
||||
tone="text-status-success"
|
||||
title={t("host.title")}
|
||||
right={
|
||||
s && (
|
||||
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
|
||||
{s.hostname}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!s ? (
|
||||
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2.5">
|
||||
<Row label={t("host.os")}>
|
||||
{s.os.distro ?? s.os.platform} · {s.os.release}
|
||||
</Row>
|
||||
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
|
||||
<Row label={t("host.cpuModel")}>
|
||||
<Tip raw={s.cpu.model ?? undefined}>
|
||||
<span>
|
||||
{s.cpu.physicalCores
|
||||
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
|
||||
: ""}
|
||||
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
{s.os.platform !== "win32" && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||||
{t("host.load")}
|
||||
</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{s.cpu.load.slice(0, 3).map((load, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{load.toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{s.process && (
|
||||
<Row label={t("host.agenthub")}>
|
||||
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
|
||||
<span>
|
||||
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{s.usage && (
|
||||
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
|
||||
)}
|
||||
<HealthBar
|
||||
label={t("host.memory")}
|
||||
value={mem ? pct(mem.used, mem.total) : null}
|
||||
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
/>
|
||||
{mem && mem.swapTotal > 0 && (
|
||||
<HealthBar
|
||||
label={t("host.swap")}
|
||||
value={pct(mem.swapUsed, mem.swapTotal)}
|
||||
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
|
||||
/>
|
||||
)}
|
||||
{heap && (
|
||||
<HealthBar
|
||||
label={t("health.heap")}
|
||||
value={heapPct}
|
||||
warn={60}
|
||||
bad={85}
|
||||
tip={t("health.heapTip", {
|
||||
pct: (heapPct ?? 0).toFixed(1),
|
||||
used: formatBytes(heap.heapUsed),
|
||||
limit: formatBytes(heapMax),
|
||||
allocated: formatBytes(heap.heapTotal),
|
||||
external: formatBytes(heap.external),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tab's machine group: host + history, CPU / disks / processes, users. */
|
||||
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { stats } = host;
|
||||
const [range, setRange] = useState<HostHistoryRange>("1h");
|
||||
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||
// The user tile whose busiest processes are shown.
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
const loadHistory = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.history(range)
|
||||
.then(setHistory)
|
||||
.catch(() => setHistory(null)),
|
||||
[range]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void loadHistory();
|
||||
}, HISTORY_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [loadHistory]);
|
||||
|
||||
// Same tree as the full render below, so the card is not remounted once stats arrive.
|
||||
if (!stats)
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const groups = stats.usage?.groups ?? [];
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("host.hostGroup");
|
||||
if (g.kind === "system") return t("host.systemGroup");
|
||||
return g.displayName || g.username || "";
|
||||
};
|
||||
const groupByKey = new Map(groups.map((g) => [g.key, g]));
|
||||
const ownerOf = (key: string) => {
|
||||
const g = groupByKey.get(key);
|
||||
if (g) return groupName(g);
|
||||
if (key === "host") return t("host.hostGroup");
|
||||
if (key === "system") return t("host.systemGroup");
|
||||
return key;
|
||||
};
|
||||
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const histPoints = (key: "cpu" | "memory") =>
|
||||
(history?.points ?? [])
|
||||
.filter((p) => p[key] != null)
|
||||
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
|
||||
const perCore = stats.cpu.perCore ?? [];
|
||||
const network = stats.network ?? [];
|
||||
const temps = stats.temperatures ?? [];
|
||||
const topProcs = stats.topProcesses ?? [];
|
||||
const others = groups.filter((g) => g.kind !== "user");
|
||||
const people = groups.filter((g) => g.kind === "user");
|
||||
// History, per-thread CPU and processes are read from /proc: on Windows and
|
||||
// macOS their cards would only ever be empty, so they are left out.
|
||||
const linux = stats.os.platform === "linux";
|
||||
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
|
||||
const showProcs = !!stats.usage || others.length > 0;
|
||||
|
||||
// A whole disk's I/O goes on the first mounted file system that lives on it
|
||||
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
|
||||
// mounted gets a row of its own.
|
||||
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
|
||||
const diskRows = stats.disks.map((d) => {
|
||||
const base = d.device.split("/").pop() ?? "";
|
||||
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
|
||||
const io = key ? ioLeft.get(key) : undefined;
|
||||
if (key) ioLeft.delete(key);
|
||||
return { disk: d, io };
|
||||
});
|
||||
const loneIo = [...ioLeft.values()];
|
||||
|
||||
const rate = (down?: number, up?: number) => (
|
||||
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowDown className="w-2.5 h-2.5" />
|
||||
{down == null ? "—" : perSec(down)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowUp className="w-2.5 h-2.5" />
|
||||
{up == null ? "—" : perSec(up)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
|
||||
{/* CPU and memory over time. */}
|
||||
{linux && (
|
||||
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
|
||||
<CardHead
|
||||
icon={Activity}
|
||||
tone="text-accent"
|
||||
title={t("host.historyTitle")}
|
||||
right={
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("host.range")}
|
||||
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
|
||||
>
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-secondary"
|
||||
: "text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{t(`host.range${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("cpu")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyCpu")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("memory")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyMemory")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{history?.startedAt && (
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{t("host.historyHelp", {
|
||||
s: history.intervalMs / 1000,
|
||||
at: formatDateTime(history.startedAt),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
|
||||
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
|
||||
{showCores && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
|
||||
<CardHead
|
||||
icon={Cpu}
|
||||
tone="text-syntax-keyword"
|
||||
title={t("host.cpu")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{perCore.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
|
||||
{perCore.map((v, i) => (
|
||||
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{temps.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-temperatures"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
<Thermometer className="w-3 h-3 text-status-warning" />
|
||||
{t("host.temperatures")}
|
||||
</div>
|
||||
{temps.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
|
||||
{s.label} <span className="text-fg-muted">· {s.chip}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||
s.celsius >= 90
|
||||
? "bg-status-danger/20 text-status-danger"
|
||||
: s.celsius >= 75
|
||||
? "bg-status-warning/20 text-status-warning"
|
||||
: "bg-surface-3 text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{s.celsius.toFixed(0)} °C
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{stats.gpus.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{stats.gpus.map((g, i) => (
|
||||
<HealthBar
|
||||
key={i}
|
||||
label={g.name}
|
||||
value={g.utilization}
|
||||
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disks (with their I/O) and network interfaces. */}
|
||||
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
|
||||
<CardHead
|
||||
icon={HardDrive}
|
||||
tone="text-syntax-builtin"
|
||||
title={t("host.storageNetwork")}
|
||||
/>
|
||||
<div className="space-y-3" data-testid="server-disk-io">
|
||||
{diskRows.map(({ disk: d, io }) => (
|
||||
<div key={d.device} className="space-y-1">
|
||||
<HealthBar
|
||||
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
|
||||
value={pct(d.used, d.total)}
|
||||
warn={75}
|
||||
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
|
||||
/>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(d.free)} {t("host.free")}
|
||||
</span>
|
||||
{io && rate(io.read, io.write)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loneIo.map((io) => (
|
||||
<div key={io.name} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
|
||||
<span className="ml-auto">{rate(io.read, io.write)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{network.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-network"
|
||||
>
|
||||
{network.map((n) => (
|
||||
<div key={n.name} className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||
<Network className="w-3 h-3 text-fg-muted" />
|
||||
<span className="font-mono">{n.name}</span>
|
||||
</span>
|
||||
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The busiest processes, and what belongs to no user. */}
|
||||
{showProcs && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
|
||||
<CardHead
|
||||
icon={ListTree}
|
||||
tone="text-syntax-function"
|
||||
title={t("host.topProcesses")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!stats.usage ? (
|
||||
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProcs.map((p) => (
|
||||
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
<span className="font-mono text-fg-secondary">{p.name}</span>
|
||||
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(p.memory)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{fmtPct(p.cpu)}
|
||||
</span>
|
||||
</div>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-1.5"
|
||||
data-testid="server-usage-table"
|
||||
>
|
||||
{others.map((g) => (
|
||||
<div key={g.key} className="flex items-center gap-3 text-[10px]">
|
||||
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
|
||||
{groupName(g)}
|
||||
</span>
|
||||
<span className="font-mono text-fg-secondary">
|
||||
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Each user's live share of the machine (server mode). */}
|
||||
{people.length > 0 && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
|
||||
<CardHead
|
||||
icon={Users}
|
||||
tone="text-syntax-string"
|
||||
title={t("host.usersTitle")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.usersCount", { n: people.length })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{people.map((g) => {
|
||||
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} 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @file HostHealth.test.tsx
|
||||
* @description The machine on the Dashboard's System Health tab
|
||||
* (`HostHealth.tsx`, `GET /api/host`), its whole machine group: the first card names the host, its OS,
|
||||
* load and the AgentHub process with bars for CPU, memory, swap and heap; the
|
||||
* rows under it show every CPU thread, temperatures, each disk with its I/O
|
||||
* 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);
|
||||
* off Linux the cards it can never fill are left out.
|
||||
* @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 type { HostStats } from "../../../lib/api";
|
||||
|
||||
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(), history: vi.fn() },
|
||||
me: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
});
|
||||
|
||||
import { HostSections, useHostStats } from "../HostHealth";
|
||||
|
||||
function Tab() {
|
||||
const host = useHostStats();
|
||||
return (
|
||||
<HostSections
|
||||
host={host}
|
||||
heap={{ heapUsed: GB / 4, heapTotal: GB / 2, heapLimit: GB, external: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const full: HostStats = {
|
||||
...stats,
|
||||
usage: {
|
||||
...stats.usage!,
|
||||
groups: stats.usage!.groups.map((g) => (g.kind === "user" ? { ...g, displayName: "Mia" } : g)),
|
||||
},
|
||||
bootTime: "2026-10-14T00:00:00Z",
|
||||
cpu: { ...stats.cpu, physicalCores: 4, perCore: [10, 20, 30, 40] },
|
||||
network: [{ name: "enp4s0", rx: 2048, tx: 1024 }],
|
||||
diskIo: [{ name: "sda", read: 4096, write: 0 }],
|
||||
temperatures: [{ chip: "coretemp", label: "Package id 0", celsius: 55 }],
|
||||
topProcesses: [{ pid: 77, name: "claude", cpu: 20, memory: GB, group: "user:2" }],
|
||||
process: { pid: 9, node: "v24.0.0", uptime: 3600, rss: GB, heapUsed: GB / 2 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.host.stats.mockResolvedValue(full);
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: true,
|
||||
startedAt: "2026-10-15T00:00:00Z",
|
||||
points: [
|
||||
{
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
cpu: 30,
|
||||
memory: 50,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
{
|
||||
at: "2026-10-15T00:00:30Z",
|
||||
cpu: 40,
|
||||
memory: 52,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostHealth", () => {
|
||||
it("names the machine and the AgentHub process, with bars", async () => {
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText("box")).toBeTruthy();
|
||||
expect(card.textContent).toContain("Ubuntu 26.04");
|
||||
expect(card.textContent).toContain("1.0 GB");
|
||||
expect(within(card).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"40"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "Memory" }).getAttribute("aria-valuenow")).toBe(
|
||||
"50"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "V8 Heap" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows every thread, sensors, disks with I/O, network, history and processes", async () => {
|
||||
render(<Tab />);
|
||||
expect(await screen.findByTestId("server-history")).toBeTruthy();
|
||||
expect(await screen.findByRole("img", { name: /CPU/ })).toBeTruthy();
|
||||
expect(screen.getByTestId("server-cores").textContent).toContain("40%");
|
||||
expect(screen.getByTestId("server-temperatures").textContent).toContain("55 °C");
|
||||
const disks = screen.getByTestId("server-disk-io").textContent;
|
||||
expect(disks).toContain("/data · HDD");
|
||||
expect(disks).toContain("4.0 KB/s");
|
||||
expect(screen.getByTestId("server-network").textContent).toContain("enp4s0");
|
||||
const top = screen.getByTestId("server-top-processes");
|
||||
expect(await within(top).findByText("Mia")).toBeTruthy();
|
||||
const other = screen.getByTestId("server-usage-table");
|
||||
expect(within(other).queryByText("Mia")).toBeNull();
|
||||
expect(within(other).getByText(/^Host account/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "24 h" }));
|
||||
expect(api.host.history).toHaveBeenLastCalledWith("24h");
|
||||
});
|
||||
|
||||
it("shows each user's live CPU and RAM, their processes on click", async () => {
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
expect(tile.textContent).toContain("Mia");
|
||||
expect(within(tile).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"25"
|
||||
);
|
||||
expect(tile.textContent).toContain("2.0 GB");
|
||||
expect(within(tile).queryByText("claude")).toBeNull();
|
||||
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||
api.host.stats.mockResolvedValue({
|
||||
...stats,
|
||||
os: { platform: "win32", release: "10.0.26300", distro: null, arch: "x64" },
|
||||
cpu: { ...stats.cpu, load: [0, 0, 0], perCore: [] },
|
||||
disks: [],
|
||||
usage: null,
|
||||
});
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: false,
|
||||
points: [],
|
||||
});
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText(/win32/)).toBeTruthy();
|
||||
expect(within(card).queryByRole("meter", { name: "CPU" })).toBeNull();
|
||||
expect(card.textContent).not.toContain("Load");
|
||||
expect(within(card).getByRole("meter", { name: "Memory" })).toBeTruthy();
|
||||
for (const id of [
|
||||
"server-history",
|
||||
"server-cores",
|
||||
"server-top-processes",
|
||||
"server-disks-table",
|
||||
])
|
||||
expect(screen.queryByTestId(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* fetch, pull, push, switch branch (a local one, or one only a remote has —
|
||||
* listed under "On the remote" and checked out as a new tracking branch; the
|
||||
* list is re-read after every fetch and pull), create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
@@ -114,6 +116,8 @@ export function LaneGitBar({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
// Fetched remote branches with no local one yet (`origin/feature`).
|
||||
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
@@ -138,6 +142,7 @@ export function LaneGitBar({
|
||||
setError(null);
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setRemoteBranches([]);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
@@ -152,6 +157,9 @@ export function LaneGitBar({
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
// A remote branch: switching creates its local tracking branch; it cannot
|
||||
// be deleted here or given a sibling worktree before that.
|
||||
const pickedRemote = remoteBranches.includes(picked);
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
@@ -184,6 +192,9 @@ export function LaneGitBar({
|
||||
setPicked("");
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
} else if ((action === "fetch" || action === "pull") && branches !== null) {
|
||||
// A fetch may have brought branches the list does not show yet.
|
||||
await refreshBranches();
|
||||
}
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
@@ -217,6 +228,7 @@ export function LaneGitBar({
|
||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setRemoteBranches(r.remotes || []);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
@@ -446,6 +458,15 @@ export function LaneGitBar({
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{remoteBranches.length > 0 && (
|
||||
<optgroup label={t("gitBar.remoteBranches")}>
|
||||
{remoteBranches.map((b) => (
|
||||
<option key={`remote:${b}`} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
@@ -476,7 +497,7 @@ export function LaneGitBar({
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch || pickedRemote}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
@@ -486,7 +507,7 @@ export function LaneGitBar({
|
||||
label={t("gitBar.branchDelete")}
|
||||
icon={<Trash2 className="h-3 w-3" />}
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || pickedRemote}
|
||||
title={t("gitBar.branchDeleteHint")}
|
||||
onClick={() =>
|
||||
void confirmRun(
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||
* `resumeById` (omp) can resume a session by id — any other tool's session
|
||||
* `resumeById` (omp, Cursor, Antigravity) can resume a session by id — any other tool's session
|
||||
* offers a new session from the workspace home
|
||||
* instead. The header carries the transcript page's Resume/Fork
|
||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* refuses it too), that branches only the remote has are offered (and the list
|
||||
* is re-read after a fetch), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -236,6 +237,43 @@ describe("LaneGitBar", () => {
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
remotes: ["origin/feat/new"],
|
||||
current: "feat/thing",
|
||||
worktrees: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ dirty: 0, untracked: 0 })}
|
||||
onChanged={vi.fn()}
|
||||
onNewLane={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
expect(
|
||||
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
|
||||
).toBeInTheDocument();
|
||||
await user.selectOptions(select, "origin/feat/new");
|
||||
// Not a local branch yet: nothing to delete, no worktree to start on it.
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "origin/feat/new" })
|
||||
);
|
||||
|
||||
vi.mocked(api.lanes.gitBranches).mockClear();
|
||||
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Default badge or Set default, and Update whenever the catalog names an
|
||||
* updater — no version lookup, the updater itself knows whether it has work.
|
||||
* The ones available to install fold behind a disclosure, with Install where
|
||||
* the catalog knows how. Install/Update/Update all confirm the exact command
|
||||
* the catalog knows how (an admin's; Update all is anyone's — auth.js
|
||||
* MEMBER_WRITE_EXCEPTIONS). Install/Update/Update all confirm the exact command
|
||||
* first, run as ONE server job at a time and show its log under the row
|
||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||
* A right-click on an installed row opens its Update / Set default /
|
||||
@@ -231,7 +232,8 @@ export function AiToolsSettings({
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && updateAllTools.length > 0 && (
|
||||
{/* Every signed-in user may run Update all (auth.js MEMBER_WRITE_EXCEPTIONS). */}
|
||||
{updateAllTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file NineRouterUpdate.tsx
|
||||
* @description **Update 9router** on the AI Proxy page (an admin's; beside
|
||||
* @description **Update 9router** on the AI Proxy page (every user's; beside
|
||||
* Config 9router): a button — dotted when npm has a newer 9router — and a
|
||||
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
|
||||
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
|
||||
* mode, every user picks which account of the shared account pool their own
|
||||
* new sessions run on, per tool, among the accounts their pool group may use
|
||||
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
|
||||
* Claude login goes into the user's home at once, so running panes follow it;
|
||||
* Codex and Cursor take it at the next session. Renders nothing in local mode
|
||||
* or when the pool holds no account the user may use.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } from "lucide-react";
|
||||
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsRow, SettingsSection } from "./SettingsRow";
|
||||
|
||||
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
|
||||
|
||||
export function PoolAccountSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { local } = usePermissions();
|
||||
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
|
||||
const [busy, setBusy] = useState<PoolTool | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (local) return;
|
||||
api.me
|
||||
.poolAccounts()
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => setTools(null));
|
||||
}, [local]);
|
||||
|
||||
if (local || !tools?.length) return null;
|
||||
|
||||
const choose = async (tool: PoolTool, account: string | null) => {
|
||||
setBusy(tool);
|
||||
try {
|
||||
const r = await api.me.choosePoolAccount(tool, account);
|
||||
setTools((list) =>
|
||||
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
|
||||
);
|
||||
toast.success(
|
||||
!r.choice
|
||||
? t("poolAccount.automaticSaved")
|
||||
: r.switched === "switched" || r.switched === "kept"
|
||||
? t("poolAccount.switchedNow", { account: r.choice })
|
||||
: t("poolAccount.nextSession", { account: r.choice })
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="pool-account"
|
||||
title={t("poolAccount.title")}
|
||||
description={t("poolAccount.description")}
|
||||
>
|
||||
{tools.map((x) => (
|
||||
<SettingsRow
|
||||
key={x.tool}
|
||||
id={`pool-account-${x.tool}`}
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
|
||||
{LABEL[x.tool]}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
x.pin ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("poolAccount.pinned", { account: x.pin })}
|
||||
</span>
|
||||
) : (
|
||||
t("poolAccount.count", { count: x.accounts.length })
|
||||
)
|
||||
}
|
||||
disabled={!!x.pin}
|
||||
control={
|
||||
<select
|
||||
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
|
||||
value={x.pin ?? x.choice ?? ""}
|
||||
disabled={!!x.pin || busy === x.tool}
|
||||
onChange={(e) => void choose(x.tool, e.target.value || null)}
|
||||
className="select !py-1 !text-xs max-w-[16rem]"
|
||||
>
|
||||
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
|
||||
{x.accounts.map((a) => (
|
||||
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
|
||||
{[
|
||||
a.name,
|
||||
a.email && a.email !== a.name ? `(${a.email})` : null,
|
||||
a.expired
|
||||
? `— ${t("poolAccount.expired")}`
|
||||
: a.full
|
||||
? `— ${t("poolAccount.full")}`
|
||||
: a.quotaPercent != null
|
||||
? `— ${Math.round(a.quotaPercent)}%`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -800,7 +800,7 @@ function ProfileUsageLine({
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
|
||||
* hidden in local mode and with no usable account; one select per tool that
|
||||
* starts on the user's choice (or Automatic) and saves a pick through
|
||||
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
import { PoolAccountSettings } from "../PoolAccountSettings";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
|
||||
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
|
||||
}));
|
||||
|
||||
const account = (name: string) => ({
|
||||
name,
|
||||
kind: "login",
|
||||
email: `${name}@x.io`,
|
||||
expired: false,
|
||||
quotaPercent: 20,
|
||||
full: false,
|
||||
});
|
||||
|
||||
async function show() {
|
||||
render(<PoolAccountSettings />);
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockReset();
|
||||
m.choosePoolAccount.mockReset();
|
||||
});
|
||||
|
||||
describe("PoolAccountSettings", () => {
|
||||
it("renders nothing in local mode or with no account to choose", async () => {
|
||||
perms.local = true;
|
||||
await show();
|
||||
expect(m.poolAccounts).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
await show();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts on the user's choice and saves a new pick", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.value).toBe("two");
|
||||
await userEvent.selectOptions(select, "one");
|
||||
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||
await userEvent.selectOptions(select, "");
|
||||
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||
});
|
||||
|
||||
it("is locked on an admin's pin", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(select.value).toBe("one");
|
||||
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`), one
|
||||
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
|
||||
* Codex and Cursor have a pool — the accounts every user's new sessions of that
|
||||
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
|
||||
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
|
||||
* Oh My Pi has no pool but can share the host's login (the same switch as
|
||||
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
|
||||
* other installed tool says that its users sign in themselves. A tab shows
|
||||
* how many accounts its pool holds, and an empty pool says so instead of
|
||||
* vanishing. An admin adds an account to the open tab's tool — for Claude a
|
||||
* @description The Users page's account pool card (`GET /api/accounts`).
|
||||
* Claude Code, Codex and Cursor accounts live on the POOLS board
|
||||
* (`PoolGroups`): one column per pool with its accounts of every tool — each
|
||||
* a card with its live panes (and, for Claude/Codex, $ and tokens in the last
|
||||
* 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
|
||||
* tools with no pool get a tab each below it: Oh My Pi can share the host's
|
||||
* login (the same switch as Settings → AI tools,
|
||||
* `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
|
||||
* says that its users sign in themselves. An admin adds an account from the
|
||||
* header's Add account, picking its tool — for Claude a
|
||||
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
||||
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
||||
* `claude setup-token` token; for Codex a saved Codex profile
|
||||
@@ -17,11 +17,11 @@
|
||||
* Cursor API key — or removes one, each time with their own password again;
|
||||
* an analyst only reads. The secret only goes IN: the server never sends it
|
||||
* back. An account's Edit (pencil) picks the users who run on it
|
||||
* (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a
|
||||
* (`PUT /api/accounts/:name/users`, from a `MemberPicker` filtered by role
|
||||
* or "no group"): their sessions skip the balancer, and a
|
||||
* Claude login goes into their home at once — no restart. A profile's
|
||||
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
||||
* which opens that tool's tab and Add on that profile; the header links back
|
||||
* to the profiles.
|
||||
* which opens Add on that profile; the header links back to the profiles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
@@ -47,10 +47,10 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
||||
import { PoolGroups, groupsOf } from "./PoolGroups";
|
||||
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
|
||||
import { MemberPicker } from "./MemberPicker";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
@@ -69,18 +69,20 @@ type Row = {
|
||||
full: boolean;
|
||||
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||
windows: QuotaWindow[];
|
||||
/** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */
|
||||
plan?: string | null;
|
||||
};
|
||||
|
||||
/** One quota window of an account: % used and when it rolls over. */
|
||||
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
export type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
|
||||
/**
|
||||
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
|
||||
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
|
||||
* passed is empty again (the reading predates it), as the balancer treats it.
|
||||
*/
|
||||
function windowsOf(
|
||||
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
|
||||
export function windowsOf(
|
||||
a: Pick<AccountPool["accounts"][number] | AccountPool["codex"][number], "quota">
|
||||
): QuotaWindow[] {
|
||||
const q = a.quota as
|
||||
| {
|
||||
@@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null {
|
||||
|
||||
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
||||
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
||||
/** Fixed tabs, always shown; any other installed tool follows them. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "cursor", label: "Cursor CLI" },
|
||||
{ id: "omp", label: "Oh My Pi" },
|
||||
];
|
||||
/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
|
||||
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||
|
||||
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const profile = params.get("poolProfile");
|
||||
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||
})();
|
||||
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
|
||||
const [tab, setTab] = useState<string>("omp");
|
||||
// What Add opens on: a tool, and a saved profile to share into its pool.
|
||||
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
||||
isAdmin && preset ? preset : null
|
||||
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const tabs = [
|
||||
...FIXED_TABS,
|
||||
...aiTools.tools
|
||||
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.filter((x) => x.installed && !isPoolTool(x.id) && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.map((x) => ({ id: x.id, label: x.label })),
|
||||
];
|
||||
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
||||
const badge = (id: string): string | null => {
|
||||
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
|
||||
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
return null;
|
||||
};
|
||||
const badge = (id: string): string | null =>
|
||||
id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
const rowOf = (tool: PoolTool, name: string) =>
|
||||
pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
|
||||
|
||||
return (
|
||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||
@@ -229,6 +225,29 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<label className="relative">
|
||||
<span className="btn-primary pointer-events-none text-xs">
|
||||
<Plus className="h-4 w-4" /> {t("pool.addAccount")}
|
||||
</span>
|
||||
<select
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (isPoolTool(e.target.value)) setAdding({ tool: e.target.value });
|
||||
}}
|
||||
aria-label={t("pool.addAccount")}
|
||||
data-testid="pool-add-account"
|
||||
>
|
||||
<option value="">{t("pool.addAccount")}</option>
|
||||
{POOL_TOOL_LABELS.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && (
|
||||
<PoolGroups
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
onChanged={() => void load()}
|
||||
renderAccount={(tool, name) => {
|
||||
const r = rowOf(tool, name);
|
||||
return r ? (
|
||||
<AccountCard
|
||||
row={r}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => setEditing(r)}
|
||||
onRemove={() => setRemoving(r)}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h3 className="border-t border-border px-5 pt-4 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.otherTools")}
|
||||
</h3>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("pool.title")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
||||
aria-label={t("pool.otherTools")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
|
||||
>
|
||||
{tabs.map((x) => {
|
||||
const selected = x.id === current.id;
|
||||
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||
{isPoolTool(current.id) ? (
|
||||
<PoolPanel
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
|
||||
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
|
||||
onEdit={setEditing}
|
||||
onRemove={setRemoving}
|
||||
/>
|
||||
) : current.id === "omp" ? (
|
||||
{current.id === "omp" ? (
|
||||
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||
) : (
|
||||
<NoPoolPanel id={current.id} label={current.label} />
|
||||
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
tool={adding.tool}
|
||||
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
label={POOL_TOOL_LABELS.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||
onClose={closeAdd}
|
||||
onDone={(name) => {
|
||||
@@ -351,89 +380,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
|
||||
function PoolPanel({
|
||||
tool,
|
||||
label,
|
||||
pool,
|
||||
isAdmin,
|
||||
installed,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
pool: AccountPool | null;
|
||||
isAdmin: boolean;
|
||||
installed: boolean;
|
||||
/** Add an account; `profile` = share that saved profile into the pool. */
|
||||
onAdd: (profile?: string) => void;
|
||||
onEdit: (r: Row) => void;
|
||||
onRemove: (r: Row) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const rows = pool ? rowsOf(pool, tool) : [];
|
||||
const grouped = (pool?.groups ?? []).length > 0;
|
||||
const addButton = isAdmin && (
|
||||
<button type="button" onClick={() => onAdd()} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
|
||||
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
|
||||
{t(`pool.about.${tool}`)}
|
||||
{!installed && (
|
||||
<span className="ml-1 text-status-warning">
|
||||
{t("pool.notInstalled", { tool: label })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{rows.length > 0 && addButton}
|
||||
</div>
|
||||
{pool && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
|
||||
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
|
||||
{addButton}
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((r) => (
|
||||
<AccountCard
|
||||
key={r.name}
|
||||
row={r}
|
||||
pool={pool!}
|
||||
grouped={grouped}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => onEdit(r)}
|
||||
onRemove={() => onRemove(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && SOURCES[tool].includes("profile") && (
|
||||
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool account: who it is, how loaded, which groups and users run on it. */
|
||||
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
|
||||
function AccountCard({
|
||||
row: r,
|
||||
pool,
|
||||
grouped,
|
||||
isAdmin,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
row: Row;
|
||||
pool: AccountPool;
|
||||
grouped: boolean;
|
||||
isAdmin: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
@@ -443,39 +399,52 @@ function AccountCard({
|
||||
const users = pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username);
|
||||
const inGroups = groupsOf(pool, r.tool, r.name);
|
||||
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
||||
className={`group flex flex-col gap-2 rounded-lg border bg-surface-1 p-2.5 shadow-sm transition-[border-color,box-shadow] duration-150 hover:border-accent/40 hover:shadow-md ${
|
||||
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||
}`}
|
||||
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
data-testid="pool-account"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold text-fg-primary">{r.name}</span>
|
||||
{r.plan && (
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-accent/10 px-1.5 text-[10px] font-medium leading-4 text-accent"
|
||||
data-testid="pool-account-plan"
|
||||
>
|
||||
{r.plan}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-muted">{r.email}</div>}
|
||||
</div>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
||||
<span className="shrink-0 rounded border border-border px-1 text-[10px] leading-4 text-fg-muted">
|
||||
{t(`pool.kindShort.${r.kind}`)}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<div className="-mr-1 -mt-1 flex shrink-0">
|
||||
<div className="-mr-1 -mt-1 flex shrink-0 opacity-60 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title={t("pool.edit", { name: r.name })}
|
||||
aria-label={t("pool.edit", { name: r.name })}
|
||||
className="btn-icon"
|
||||
className="btn-icon p-1"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
className="btn-icon p-1 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -488,68 +457,47 @@ function AccountCard({
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
<span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{metered && (
|
||||
<div className="space-y-1" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] tabular-nums text-fg-muted">
|
||||
<span className="inline-flex items-center gap-1 text-fg-secondary">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("pool.liveCount", { count: r.livePanes })}
|
||||
</span>
|
||||
{metered && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</span>
|
||||
<span className="basis-full">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{metered && (
|
||||
<div className="space-y-1.5" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{metered && (
|
||||
<div className="text-[11px] tabular-nums text-fg-muted">
|
||||
<div>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</div>
|
||||
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
|
||||
</div>
|
||||
)}
|
||||
{grouped && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{inGroups.length === 0 ? (
|
||||
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
|
||||
) : (
|
||||
inGroups.map((g) => (
|
||||
<span key={g.id} className="badge py-0 text-[10px]">
|
||||
{g.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||
<UserRound className="h-3 w-3" aria-hidden />
|
||||
{t("pool.pinned", { users: users.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -558,7 +506,7 @@ function AccountCard({
|
||||
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
||||
* 0% = used up), with when it resets.
|
||||
*/
|
||||
function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
const { t } = useTranslation("users");
|
||||
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
|
||||
const resets = untilText(w.resetsAt);
|
||||
@@ -568,7 +516,7 @@ function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${
|
||||
className={`block h-full transition-[width] duration-500 ${
|
||||
left == null
|
||||
? ""
|
||||
: left === 0
|
||||
@@ -596,72 +544,6 @@ function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
|
||||
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
|
||||
* leaves the switcher, since a login's refresh token may have one holder.
|
||||
*/
|
||||
function SavedProfiles({
|
||||
tool,
|
||||
pool,
|
||||
onShare,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
|
||||
pool: AccountPool | null;
|
||||
onShare: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, [tool, pool]);
|
||||
return (
|
||||
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.savedProfiles.title")}
|
||||
</h3>
|
||||
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
</div>
|
||||
{profiles !== null && profiles.length === 0 && (
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
|
||||
)}
|
||||
{profiles && profiles.length > 0 && (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{profiles.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm text-fg-primary">{p.label}</div>
|
||||
<div className="truncate text-[11px] text-fg-muted">
|
||||
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
|
||||
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShare(p.name)}
|
||||
className="btn-ghost shrink-0 text-xs"
|
||||
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
|
||||
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
|
||||
@@ -777,13 +659,8 @@ function EditAccountDialog({
|
||||
|
||||
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||
const ready = changed && current.length > 0;
|
||||
const toggle = (id: number, on: boolean) =>
|
||||
setChosen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const groupOf = (userId: number) =>
|
||||
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
@@ -822,38 +699,28 @@ function EditAccountDialog({
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
||||
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users?.map((u) => {
|
||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||
return (
|
||||
<div key={u.id} className="px-3 py-2">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => toggle(u.id, on)}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
||||
)}
|
||||
{elsewhere && (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.onAccount", { account: elsewhere.account })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-1">
|
||||
<MemberPicker
|
||||
users={users}
|
||||
chosen={chosen}
|
||||
onChange={setChosen}
|
||||
groupOf={groupOf}
|
||||
grouped={(pool.groups ?? []).length > 0}
|
||||
note={(u) => {
|
||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||
const group = groupOf(u.id);
|
||||
return (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{[
|
||||
group && t("pool.groups.inGroup", { group }),
|
||||
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @file MemberPicker.tsx
|
||||
* @description The pool dialogs' member list (an account's Edit, a group's
|
||||
* Edit): filter chips by role (Admin / Developer / Analyst, each with its
|
||||
* count) plus "No group" for the users in no pool group, a search box, a
|
||||
* tick-all for what is shown, and one checkbox per user with their role and
|
||||
* where they are now. Pure UI: the caller owns the ticked set.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import type { AuthUser, ServerUserUsage } from "../../lib/api";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
|
||||
/** A role, every user, or the users in no pool group. */
|
||||
export type MemberFilter = "all" | "nogroup" | AuthUser["role"];
|
||||
|
||||
/** The chips' filter applied to one user. */
|
||||
export function matchesFilter(
|
||||
u: Pick<ServerUserUsage, "id" | "role">,
|
||||
filter: MemberFilter,
|
||||
groupOf: (userId: number) => string | null
|
||||
): boolean {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "nogroup") return groupOf(u.id) === null;
|
||||
return u.role === filter;
|
||||
}
|
||||
|
||||
/** A row of filter chips with counts; `nogroup` only when groups exist. */
|
||||
export function MemberFilterChips({
|
||||
users,
|
||||
filter,
|
||||
onFilter,
|
||||
groupOf,
|
||||
grouped,
|
||||
}: {
|
||||
users: Pick<ServerUserUsage, "id" | "role">[];
|
||||
filter: MemberFilter;
|
||||
onFilter: (f: MemberFilter) => void;
|
||||
groupOf: (userId: number) => string | null;
|
||||
grouped: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const chips: { id: MemberFilter; label: string }[] = [
|
||||
{ id: "all", label: t("pool.members.all") },
|
||||
...ROLES.map((r) => ({ id: r as MemberFilter, label: t(`roles.${r}`) })),
|
||||
...(grouped ? [{ id: "nogroup" as MemberFilter, label: t("pool.members.noGroup") }] : []),
|
||||
];
|
||||
return (
|
||||
<div role="group" aria-label={t("pool.members.filter")} className="flex flex-wrap gap-1">
|
||||
{chips.map((c) => {
|
||||
const n = users.filter((u) => matchesFilter(u, c.id, groupOf)).length;
|
||||
if (n === 0 && c.id !== "all" && c.id !== filter) return null;
|
||||
const on = filter === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onFilter(c.id)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
|
||||
on
|
||||
? "border-accent/50 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
<span className="tabular-nums opacity-70">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberPicker({
|
||||
users,
|
||||
chosen,
|
||||
onChange,
|
||||
groupOf,
|
||||
grouped,
|
||||
note,
|
||||
}: {
|
||||
users: ServerUserUsage[] | null;
|
||||
chosen: Set<number>;
|
||||
onChange: (next: Set<number>) => void;
|
||||
/** The pool group a user is in now, by name, or null. */
|
||||
groupOf: (userId: number) => string | null;
|
||||
/** Whether any pool group exists (shows the "No group" chip). */
|
||||
grouped: boolean;
|
||||
/** Extra text after a user's name (where they run now). */
|
||||
note?: (u: ServerUserUsage) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [filter, setFilter] = useState<MemberFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (users ?? []).filter(
|
||||
(u) =>
|
||||
matchesFilter(u, filter, groupOf) &&
|
||||
(!q ||
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.displayName ?? "").toLowerCase().includes(q))
|
||||
);
|
||||
const allShown = shown.length > 0 && shown.every((u) => chosen.has(u.id));
|
||||
const setMany = (ids: number[], on: boolean) => {
|
||||
const next = new Set(chosen);
|
||||
for (const id of ids) {
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="member-picker">
|
||||
{users && users.length > 0 && (
|
||||
<>
|
||||
<MemberFilterChips
|
||||
users={users}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
groupOf={groupOf}
|
||||
grouped={grouped}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
className="input w-full py-1 pl-7 text-xs"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("pool.members.search")}
|
||||
aria-label={t("pool.members.search")}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={allShown}
|
||||
disabled={shown.length === 0}
|
||||
onChange={(on) =>
|
||||
setMany(
|
||||
shown.map((u) => u.id),
|
||||
on
|
||||
)
|
||||
}
|
||||
label={t("pool.members.tickShown", { count: shown.length })}
|
||||
labelClassName="text-[11px] text-fg-secondary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users && users.length > 0 && shown.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.members.noneShown")}</p>
|
||||
)}
|
||||
{shown.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-2 px-3 py-1.5">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => setMany([u.id], on)}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
||||
{note?.(u)}
|
||||
</span>
|
||||
}
|
||||
className="min-w-0 flex-1"
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`roles.${u.role}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @file MemberTmux.tsx
|
||||
* @description Each member's own tmux server as part of the Administration
|
||||
* page's users table (one Linux user per account only, `GET /api/users/tmux`,
|
||||
* `useMemberTmux`): a `TmuxCell` per row — the version it runs, a badge when
|
||||
* older than the installed one, its sessions, whether an agent of theirs is
|
||||
* working — and `MemberTmuxBar` above the table, so an admin sees who still
|
||||
* runs an old tmux after an upgrade.
|
||||
* An admin restarts one member's server, or every idle outdated one at once
|
||||
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||
* time with their own password again; a restart ends that member's panes,
|
||||
* and the next start runs the installed tmux. An analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { RotateCcw, TerminalSquare } from "lucide-react";
|
||||
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`tmux.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
||||
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
||||
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
||||
|
||||
/** `GET /api/users/tmux`; `available` false without one Linux user per account. */
|
||||
export function useMemberTmux(enabled = true) {
|
||||
const [data, setData] = useState<MemberTmux | null>(null);
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.users
|
||||
.tmux()
|
||||
.then(setData)
|
||||
.catch(() => setData(null)),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (enabled) void reload();
|
||||
}, [enabled, reload]);
|
||||
return { data, reload };
|
||||
}
|
||||
|
||||
/** One member's tmux server as a users-table cell: version, badges, sessions, agents. */
|
||||
export function TmuxCell({ row }: { row?: MemberTmuxRow }) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!row?.running) return <span className="text-sm text-fg-muted">—</span>;
|
||||
return (
|
||||
<div className="text-sm text-fg-secondary">
|
||||
<span className="font-mono">{row.stuck ? "?" : (row.version ?? "?")}</span>
|
||||
{row.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
||||
{row.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
||||
<div
|
||||
className="text-[11px] text-fg-muted"
|
||||
title={
|
||||
row.startedAt
|
||||
? t("tmux.since", { at: formatDateTime(new Date(row.startedAt).toISOString()) })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("tmux.sessionsCount", { count: row.sessions ?? 0 })} ·{" "}
|
||||
{row.busy > 0 ? t("tmux.working", { count: row.busy }) : t("tmux.idle")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Above the users table: the installed tmux, the not-yet-installed tmux-member
|
||||
* warning, and (an admin) "Restart N idle members". Nothing without S3.
|
||||
*/
|
||||
export function MemberTmuxBar({
|
||||
data,
|
||||
isAdmin,
|
||||
onRestart,
|
||||
}: {
|
||||
data: MemberTmux | null;
|
||||
isAdmin: boolean;
|
||||
onRestart: (target: "idle") => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!data?.available) return null;
|
||||
const idle = data.users.filter((u) => u.running && u.outdated && u.busy === 0);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs" data-testid="member-tmux">
|
||||
<span className="inline-flex items-center gap-1.5 text-fg-muted">
|
||||
<TerminalSquare className="w-3.5 h-3.5" />
|
||||
{t("tmux.description", { version: data.installed ?? "?" })}
|
||||
</span>
|
||||
{!data.matched && <span className="text-status-warning">{t("tmux.notMatched")}</span>}
|
||||
{isAdmin && idle.length > 0 && (
|
||||
<button type="button" onClick={() => onRestart("idle")} className="btn-ghost text-xs">
|
||||
<RotateCcw className="w-3.5 h-3.5" /> {t("tmux.restartIdle", { count: idle.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An admin's restart of one member's tmux, or of every idle outdated one. */
|
||||
export function TmuxRestartDialog({
|
||||
target,
|
||||
data,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: MemberTmuxRow | "idle";
|
||||
data: MemberTmux | null;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const idle = (data?.users ?? []).filter((u) => u.running && u.outdated && u.busy === 0);
|
||||
return <RestartDialog target={target} idle={idle} onClose={onClose} onDone={onDone} />;
|
||||
}
|
||||
|
||||
function RestartDialog({
|
||||
target,
|
||||
idle,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: MemberTmuxRow | "idle";
|
||||
idle: MemberTmuxRow[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const one = target === "idle" ? null : target;
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (one) {
|
||||
await api.users.tmuxRestart(one.id, current);
|
||||
onDone(t("tmux.done", { name: one.username }));
|
||||
} else {
|
||||
const r = await api.users.tmuxRestartIdle(current);
|
||||
// A server that would not stop needs root: say which, the rest went.
|
||||
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
||||
message={
|
||||
one
|
||||
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
||||
name: one.username,
|
||||
count: one.sessions ?? 0,
|
||||
})
|
||||
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
||||
}
|
||||
confirmLabel={t("tmux.restartButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* @file MemberTmuxCard.tsx
|
||||
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
|
||||
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
|
||||
* beside the installed one, its sessions, whether an agent of theirs is
|
||||
* working — so an admin sees who still runs an old tmux after an upgrade.
|
||||
* An admin restarts one member's server, or every idle outdated one at once
|
||||
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||
* time with their own password again; a restart ends that member's panes,
|
||||
* and the next start runs the installed tmux. An analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
|
||||
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`tmux.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
||||
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
||||
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
||||
|
||||
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const [data, setData] = useState<MemberTmux | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A member row = restart that one; "idle" = every idle outdated member.
|
||||
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.users
|
||||
.tmux()
|
||||
.then((r) => {
|
||||
setData(r);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!data?.available) return null;
|
||||
const rows = data.users.filter((u) => u.running);
|
||||
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<section className="card" data-testid="member-tmux">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<TerminalSquare className="w-4 h-4 text-fg-muted" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("tmux.description", { version: data.installed ?? "?" })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
title={t("tmux.refresh")}
|
||||
aria-label={t("tmux.refresh")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && idleOutdated.length > 0 && (
|
||||
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
|
||||
<RotateCcw className="w-4 h-4" />{" "}
|
||||
{t("tmux.restartIdle", { count: idleOutdated.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!data.matched && (
|
||||
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
|
||||
{t("tmux.notMatched")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("tmux.user")}</th>
|
||||
<th className={th}>{t("tmux.version")}</th>
|
||||
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
|
||||
<th className={th}>{t("tmux.agents")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
|
||||
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
||||
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
||||
{u.startedAt && (
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("tmux.since", {
|
||||
at: formatDateTime(new Date(u.startedAt).toISOString()),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.sessions ?? "—"}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestarting(u)}
|
||||
title={t("tmux.restart", { name: u.username })}
|
||||
aria-label={t("tmux.restart", { name: u.username })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{restarting && (
|
||||
<RestartDialog
|
||||
target={restarting}
|
||||
idle={idleOutdated}
|
||||
onClose={() => setRestarting(null)}
|
||||
onDone={(msg) => {
|
||||
toast.success(msg);
|
||||
setRestarting(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RestartDialog({
|
||||
target,
|
||||
idle,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: MemberTmuxRow | "idle";
|
||||
idle: MemberTmuxRow[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const one = target === "idle" ? null : target;
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (one) {
|
||||
await api.users.tmuxRestart(one.id, current);
|
||||
onDone(t("tmux.done", { name: one.username }));
|
||||
} else {
|
||||
const r = await api.users.tmuxRestartIdle(current);
|
||||
// A server that would not stop needs root: say which, the rest went.
|
||||
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
||||
message={
|
||||
one
|
||||
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
||||
name: one.username,
|
||||
count: one.sessions ?? 0,
|
||||
})
|
||||
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
||||
}
|
||||
confirmLabel={t("tmux.restartButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* @file UserDetail.tsx
|
||||
* @description One account, opened IN PLACE under its row of the
|
||||
* Administration page's users table (`?user=<id>`, `GET /api/users/:id/details`).
|
||||
* The row already shows identity, sessions, spend, home and tmux, so
|
||||
* this keeps only what the row cannot: one summary line (last sign-in, last
|
||||
* activity, active sessions and live panes), then three short columns — spend
|
||||
* (30-day bars, by AI tool, top models), the account pool (per tool: pin or
|
||||
* group, each account's quota left beside this user's own 7-day share), and
|
||||
* what runs now (live panes, owned workspaces) — and the five newest sessions. Read-only; Edit stays on
|
||||
* the row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Play, RefreshCw } from "lucide-react";
|
||||
import { api, type PoolTool, type UserDetails } from "../../lib/api";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { effectiveSessionStatus, sessionAwaitingReason } from "../../lib/types";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SessionStatusBadge } from "../StatusBadge";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { QuotaBar, windowsOf } from "./AccountPoolCard";
|
||||
|
||||
const POOL_LABEL: Record<PoolTool, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
cursor: "Cursor CLI",
|
||||
};
|
||||
const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
export function UserDetail({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("users");
|
||||
const { label: toolLabel } = useAiTools();
|
||||
const [data, setData] = useState<UserDetails | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.users
|
||||
.details(userId)
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[userId]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
useEffect(() => {
|
||||
setData(null);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="user-detail"
|
||||
className="flex flex-wrap items-center gap-3 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
{t("detail.failed")}: {error}
|
||||
</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
if (!data)
|
||||
return (
|
||||
<p data-testid="user-detail" className="py-4 text-center text-sm text-fg-muted">
|
||||
{t("common:loading")}
|
||||
</p>
|
||||
);
|
||||
|
||||
const u = data.user;
|
||||
const total30 = data.usage.last30d.cost;
|
||||
const pools = (Object.keys(POOL_LABEL) as PoolTool[]).filter(
|
||||
(tool) => (data.pool[tool]?.accounts.length ?? 0) > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="user-detail">
|
||||
{/* One line of what the row does not show. */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs">
|
||||
<Inline label={t("detail.lastLogin")}>
|
||||
{u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("detail.never")}
|
||||
</Inline>
|
||||
<Inline label={t("detail.lastSession")}>
|
||||
{data.sessions.lastAt ? formatDateTime(data.sessions.lastAt) : "—"}
|
||||
</Inline>
|
||||
<Inline label={t("detail.activeLabel")}>
|
||||
{t("detail.activeValue", { sessions: data.sessions.active, panes: data.panes.length })}
|
||||
</Inline>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost ml-auto py-1 text-xs"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{/* Spend: the month at a glance, then where it went. */}
|
||||
<Panel title={t("detail.spendDaily")}>
|
||||
<MiniChart
|
||||
kind="bar"
|
||||
label={t("detail.spendDaily")}
|
||||
points={data.usage.daily.map((d) => ({ x: d.date.slice(5), y: d.cost }))}
|
||||
format={(v) => fmtCost(v)}
|
||||
/>
|
||||
{data.usage.byTool.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("detail.noUsage")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 pt-1">
|
||||
{data.usage.byTool.map((r) => (
|
||||
<ShareRow
|
||||
key={r.key}
|
||||
label={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AiToolIcon
|
||||
tool={{ id: r.key, label: toolLabel(r.key) }}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{toolLabel(r.key)}
|
||||
</span>
|
||||
}
|
||||
cost={r.cost}
|
||||
total={total30}
|
||||
/>
|
||||
))}
|
||||
{data.usage.byModel.slice(0, 3).map((r) => (
|
||||
<ShareRow
|
||||
key={`m:${r.key}`}
|
||||
label={<span className="font-mono text-[11px] text-fg-muted">{r.key}</span>}
|
||||
cost={r.cost}
|
||||
total={total30}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Pool: what they may run on, and how much of it is theirs. */}
|
||||
<Panel title={t("detail.pool")}>
|
||||
{pools.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("detail.poolNone")}</p>
|
||||
) : (
|
||||
pools.map((tool) => {
|
||||
const p = data.pool[tool]!;
|
||||
return (
|
||||
<div key={tool} className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<AiToolIcon tool={{ id: tool, label: POOL_LABEL[tool] }} className="h-4 w-4" />
|
||||
<span className="font-medium text-fg-primary">{POOL_LABEL[tool]}</span>
|
||||
<span className="text-fg-muted">
|
||||
·{" "}
|
||||
{p.pin
|
||||
? t("detail.poolPinned", { account: p.pin })
|
||||
: p.choice
|
||||
? t("detail.poolChosen", { account: p.choice })
|
||||
: p.group
|
||||
? t("detail.poolGroup", { name: p.group.name })
|
||||
: t("detail.poolShared")}
|
||||
</span>
|
||||
</div>
|
||||
{p.accounts.map((a) => {
|
||||
const share = a.spend7d > 0 ? (a.userSpend7d / a.spend7d) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={a.name}
|
||||
className={`rounded-md border border-border px-2.5 py-2 space-y-1 ${a.allowed ? "" : "opacity-60"}`}
|
||||
data-testid="user-pool-account"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-medium text-fg-primary">
|
||||
{a.name}
|
||||
{a.expired && (
|
||||
<span className="ml-1.5 text-status-danger">{t("pool.expired")}</span>
|
||||
)}
|
||||
{!a.allowed && (
|
||||
<span className="ml-1.5 text-fg-muted">
|
||||
{t("detail.poolNotAllowed")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{tool !== "cursor" && (
|
||||
<span
|
||||
className="flex-shrink-0 tabular-nums text-fg-secondary"
|
||||
title={t("detail.poolTotal7d", { cost: fmtCost(a.spend7d) })}
|
||||
>
|
||||
{t("detail.poolTheirs7d", { cost: fmtCost(a.userSpend7d) })}
|
||||
{a.spend7d > 0 && (
|
||||
<span className="text-fg-muted"> ({pctText(share)})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tool !== "cursor" &&
|
||||
windowsOf(a).map((w) => <QuotaBar key={w.key} window={w} />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Now: live panes and the workspaces they own. */}
|
||||
<Panel title={t("detail.nowTitle")}>
|
||||
{data.panes.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("detail.noPanes")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{data.panes.map((p) => (
|
||||
<li key={p.id} className="text-xs">
|
||||
<Link
|
||||
to={`/workspace?lane=${p.laneId}&run=${encodeURIComponent(p.id)}`}
|
||||
className="inline-flex items-center gap-1.5 text-fg-primary hover:text-accent"
|
||||
>
|
||||
<Play className="h-3 w-3 text-status-success" />
|
||||
{p.laneTitle || p.laneCwd}
|
||||
</Link>
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
· {toolLabel(p.tool)}
|
||||
{p.account ? ` · ${p.account}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{data.workspaces.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-border pt-2">
|
||||
{data.workspaces.map((w) => (
|
||||
<Link
|
||||
key={w.id}
|
||||
to={`/workspace?lane=${w.id}`}
|
||||
title={w.cwd}
|
||||
className="badge py-0.5 text-[11px] hover:text-accent"
|
||||
>
|
||||
{w.livePanes > 0 && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-status-success" />
|
||||
)}
|
||||
{w.title || w.cwd.split("/").pop()}
|
||||
<span className="ml-1 text-fg-muted">{w.sessions}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{data.recentSessions.length > 0 && (
|
||||
<Panel title={t("detail.recentTitle")}>
|
||||
<ul className="divide-y divide-border">
|
||||
{data.recentSessions.slice(0, 5).map((s) => (
|
||||
<li key={s.id} className="flex items-center gap-3 py-1.5 text-xs">
|
||||
<SessionStatusBadge
|
||||
status={effectiveSessionStatus(s)}
|
||||
reason={sessionAwaitingReason(s)}
|
||||
/>
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
className="min-w-0 flex-1 truncate text-fg-primary hover:text-accent"
|
||||
>
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="flex-shrink-0 text-fg-muted">{toolLabel(s.tool || "claude")}</span>
|
||||
<span className="w-32 flex-shrink-0 text-right text-fg-muted">
|
||||
{formatDateTime(s.last_activity || s.started_at)}
|
||||
</span>
|
||||
<span className="w-16 flex-shrink-0 text-right font-mono text-fg-secondary">
|
||||
{s.cost ? fmtCost(s.cost) : "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Inline({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<span>
|
||||
<span className="text-fg-muted">{label}: </span>
|
||||
<span className="text-fg-primary">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="card p-3 space-y-2.5 min-w-0">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One slice of the 30-day spend: label, $, and a thin bar of its share. */
|
||||
function ShareRow({ label, cost, total }: { label: ReactNode; cost: number; total: number }) {
|
||||
const share = total > 0 ? (cost / total) * 100 : 0;
|
||||
return (
|
||||
<li className="space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="min-w-0 truncate text-fg-secondary">{label}</span>
|
||||
<span className="flex-shrink-0 tabular-nums text-fg-primary">
|
||||
{fmtCost(cost)} <span className="text-fg-muted">{Math.round(share)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="block h-1 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className="block h-full rounded-full bg-accent-solid"
|
||||
style={{ width: `${Math.min(100, share)}%` }}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
* account), a new password (typed or generated, copyable — someone else's
|
||||
* may be anything and makes them choose their own at next sign-in; your own
|
||||
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
|
||||
* account owns (remove, add from every workspace), and "sign out everywhere".
|
||||
* account owns (remove, add from every workspace), its spend quota (the
|
||||
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), and
|
||||
* "sign out everywhere".
|
||||
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||
* the server validates the whole change before it applies any of it. A
|
||||
@@ -17,7 +19,14 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNod
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
||||
import {
|
||||
api,
|
||||
type AuthUser,
|
||||
type ServerUserUsage,
|
||||
type UserPatch,
|
||||
type UserQuota,
|
||||
type UserQuotaSetting,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -27,6 +36,7 @@ import { PasswordRules } from "./PasswordRules";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Switch } from "../Switch";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { QuotaFields, parseLimit, useQuotaLabel, type QuotaPeriod } from "./UserQuota";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
@@ -55,10 +65,13 @@ export function UserEditDialog({
|
||||
user,
|
||||
self,
|
||||
users,
|
||||
quotaDefault = null,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
user: ServerUserUsage;
|
||||
/** The default spend quota, named beside the "Default" choice. */
|
||||
quotaDefault?: UserQuota | null;
|
||||
/** The account being edited is the signed-in admin's own. */
|
||||
self: boolean;
|
||||
users: ServerUserUsage[];
|
||||
@@ -81,6 +94,18 @@ export function UserEditDialog({
|
||||
const [added, setAdded] = useState<number[]>([]);
|
||||
const [pick, setPick] = useState("");
|
||||
const [signOut, setSignOut] = useState(false);
|
||||
// Spend quota: absent from an older server, which then gets no quota field.
|
||||
const quotaSetting = user.quotaSetting;
|
||||
const [quotaMode, setQuotaMode] = useState<UserQuotaSetting["mode"]>(
|
||||
quotaSetting?.mode ?? "default"
|
||||
);
|
||||
const [quotaLimit, setQuotaLimit] = useState(
|
||||
quotaSetting?.mode === "custom" ? String(quotaSetting.limit_usd) : ""
|
||||
);
|
||||
const [quotaPeriod, setQuotaPeriod] = useState<QuotaPeriod>(
|
||||
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
|
||||
);
|
||||
const quotaLabel = useQuotaLabel();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -117,12 +142,33 @@ export function UserEditDialog({
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
if (signOut) patch.signOut = true;
|
||||
const quotaValue = parseLimit(quotaLimit);
|
||||
const quotaBad = quotaMode === "custom" && quotaValue === null;
|
||||
if (quotaSetting && !quotaBad) {
|
||||
const same =
|
||||
quotaMode === quotaSetting.mode &&
|
||||
(quotaMode !== "custom" ||
|
||||
(quotaSetting.mode === "custom" &&
|
||||
quotaSetting.limit_usd === quotaValue &&
|
||||
quotaSetting.period === quotaPeriod));
|
||||
if (!same) {
|
||||
patch.quota =
|
||||
quotaMode === "custom"
|
||||
? {
|
||||
mode: "custom",
|
||||
limit_usd: quotaValue as number,
|
||||
period: quotaPeriod,
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
}
|
||||
: { mode: quotaMode };
|
||||
}
|
||||
}
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
// A blank field keeps the password. Someone else's may be anything (they
|
||||
// choose their own at next sign-in); your own must pass every rule.
|
||||
const weakOwn =
|
||||
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
|
||||
const ready = changed && current.length > 0 && !weakOwn && !busy;
|
||||
const ready = changed && current.length > 0 && !weakOwn && !quotaBad && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -479,6 +525,44 @@ export function UserEditDialog({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{quotaSetting && (
|
||||
<Section title={t("quota.title")}>
|
||||
<div className="space-y-2" data-testid="user-edit-quota">
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={quotaMode}
|
||||
onChange={(e) => setQuotaMode(e.target.value as UserQuotaSetting["mode"])}
|
||||
aria-label={t("quota.title")}
|
||||
>
|
||||
<option value="default">
|
||||
{quotaDefault
|
||||
? t("quota.modes.defaultOf", { quota: quotaLabel(quotaDefault) })
|
||||
: t("quota.modes.defaultNone")}
|
||||
</option>
|
||||
<option value="unlimited">{t("quota.modes.unlimited")}</option>
|
||||
<option value="custom">{t("quota.modes.custom")}</option>
|
||||
</select>
|
||||
{quotaMode === "custom" && (
|
||||
<QuotaFields
|
||||
limit={quotaLimit}
|
||||
period={quotaPeriod}
|
||||
onLimit={setQuotaLimit}
|
||||
onPeriod={setQuotaPeriod}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)}
|
||||
<span className="block text-[11px] text-fg-muted">
|
||||
{user.quota
|
||||
? t("quota.now", {
|
||||
spend: fmtCost(user.quota.spend),
|
||||
limit: quotaLabel(user.quota),
|
||||
})
|
||||
: t("quota.enforceHint")}
|
||||
</span>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title={t("edit.sessions")}>
|
||||
<Checkbox
|
||||
checked={signOut}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* @file UserQuota.tsx
|
||||
* @description Spend quotas per user on the Users page (server mode;
|
||||
* server/lib/user-quota.js): `QuotaSummary`, a row's "spent / limit per day"
|
||||
* line with its bar — red once used up, with when it opens again;
|
||||
* `QuotaFields`, a limit in dollars and its period (a day from local midnight,
|
||||
* or a sliding 5 hours); and `QuotaDefaultCard`, the default every user
|
||||
* without one of their own gets — an admin edits it with their own password
|
||||
* again (`PUT /api/users/quota-default`), an analyst only reads. A user's own
|
||||
* setting is part of the Edit user form (`UserEditDialog`). A user over their
|
||||
* quota can start no session and their prompts are refused until it resets.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Gauge, Pencil } from "lucide-react";
|
||||
import { api, type UserQuota, type UserQuotaStatus } from "../../lib/api";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Meter } from "../health/HostHealth";
|
||||
|
||||
export type QuotaPeriod = UserQuota["period"];
|
||||
export const QUOTA_PERIODS: QuotaPeriod[] = ["day", "5h"];
|
||||
|
||||
/** "$10 / day", "$2.50 / 5 h". */
|
||||
export function useQuotaLabel() {
|
||||
const { t } = useTranslation("users");
|
||||
return (q: Pick<UserQuota, "limit_usd" | "period">) =>
|
||||
t(`quota.per.${q.period}`, { limit: fmtCost(q.limit_usd) });
|
||||
}
|
||||
|
||||
/** A row's quota: spent of the limit, a bar, and when a used-up one opens again. */
|
||||
export function QuotaSummary({ quota }: { quota: UserQuotaStatus | null | undefined }) {
|
||||
const { t } = useTranslation("users");
|
||||
const label = useQuotaLabel();
|
||||
if (quota === undefined) return null;
|
||||
if (quota === null) {
|
||||
return <div className="text-[11px] text-fg-muted">{t("quota.none")}</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="mt-1 space-y-0.5"
|
||||
data-testid="user-quota"
|
||||
title={
|
||||
quota.exceeded && quota.resetsAt
|
||||
? t("quota.blockedUntil", { at: formatDateTime(quota.resetsAt) })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`whitespace-nowrap text-[11px] ${quota.exceeded ? "text-status-danger" : "text-fg-muted"}`}
|
||||
>
|
||||
{t("quota.used", { spend: fmtCost(quota.spend), limit: label(quota) })}
|
||||
{quota.source === "default" && <span className="ml-1">{t("quota.defaultTag")}</span>}
|
||||
</div>
|
||||
<Meter value={quota.pct} label={t("quota.title")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A dollar limit and its period. `limit` is the raw input text. */
|
||||
export function QuotaFields({
|
||||
limit,
|
||||
period,
|
||||
onLimit,
|
||||
onPeriod,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
}: {
|
||||
limit: string;
|
||||
period: QuotaPeriod;
|
||||
onLimit: (v: string) => void;
|
||||
onPeriod: (v: QuotaPeriod) => void;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative w-36">
|
||||
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-fg-muted">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
className="input w-full pl-6 text-sm tabular-nums"
|
||||
value={limit}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onLimit(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={t("quota.limit")}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="input flex-1 text-sm"
|
||||
value={period}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onPeriod(e.target.value as QuotaPeriod)}
|
||||
aria-label={t("quota.period")}
|
||||
>
|
||||
{QUOTA_PERIODS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`quota.periods.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A positive dollar amount from the input text, or null. */
|
||||
export function parseLimit(text: string): number | null {
|
||||
const n = Number(text);
|
||||
return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
|
||||
}
|
||||
|
||||
/** The default quota: shown to staff, edited by an admin with their own password. */
|
||||
export function QuotaDefaultCard({
|
||||
quotaDefault,
|
||||
isAdmin,
|
||||
onSaved,
|
||||
}: {
|
||||
/** `undefined` while loading, null for none. */
|
||||
quotaDefault: UserQuota | null | undefined;
|
||||
isAdmin: boolean;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const label = useQuotaLabel();
|
||||
const [editing, setEditing] = useState(false);
|
||||
return (
|
||||
<section className="card" data-testid="quota-default-card">
|
||||
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
|
||||
<Gauge className="h-4 w-4 text-fg-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("quota.cardTitle")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("quota.cardDescription")}</p>
|
||||
</div>
|
||||
<div className="text-sm tabular-nums text-fg-primary" data-testid="quota-default-value">
|
||||
{quotaDefault === undefined
|
||||
? "…"
|
||||
: quotaDefault
|
||||
? label(quotaDefault)
|
||||
: t("quota.noDefault")}
|
||||
</div>
|
||||
{isAdmin && quotaDefault !== undefined && (
|
||||
<button type="button" className="btn-ghost text-xs" onClick={() => setEditing(true)}>
|
||||
<Pencil className="h-3.5 w-3.5" /> {t("quota.editDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<QuotaDefaultDialog
|
||||
current={quotaDefault ?? null}
|
||||
onClose={() => setEditing(false)}
|
||||
onDone={(message) => {
|
||||
setEditing(false);
|
||||
onSaved(message);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaDefaultDialog({
|
||||
current,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
current: UserQuota | null;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [on, setOn] = useState(current !== null);
|
||||
const [limit, setLimit] = useState(current ? String(current.limit_usd) : "");
|
||||
const [period, setPeriod] = useState<QuotaPeriod>(current?.period ?? "day");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const value = parseLimit(limit);
|
||||
const ready = password.length > 0 && (!on || value !== null) && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.setQuotaDefault(
|
||||
on && value !== null
|
||||
? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() }
|
||||
: null,
|
||||
password
|
||||
);
|
||||
onDone(t("quota.defaultSaved"));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`errors.${err.code}`)
|
||||
: err.message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("quota.defaultDialog")}
|
||||
confirmLabel={t("save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3">
|
||||
<Checkbox
|
||||
checked={on}
|
||||
onChange={setOn}
|
||||
label={t("quota.defaultOn")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
<QuotaFields
|
||||
limit={limit}
|
||||
period={period}
|
||||
onLimit={setLimit}
|
||||
onPeriod={setPeriod}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={!on}
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("quota.enforceHint")}</p>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
/**
|
||||
* @file AccountPoolCard.test.tsx
|
||||
* @description The Users page's account pool card: one tab per AI tool — the
|
||||
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
|
||||
* Oh My Pi's login sharing, and a "no pool" note for any other installed
|
||||
* tool; says what an empty pool means per tool, an
|
||||
* @description The Users page's account pool card: the pools board with every
|
||||
* tool's accounts (live panes and quota) and members per pool, then a tab per
|
||||
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
|
||||
* other installed tool; an
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||
* it, saved with the admin's own password; the users show on the account.
|
||||
* The board moves members and accounts between pools (drag, or a column's
|
||||
* menu) — an account sits in one pool, so a move takes it out of the other —
|
||||
* splits an account off onto New pool, merges a pool by dragging its only
|
||||
* account into another, adds a saved profile to a pool, shows each account's
|
||||
* plan, and saves every change with ONE password.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { AccountPool } from "../../../lib/api";
|
||||
@@ -129,6 +134,28 @@ const OMP = {
|
||||
gateway: { running: false },
|
||||
};
|
||||
|
||||
/** The account names a pool column holds (its move menu names others too). */
|
||||
const held = (col: HTMLElement) =>
|
||||
within(col)
|
||||
.queryAllByTestId("pool-group-account")
|
||||
.map((c) => within(c).queryByText(/^(team|cx)-\d$/)?.textContent);
|
||||
|
||||
/** A drag-and-drop between two elements, through one shared DataTransfer. */
|
||||
function drag(from: Element, to: Element) {
|
||||
const store: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (store[k] = v),
|
||||
getData: (k: string) => store[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(store);
|
||||
},
|
||||
effectAllowed: "",
|
||||
};
|
||||
fireEvent.dragStart(from, { dataTransfer });
|
||||
fireEvent.dragOver(to, { dataTransfer });
|
||||
fireEvent.drop(to, { dataTransfer });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
aiTools.list.mockResolvedValue({
|
||||
@@ -147,8 +174,8 @@ beforeEach(() => {
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
users.list.mockResolvedValue({
|
||||
users: [
|
||||
{ id: 7, username: "mia", disabled: false },
|
||||
{ id: 8, username: "leo", disabled: false },
|
||||
{ id: 7, username: "mia", role: "developer", disabled: false },
|
||||
{ id: 8, username: "leo", role: "analyst", disabled: false },
|
||||
],
|
||||
});
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
@@ -166,11 +193,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
|
||||
it("shows every tool's accounts on the board, and a tab per tool with no pool", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(card(false));
|
||||
render(card(false));
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("cx-1")).toBeNull();
|
||||
// With no pool made yet, every account sits in the shared pool, by tool.
|
||||
const shared = screen.getByTestId("pool-shared");
|
||||
expect(shared).toHaveTextContent("Claude Code2");
|
||||
expect(shared).toHaveTextContent("Codex1");
|
||||
expect(shared).toHaveTextContent("cx-1");
|
||||
const [account, second] = screen.getAllByTestId("pool-account");
|
||||
// Every account of the pool has its card; quota is shown as what is LEFT.
|
||||
expect(second).toHaveTextContent("team-2");
|
||||
@@ -188,39 +219,25 @@ describe("AccountPoolCard", () => {
|
||||
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
|
||||
expect(account).toHaveTextContent("2 live");
|
||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||
// Without groups nothing is said about them on an account.
|
||||
expect(screen.queryByText("Shared pool")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
|
||||
// An analyst reads: no add, remove or new pool.
|
||||
expect(screen.queryByTestId("pool-add-account")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
const codexTab = screen.getByRole("tab", { name: /Codex/ });
|
||||
expect(codexTab).toHaveTextContent("1");
|
||||
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
|
||||
await user.click(codexTab);
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("team-1")).toBeNull();
|
||||
// A pool with no account still has its tab, with what that means.
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
|
||||
// An installed tool without a pool says so; an uninstalled one gets no tab.
|
||||
expect(screen.queryByRole("button", { name: "New pool" })).toBeNull();
|
||||
// Pool tools have no tab any more; an installed tool without a pool does.
|
||||
expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull();
|
||||
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
||||
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
||||
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(card(true));
|
||||
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a Cursor API key from the Cursor tab", async () => {
|
||||
it("adds a Cursor API key from Add account", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
|
||||
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(screen.queryByLabelText("From")).toBeNull();
|
||||
await user.type(screen.getByLabelText("Name"), "cu-1");
|
||||
@@ -238,27 +255,7 @@ describe("AccountPoolCard", () => {
|
||||
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||
});
|
||||
|
||||
it("shares a saved profile from its card", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the groups as cards and saves a new one with members and accounts", async () => {
|
||||
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
@@ -272,28 +269,180 @@ describe("AccountPoolCard", () => {
|
||||
});
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const groupCard = await screen.findByTestId("pool-group");
|
||||
expect(groupCard).toHaveTextContent("Team A");
|
||||
expect(groupCard).toHaveTextContent("leo");
|
||||
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
|
||||
await user.click(screen.getByRole("button", { name: "New group" }));
|
||||
await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Group name"), "Team B");
|
||||
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
|
||||
expect(screen.getByText("now in Team A")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
const teamA = await screen.findByTestId("pool-group");
|
||||
expect(teamA).toHaveTextContent("leo");
|
||||
expect(teamA).toHaveTextContent("team-1");
|
||||
await screen.findByText("mia");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New pool" }));
|
||||
const names = screen.getAllByLabelText("Pool name");
|
||||
await user.type(names[names.length - 1]!, "Team B");
|
||||
const teamB = screen.getAllByTestId("pool-group")[1]!;
|
||||
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
|
||||
expect(teamB).toHaveTextContent("mia");
|
||||
const add = within(teamB).getByLabelText("Move an account into Team B");
|
||||
await user.selectOptions(add, "codex|cx-1");
|
||||
await user.selectOptions(add, "claude|profile:work");
|
||||
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
|
||||
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
|
||||
expect(accountPool.updateGroup).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
2,
|
||||
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
|
||||
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
||||
// Team A did not change: nothing is sent for it.
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Team A",
|
||||
users: [{ userId: 8, username: "leo" }],
|
||||
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const teamA = await screen.findByTestId("pool-group");
|
||||
const shared = screen.getByTestId("pool-shared");
|
||||
const item = (col: HTMLElement, name: string) =>
|
||||
within(col)
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes(name))!;
|
||||
drag(item(shared, "team-2"), teamA);
|
||||
expect(held(teamA)).toEqual(["team-1", "team-2"]);
|
||||
// And back again: an account is in one pool, so it leaves the other.
|
||||
drag(item(teamA, "team-1"), shared);
|
||||
expect(held(teamA)).toEqual(["team-2"]);
|
||||
expect(held(shared)).toContain("team-1");
|
||||
expect(teamA).toHaveTextContent("team-2");
|
||||
await user.click(
|
||||
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
|
||||
);
|
||||
expect(shared).toHaveTextContent("leo");
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
name: "Team A",
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows each account's plan on its card", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
accounts: [{ ...POOL.accounts[0]!, plan: "Max 5x" }, POOL.accounts[1]!],
|
||||
});
|
||||
render(card(false));
|
||||
const [first, second] = await screen.findAllByTestId("pool-account");
|
||||
expect(within(first!).getByTestId("pool-account-plan")).toHaveTextContent("Max 5x");
|
||||
expect(within(second!).queryByTestId("pool-account-plan")).toBeNull();
|
||||
});
|
||||
|
||||
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Team A",
|
||||
users: [{ userId: 8, username: "leo" }],
|
||||
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Team B",
|
||||
users: [{ userId: 7, username: "mia" }],
|
||||
accounts: { claude: [], codex: ["cx-1"], cursor: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
accountPool.deleteGroup.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
|
||||
await screen.findByText("mia");
|
||||
const item = (col: HTMLElement, name: string) =>
|
||||
within(col)
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes(name))!;
|
||||
// Split: team-2 leaves the shared pool for a new pool of its own.
|
||||
drag(item(screen.getByTestId("pool-shared"), "team-2"), screen.getByTestId("pool-new"));
|
||||
const pools = screen.getAllByTestId("pool-group");
|
||||
expect(pools).toHaveLength(3);
|
||||
expect(held(pools[2]!)).toEqual(["team-2"]);
|
||||
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
|
||||
// Merge: Team B's only account into Team A brings mia along; Team B goes.
|
||||
drag(item(teamB!, "cx-1"), teamA!);
|
||||
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
|
||||
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
|
||||
expect(teamA).toHaveTextContent("mia");
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
name: "Team A",
|
||||
userIds: [8, 7],
|
||||
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
3,
|
||||
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
|
||||
"boss-pass"
|
||||
);
|
||||
// The members moved before the emptied pool was deleted.
|
||||
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
fn.mock.invocationCallOrder[0]!;
|
||||
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
|
||||
});
|
||||
|
||||
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||
@@ -312,7 +461,7 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const select = await screen.findByLabelText("Profile");
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||
@@ -337,7 +486,7 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
@@ -362,7 +511,6 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Codex/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
@@ -425,7 +573,6 @@ describe("AccountPoolCard", () => {
|
||||
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
||||
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
|
||||
+57
-21
@@ -1,18 +1,20 @@
|
||||
/**
|
||||
* @file MemberTmuxCard.test.tsx
|
||||
* @description The Users page's members' tmux card: hidden without one Linux
|
||||
* user per account, lists each running member server with its version and a
|
||||
* badge when it is older than the installed tmux, warns until tmux-member is
|
||||
* installed, and an admin restarts one member — or every idle outdated one,
|
||||
* the working member left out — only with their own password typed; an
|
||||
* analyst sees no restart control.
|
||||
* @file MemberTmux.test.tsx
|
||||
* @description Each member's tmux server as part of the users table
|
||||
* (`MemberTmux.tsx`): the bar above the table is hidden without one Linux
|
||||
* user per account, names the installed tmux and warns until tmux-member is
|
||||
* installed; a row's cell shows the version with a badge when it is older than
|
||||
* the installed tmux, and "—" for a member with no server; an admin restarts
|
||||
* one member — or every idle outdated one, the working member left out — only
|
||||
* with their own password typed; an analyst gets no "Restart idle".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { MemberTmux } from "../../../lib/api";
|
||||
import type { MemberTmux, MemberTmuxRow } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({
|
||||
tmux: vi.fn(),
|
||||
@@ -25,7 +27,41 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { MemberTmuxCard } from "../MemberTmuxCard";
|
||||
import { MemberTmuxBar, TmuxCell, TmuxRestartDialog, useMemberTmux } from "../MemberTmux";
|
||||
|
||||
/** The users table's use of it, reduced: the bar, one cell and restart per row. */
|
||||
function Harness({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { data, reload } = useMemberTmux();
|
||||
const [target, setTarget] = useState<MemberTmuxRow | "idle" | null>(null);
|
||||
return (
|
||||
<>
|
||||
<MemberTmuxBar data={data} isAdmin={isAdmin} onRestart={setTarget} />
|
||||
{data?.available &&
|
||||
data.users.map((u) => (
|
||||
<div key={u.id} data-testid={`row-${u.username}`}>
|
||||
<span>{u.username}</span>
|
||||
<TmuxCell row={u} />
|
||||
{isAdmin && u.running && (
|
||||
<button type="button" onClick={() => setTarget(u)}>
|
||||
Restart {u.username}'s tmux
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{target && (
|
||||
<TmuxRestartDialog
|
||||
target={target}
|
||||
data={data}
|
||||
onClose={() => setTarget(null)}
|
||||
onDone={() => {
|
||||
setTarget(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DATA: MemberTmux = {
|
||||
available: true,
|
||||
@@ -52,33 +88,33 @@ beforeEach(() => {
|
||||
users.tmux.mockResolvedValue(DATA);
|
||||
});
|
||||
|
||||
describe("MemberTmuxCard", () => {
|
||||
it("renders nothing without one Linux user per account", async () => {
|
||||
describe("MemberTmux", () => {
|
||||
it("shows no bar without one Linux user per account", async () => {
|
||||
users.tmux.mockResolvedValue({ available: false, users: [] });
|
||||
const { container } = render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByTestId("member-tmux")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists running servers and marks the ones older than the installed tmux", async () => {
|
||||
render(<MemberTmuxCard isAdmin={false} />);
|
||||
expect(await screen.findByText("mia")).toBeInTheDocument();
|
||||
expect(screen.queryByText("bob")).toBeNull();
|
||||
it("marks the servers older than the installed tmux, and a member with none", async () => {
|
||||
render(<Harness isAdmin={false} />);
|
||||
expect(await screen.findByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("older than installed")).toHaveLength(2);
|
||||
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-mia").textContent).toContain("2 sessions");
|
||||
expect(screen.getByTestId("row-bob").textContent).toContain("—");
|
||||
// An analyst reads only.
|
||||
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("warns until tmux-member is installed", async () => {
|
||||
users.tmux.mockResolvedValue({ ...DATA, matched: false });
|
||||
render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restarts one member only with the admin's password", async () => {
|
||||
users.tmuxRestart.mockResolvedValue({});
|
||||
render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
|
||||
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
|
||||
const go = screen.getByRole("button", { name: "Restart" });
|
||||
@@ -90,7 +126,7 @@ describe("MemberTmuxCard", () => {
|
||||
|
||||
it("restarts every idle outdated member at once, the working one left out", async () => {
|
||||
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
|
||||
render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
|
||||
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @file UserQuota.test.tsx
|
||||
* @description Spend quotas on the Users page: a row's "spent of limit" line
|
||||
* (red once used up, "(default)" when it is the default), the default quota
|
||||
* card (an admin edits it with their own password, an analyst only reads),
|
||||
* and the Edit user form sending a user's own quota as part of its ONE PATCH.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ServerUserUsage } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({
|
||||
setQuotaDefault: vi.fn(),
|
||||
update: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { users, lanes } };
|
||||
});
|
||||
|
||||
import { QuotaDefaultCard, QuotaSummary } from "../UserQuota";
|
||||
import { UserEditDialog } from "../UserEditDialog";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
users.setQuotaDefault.mockResolvedValue({ user: {}, quotaDefault: null });
|
||||
users.update.mockResolvedValue({ user: {} });
|
||||
users.get.mockResolvedValue({ user: {}, workspaces: [] });
|
||||
lanes.list.mockResolvedValue({ lanes: [] });
|
||||
});
|
||||
|
||||
describe("QuotaSummary", () => {
|
||||
it("shows spent of the limit, red and marked default once used up", () => {
|
||||
render(
|
||||
<QuotaSummary
|
||||
quota={{
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
source: "default",
|
||||
spend: 12.5,
|
||||
pct: 125,
|
||||
exceeded: true,
|
||||
resetsAt: "2026-10-29T17:00:00.000Z",
|
||||
since: "2026-10-28T17:00:00.000Z",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const line = screen.getByText(/of \$10\.00 \/ day/);
|
||||
expect(line.textContent).toContain("$12.50");
|
||||
expect(line.textContent).toContain("(default)");
|
||||
expect(line.className).toContain("text-status-danger");
|
||||
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "100");
|
||||
});
|
||||
|
||||
it("says No quota without one, and nothing for an older server", () => {
|
||||
const { container, rerender } = render(<QuotaSummary quota={null} />);
|
||||
expect(screen.getByText("No quota")).toBeInTheDocument();
|
||||
rerender(<QuotaSummary quota={undefined} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuotaDefaultCard", () => {
|
||||
it("an admin sets the default with their own password", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
render(<QuotaDefaultCard quotaDefault={null} isAdmin onSaved={onSaved} />);
|
||||
expect(screen.getByTestId("quota-default-value").textContent).toBe("No default — no limit");
|
||||
await user.click(screen.getByRole("button", { name: /Edit/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "Limit what each user may spend" }));
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "25");
|
||||
await user.selectOptions(screen.getByLabelText("Period"), "5h");
|
||||
const save = screen.getByRole("button", { name: "Save" });
|
||||
expect(save).toBeDisabled(); // no password yet
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.click(save);
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
expect(users.setQuotaDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit_usd: 25, period: "5h" }),
|
||||
"pw"
|
||||
);
|
||||
});
|
||||
|
||||
it("an analyst only reads it", () => {
|
||||
render(
|
||||
<QuotaDefaultCard
|
||||
quotaDefault={{ limit_usd: 5, period: "day" }}
|
||||
isAdmin={false}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("quota-default-value").textContent).toBe("$5.00 / day");
|
||||
expect(screen.queryByRole("button", { name: /Edit/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserEditDialog quota", () => {
|
||||
const mia: ServerUserUsage = {
|
||||
id: 7,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
workspaces: 0,
|
||||
sessions: 0,
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
quotaSetting: { mode: "default" },
|
||||
quota: null,
|
||||
} as ServerUserUsage;
|
||||
|
||||
it("sends the user's own limit in the one PATCH", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDone = vi.fn();
|
||||
render(
|
||||
<UserEditDialog
|
||||
user={mia}
|
||||
self={false}
|
||||
users={[mia]}
|
||||
quotaDefault={{ limit_usd: 10, period: "day" }}
|
||||
onClose={() => {}}
|
||||
onDone={onDone}
|
||||
/>
|
||||
);
|
||||
const select = screen.getByLabelText("Spend quota");
|
||||
expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument();
|
||||
await user.selectOptions(select, "custom");
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "40");
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
7,
|
||||
{ quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) },
|
||||
"pw"
|
||||
);
|
||||
});
|
||||
|
||||
it("No limit is a change; an empty custom limit keeps Save off", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<UserEditDialog user={mia} self={false} users={[mia]} onClose={() => {}} onDone={() => {}} />
|
||||
);
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "custom");
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +1,4 @@
|
||||
{
|
||||
"title": "Administration",
|
||||
"description": "Accounts and the server AgentHub runs on.",
|
||||
"tabs": {
|
||||
"users": "Users",
|
||||
"server": "Server"
|
||||
},
|
||||
"server": {
|
||||
"title": "Server",
|
||||
"uptime": "up {{v}}",
|
||||
"uptimeDays": "{{d}} d {{h}} h",
|
||||
"uptimeHours": "{{h}} h {{m}} min",
|
||||
"cpu": "CPU",
|
||||
"cores": "{{n}} cores · load {{load}}",
|
||||
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
|
||||
"memory": "Memory",
|
||||
"swap": "Swap",
|
||||
"homesDisk": "Homes disk",
|
||||
"homesDiskHelp": "The disk that holds the users' home folders.",
|
||||
"free": "free",
|
||||
"usageTitle": "Usage by user",
|
||||
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
|
||||
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
|
||||
"who": "User",
|
||||
"processes": "Processes",
|
||||
"home": "Home",
|
||||
"sampling": "Sampling…",
|
||||
"hostGroup": "Host account (AgentHub and other host processes)",
|
||||
"systemGroup": "System (root and other accounts)",
|
||||
"disks": "Disks",
|
||||
"mount": "Mount",
|
||||
"device": "Device",
|
||||
"used": "Used",
|
||||
"homesBadge": "homes",
|
||||
"gpus": "GPUs"
|
||||
}
|
||||
"description": "Accounts and the server AgentHub runs on."
|
||||
}
|
||||
|
||||
@@ -30,14 +30,6 @@
|
||||
"since": "waiting {{ago}}"
|
||||
},
|
||||
"health": {
|
||||
"runtime": "Runtime",
|
||||
"cores_one": "{{count}} core",
|
||||
"cores_other": "{{count}} cores",
|
||||
"uptime": "Uptime",
|
||||
"cpu": "CPU (1/5/15m)",
|
||||
"nodeRss": "Node RSS",
|
||||
"hostMemory": "Host Memory",
|
||||
"hostMemoryTip": "Host Memory: {{pct}}% used\n{{used}} / {{total}}",
|
||||
"heap": "V8 Heap",
|
||||
"heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}",
|
||||
"storage": "Storage",
|
||||
@@ -50,12 +42,7 @@
|
||||
"events": "Events",
|
||||
"score": "Health Score",
|
||||
"formula": "Formula",
|
||||
"formulaTip": "Composite index formula:\n0.4 × Success rate ({{success}}%)\n+ 0.25 × Cache hit ({{cache}}%)\n+ 0.25 × (100 − Error rate) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
|
||||
"scoreTip": "Score: {{score}} / 100\n\n• Success rate (40%): {{success}}%\n• Cache hit (25%): {{cache}}%\n• Error avoidance (25%): {{noError}}%\n• Memory health (10%): {{noHeap}}%",
|
||||
"cache": "Cache",
|
||||
"cacheTip": "Cache hit rate: {{pct}}%\nHits: {{hits}}\nMisses: {{misses}}",
|
||||
"errors": "Errors",
|
||||
"errorsTip": "Error rate: {{pct}}%\n<5% = healthy, 5–15% = warning, >15% = critical",
|
||||
"cacheTip": "Transcript cache hit rate: {{pct}}% (shown, not scored — it restarts empty with the server)",
|
||||
"compact": "Compact",
|
||||
"compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.",
|
||||
"saved": "Saved",
|
||||
@@ -65,9 +52,10 @@
|
||||
"modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%",
|
||||
"noModelData": "No model data",
|
||||
"concurrency": "Concurrency",
|
||||
"intervals_one": "{{count}} interval",
|
||||
"intervals_other": "{{count}} intervals",
|
||||
"intervals_one": "{{count}} agent type",
|
||||
"intervals_other": "{{count}} agent types",
|
||||
"concurrencyTip": "Peak: {{peak}} parallel sessions\nActive intervals: {{active}}\nAvg: {{avg}}",
|
||||
"laneTip": "{{name}}\n{{count}} agents\nRuns {{start}}–{{end}}% of the session (avg)",
|
||||
"noConcurrencyData": "No concurrency data",
|
||||
"peak": "Peak",
|
||||
"peakTip": "Peak: {{count}} sessions running simultaneously.",
|
||||
@@ -99,11 +87,101 @@
|
||||
"synchronous": "Synchronous",
|
||||
"autoVacuum": "Auto-vacuum",
|
||||
"foreignKeys": "Foreign keys",
|
||||
"busyTimeout": "Busy timeout"
|
||||
"busyTimeout": "Busy timeout",
|
||||
"groupApp": "AgentHub",
|
||||
"groupAppHint": "How the dashboard itself is doing: score, database, sessions at once",
|
||||
"groupMachine": "Machine",
|
||||
"groupMachineHint": "The computer AgentHub runs on: CPU, memory, disks, processes",
|
||||
"groupActivity": "Agent activity",
|
||||
"groupActivityHint": "What the agents used: tokens by model, tools, subagents",
|
||||
"scoreFormula": "100 minus what each check costs:",
|
||||
"scoreResult": "= {{score}} / 100",
|
||||
"check": {
|
||||
"loop": "AgentHub response",
|
||||
"cpu": "CPU wait",
|
||||
"memory": "Memory wait",
|
||||
"io": "Disk wait",
|
||||
"ram": "RAM in use",
|
||||
"disk": "Data disk",
|
||||
"heap": "V8 heap",
|
||||
"hooks": "Last hook",
|
||||
"agents": "Agent errors 24h"
|
||||
},
|
||||
"checkTip": {
|
||||
"loop": "How long AgentHub's event loop was held up (p99 over the last minute). Every page, hook and terminal keystroke waits this long.",
|
||||
"cpu": "CPU pressure (Linux PSI, last 10 s): share of time tasks waited for a free CPU — the machine feels slow above a few %.",
|
||||
"cpuLoad": "Load per core (1 min) — this machine has no PSI, so CPU wait is estimated from load.",
|
||||
"memory": "Memory pressure (Linux PSI, last 10 s): share of time tasks stalled reclaiming or swapping memory.",
|
||||
"io": "Disk I/O pressure (Linux PSI, last 10 s): share of time tasks waited on the disk — a slow or busy HDD shows here.",
|
||||
"ram": "Memory in use (total minus available).",
|
||||
"disk": "How full the disk holding AgentHub's database is ({{mount}}).",
|
||||
"heap": "AgentHub's V8 heap against its limit.",
|
||||
"hooks": "Minutes since the last hook event, counted only while an agent is working: silence then means the dashboard stopped seeing it.",
|
||||
"agents": "Agents that ended in the last 24 h with an error (errors / finished; needs at least 5)."
|
||||
},
|
||||
"checkNa": "Not measured here — costs nothing.",
|
||||
"checkThresholds": "Amber from {{warn}}{{unit}}, red from {{bad}}{{unit}}",
|
||||
"checkPenalty": "Costs {{n}} of up to {{max}} points",
|
||||
"hooksMissing": "not installed",
|
||||
"hooksIdle": "idle",
|
||||
"minutesAgo": "{{n}} min ago",
|
||||
"verdict": {
|
||||
"ok": "Healthy",
|
||||
"warn": "Degraded",
|
||||
"bad": "Unhealthy"
|
||||
},
|
||||
"allOk": "Every check is fine",
|
||||
"badCount_one": "{{count}} red",
|
||||
"badCount_other": "{{count}} red",
|
||||
"warnCount_one": "{{count}} amber",
|
||||
"warnCount_other": "{{count}} amber",
|
||||
"lagGroup": "Lag",
|
||||
"systemGroup": "Capacity & signals"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
"body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.",
|
||||
"reload": "Reload"
|
||||
},
|
||||
"host": {
|
||||
"uptimeDays": "{{d}} d {{h}} h",
|
||||
"uptimeHours": "{{h}} h {{m}} min",
|
||||
"cpu": "CPU",
|
||||
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
|
||||
"memory": "Memory",
|
||||
"swap": "Swap",
|
||||
"free": "free",
|
||||
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
|
||||
"hostGroup": "Host account (AgentHub and other host processes)",
|
||||
"systemGroup": "System (root and other accounts)",
|
||||
"homesBadge": "homes",
|
||||
"historyTitle": "History",
|
||||
"historyHelp": "Kept in memory, one point every {{s}} s, since the server started at {{at}}.",
|
||||
"historyCpu": "CPU (% of the machine)",
|
||||
"historyMemory": "Memory used (%)",
|
||||
"range": "Range",
|
||||
"range1h": "1 h",
|
||||
"range6h": "6 h",
|
||||
"range24h": "24 h",
|
||||
"coreN": "CPU {{n}}",
|
||||
"threads": "{{n}} threads",
|
||||
"physical": "{{n}} cores",
|
||||
"perSecond": "{{v}}/s",
|
||||
"temperatures": "Temperatures",
|
||||
"topProcesses": "Busiest processes",
|
||||
"cpuModel": "CPU",
|
||||
"storageNetwork": "Disks & network",
|
||||
"processCount": "{{n}} processes",
|
||||
"title": "Server",
|
||||
"os": "System",
|
||||
"uptime": "Uptime",
|
||||
"load": "Load (1/5/15 min)",
|
||||
"agenthubTip": "pid {{pid}} · Node {{node}}",
|
||||
"sampling": "Sampling…",
|
||||
"sampled": "{{s}} s sample",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
"gitBar.switch": "Branch",
|
||||
"gitBar.switchApply": "Switch branch",
|
||||
"gitBar.switchCurrent": "current",
|
||||
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
||||
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||
"gitBar.switchLabel": "Switch branch…",
|
||||
|
||||
@@ -78,6 +78,13 @@
|
||||
"quotaWindow": {
|
||||
"wk": "Week",
|
||||
"mo": "Month"
|
||||
},
|
||||
"userQuota": "Quota {{pct}}%",
|
||||
"userQuotaTitle": "Your spend quota: {{spend}} of {{limit}} {{period}}",
|
||||
"userQuotaBlocked": "Your spend quota is used up ({{spend}} of {{limit}} {{period}}): no new session and no prompt until {{at}}",
|
||||
"userQuotaPeriod": {
|
||||
"day": "today",
|
||||
"5h": "in the last 5 hours"
|
||||
}
|
||||
},
|
||||
"version": "Version",
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
"tableAgents": "Agents",
|
||||
"tableCost": "Cost",
|
||||
"tableDirectory": "Directory",
|
||||
"tableUser": "User",
|
||||
"tableTool": "AI Tool",
|
||||
"tableActions": "Actions",
|
||||
"noOwner": "No owner",
|
||||
"moreActions": "More actions",
|
||||
"viewDetails": "View details",
|
||||
"defaultName": "Session ",
|
||||
"detail": {
|
||||
"waitingBanner": {
|
||||
|
||||
@@ -1410,5 +1410,19 @@
|
||||
"apikey": "API key",
|
||||
"credentials": "Logins"
|
||||
}
|
||||
},
|
||||
"poolAccount": {
|
||||
"title": "Pool account",
|
||||
"description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.",
|
||||
"automatic": "Automatic",
|
||||
"select": "{{tool}} pool account",
|
||||
"count_one": "{{count}} account you may use",
|
||||
"count_other": "{{count}} accounts you may use",
|
||||
"pinned": "An admin pinned you to {{account}}",
|
||||
"expired": "expired",
|
||||
"full": "quota full",
|
||||
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
||||
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
||||
"nextSession": "{{account}} is used from your next session"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
"ELOCALMODE": "Accounts are managed in server mode only.",
|
||||
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
|
||||
"EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).",
|
||||
"EPWCHANGE": "Choose your own password first."
|
||||
"EPWCHANGE": "Choose your own password first.",
|
||||
"EBADQUOTA": "Enter a positive dollar limit and a period of a day or 5 hours."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
@@ -133,7 +134,7 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
"description": "Accounts each tool's new sessions run on, one tab per AI tool. The least-used account is picked (or the one a user is pinned to), so nobody signs in per user.",
|
||||
"description": "Each pool holds accounts of every AI tool (Claude Code, Codex, Cursor) and the members who run on them. A member's new session takes the least-used account of their pool, so nobody signs in per user.",
|
||||
"refresh": "Re-read quotas",
|
||||
"add": "Add account",
|
||||
"account": "Account",
|
||||
@@ -254,20 +255,20 @@
|
||||
"shareNamed": "Share {{name}} to the pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
"description": "Give each team its own accounts: a group's members run only on the group's accounts. Users in no group share the accounts no group lists.",
|
||||
"new": "New group",
|
||||
"title": "Pools",
|
||||
"description": "Members in no pool, and accounts in no pool, make up the shared pool.",
|
||||
"new": "New pool",
|
||||
"empty": "No groups yet — every user shares every account.",
|
||||
"members_one": "{{count}} member",
|
||||
"members_other": "{{count}} members",
|
||||
"edit": "Edit group {{name}}",
|
||||
"delete": "Delete group {{name}}",
|
||||
"deleteHint": "Its members go back to the shared pool. No account is removed.",
|
||||
"delete": "Delete pool {{name}}",
|
||||
"deleteHint": "Its members and accounts go back to the shared pool. No account is removed.",
|
||||
"noAccounts": "none — members sign in themselves",
|
||||
"saved": "Group {{name}} saved",
|
||||
"deleted": "Group {{name}} deleted",
|
||||
"deleted": "Pool {{name}} deleted",
|
||||
"hint": "Tick the members and the accounts they run on. A user belongs to one group, so ticking one moves them here. An account can serve several groups.",
|
||||
"name": "Group name",
|
||||
"name": "Pool name",
|
||||
"memberList": "Members",
|
||||
"inGroup": "now in {{group}}",
|
||||
"accountList": "Accounts",
|
||||
@@ -278,7 +279,57 @@
|
||||
"5h": "5 hours",
|
||||
"wk": "Week"
|
||||
},
|
||||
"resetsIn": "resets in {{time}} · {{at}}"
|
||||
"resetsIn": "resets in {{time}} · {{at}}",
|
||||
"members": {
|
||||
"title": "Members",
|
||||
"filter": "Show members",
|
||||
"all": "All",
|
||||
"noGroup": "No pool",
|
||||
"search": "Search members",
|
||||
"tickShown": "Tick all {{count}}",
|
||||
"noneShown": "No member matches."
|
||||
},
|
||||
"board": {
|
||||
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Nothing is saved until Save.",
|
||||
"sharedHint": "Members and accounts in no pool",
|
||||
"toShared": "Send {{name}} back to the shared pool",
|
||||
"removeAccount": "Send {{name}} back to the shared pool",
|
||||
"moveHere_one": "Move {{count}} here",
|
||||
"moveHere_other": "Move {{count}} here",
|
||||
"noMembers": "No members",
|
||||
"noSharedAccounts": "No account is left for the shared pool.",
|
||||
"dropMembers": "Drop members here",
|
||||
"untitled": "New pool",
|
||||
"new": "not saved yet",
|
||||
"pick": "Pick members of {{name}}",
|
||||
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Applied on the board — Save sends it.",
|
||||
"apply": "Apply",
|
||||
"addAccount": "Move an account into {{name}}",
|
||||
"addAccountOption": "+ Move an account or saved profile here…",
|
||||
"profilesOf": "Saved {{tool}} profiles — moved into the pool on Save",
|
||||
"profilePending": "A saved profile: it is moved into the pool when you save",
|
||||
"pending_one": "{{count}} unsaved change",
|
||||
"pending_other": "{{count}} unsaved changes",
|
||||
"needsName": "Every pool needs a name.",
|
||||
"discard": "Discard",
|
||||
"saveTitle": "Save the pools",
|
||||
"saveHint": "Type your own password once to apply every change below.",
|
||||
"summaryProfile": "Move the saved profile {{name}} into the pool",
|
||||
"summaryNew": "Create {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"summaryGroup": "Set {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"saved": "Pools saved",
|
||||
"dropAccounts": "Drag accounts here",
|
||||
"noAccounts": "No accounts",
|
||||
"accountsCount_one": "{{count}} account",
|
||||
"accountsCount_other": "{{count}} accounts",
|
||||
"mergeHint": "Drop to merge {{from}} into {{into}}",
|
||||
"merged": "{{from}} merged into {{into}} — Save to apply",
|
||||
"summaryMerge": "Merge {{from}} into {{into}}: pool {{from}} is deleted",
|
||||
"newDrop": "Drop here to split it into a pool of its own",
|
||||
"newHint": "Or drag an account or a member here to split it off"
|
||||
},
|
||||
"addAccount": "Add account",
|
||||
"otherTools": "Other AI tools"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
@@ -315,7 +366,9 @@
|
||||
"doneIdle": "Restarted: {{names}}",
|
||||
"errors": {
|
||||
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
|
||||
}
|
||||
},
|
||||
"sessionsCount_one": "{{count}} session",
|
||||
"sessionsCount_other": "{{count}} sessions"
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Open home in Files",
|
||||
@@ -331,5 +384,66 @@
|
||||
"digit": "A digit",
|
||||
"symbol": "A symbol (e.g. ! - _ @)",
|
||||
"username": "Does not contain the username"
|
||||
},
|
||||
"detail": {
|
||||
"open": "Open {{name}}'s details",
|
||||
"lastLogin": "Last sign-in",
|
||||
"lastSession": "Last activity",
|
||||
"never": "never",
|
||||
"spendDaily": "Spend · last 30 days",
|
||||
"noUsage": "No spend in the last 30 days.",
|
||||
"pool": "Account pool & quota",
|
||||
"poolPinned": "pinned to {{account}}",
|
||||
"poolChosen": "chose {{account}}",
|
||||
"poolGroup": "group {{name}}",
|
||||
"poolShared": "shared pool",
|
||||
"poolNone": "No account in this tool's pool.",
|
||||
"poolNotAllowed": "no longer allowed",
|
||||
"poolTotal7d": "Account total 7 days: {{cost}}",
|
||||
"noPanes": "Nothing running.",
|
||||
"recentTitle": "Recent sessions",
|
||||
"failed": "Could not load this user",
|
||||
"activeLabel": "Running now",
|
||||
"activeValue": "{{sessions}} sessions · {{panes}} panes",
|
||||
"nowTitle": "Running now",
|
||||
"poolTheirs7d": "theirs 7 d {{cost}}"
|
||||
},
|
||||
"activity": "Activity",
|
||||
"spend": "Spend today",
|
||||
"spendSub": "7 d {{week}} · 30 d {{month}}",
|
||||
"sessionsN": "{{n}} sessions",
|
||||
"workspacesN": "{{n}} workspaces",
|
||||
"tmuxCol": "tmux",
|
||||
"quota": {
|
||||
"title": "Spend quota",
|
||||
"limit": "Limit in dollars",
|
||||
"period": "Period",
|
||||
"periods": {
|
||||
"day": "per day (from local midnight)",
|
||||
"5h": "per 5 hours (sliding)"
|
||||
},
|
||||
"per": {
|
||||
"day": "{{limit}} / day",
|
||||
"5h": "{{limit}} / 5 h"
|
||||
},
|
||||
"used": "{{spend}} of {{limit}}",
|
||||
"defaultTag": "(default)",
|
||||
"none": "No quota",
|
||||
"blockedUntil": "Used up — no new session and no prompt until {{at}}",
|
||||
"cardTitle": "Default spend quota",
|
||||
"cardDescription": "Every user without a quota of their own gets this one. Once it is used up they can start no session and their prompts are refused until it resets; a pane no hook can stop (Antigravity, Cursor, Codex without trusted hooks, omp on its own login) is ended.",
|
||||
"noDefault": "No default — no limit",
|
||||
"editDefault": "Edit",
|
||||
"defaultDialog": "Default spend quota",
|
||||
"defaultOn": "Limit what each user may spend",
|
||||
"defaultSaved": "Default spend quota saved",
|
||||
"enforceHint": "Over the quota: no new session, Claude Code and Codex prompts refused, other AI tools' panes ended — until it resets.",
|
||||
"modes": {
|
||||
"defaultOf": "Default ({{quota}})",
|
||||
"defaultNone": "Default (no limit)",
|
||||
"unlimited": "No limit",
|
||||
"custom": "Their own limit"
|
||||
},
|
||||
"now": "Now: {{spend}} of {{limit}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,4 @@
|
||||
{
|
||||
"title": "Quản trị",
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
|
||||
"tabs": {
|
||||
"users": "Người dùng",
|
||||
"server": "Máy chủ"
|
||||
},
|
||||
"server": {
|
||||
"title": "Máy chủ",
|
||||
"uptime": "đã chạy {{v}}",
|
||||
"uptimeDays": "{{d}} ngày {{h}} giờ",
|
||||
"uptimeHours": "{{h}} giờ {{m}} phút",
|
||||
"cpu": "CPU",
|
||||
"cores": "{{n}} lõi · tải {{load}}",
|
||||
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
|
||||
"memory": "Bộ nhớ",
|
||||
"swap": "Swap",
|
||||
"homesDisk": "Ổ chứa thư mục người dùng",
|
||||
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
|
||||
"free": "trống",
|
||||
"usageTitle": "Mức sử dụng theo người dùng",
|
||||
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
|
||||
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
|
||||
"who": "Người dùng",
|
||||
"processes": "Tiến trình",
|
||||
"home": "Thư mục home",
|
||||
"sampling": "Đang lấy mẫu…",
|
||||
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
|
||||
"systemGroup": "Hệ thống (root và tài khoản khác)",
|
||||
"disks": "Ổ đĩa",
|
||||
"mount": "Điểm gắn",
|
||||
"device": "Thiết bị",
|
||||
"used": "Đã dùng",
|
||||
"homesBadge": "home",
|
||||
"gpus": "GPU"
|
||||
}
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy."
|
||||
}
|
||||
|
||||
@@ -30,14 +30,6 @@
|
||||
"since": "đang chờ {{ago}}"
|
||||
},
|
||||
"health": {
|
||||
"runtime": "Môi trường chạy",
|
||||
"cores_one": "{{count}} lõi",
|
||||
"cores_other": "{{count}} lõi",
|
||||
"uptime": "Thời gian chạy",
|
||||
"cpu": "CPU (1/5/15 phút)",
|
||||
"nodeRss": "Bộ nhớ Node (RSS)",
|
||||
"hostMemory": "Bộ nhớ máy",
|
||||
"hostMemoryTip": "Bộ nhớ máy: đã dùng {{pct}}%\n{{used}} / {{total}}",
|
||||
"heap": "Heap V8",
|
||||
"heapTip": "Heap V8: đã dùng {{pct}}%\n{{used}} / giới hạn {{limit}}\nĐã cấp phát: {{allocated}}\nNgoài heap: {{external}}",
|
||||
"storage": "Lưu trữ",
|
||||
@@ -50,12 +42,7 @@
|
||||
"events": "Sự kiện",
|
||||
"score": "Điểm sức khỏe",
|
||||
"formula": "Công thức",
|
||||
"formulaTip": "Công thức chỉ số tổng hợp:\n0.4 × Tỉ lệ thành công ({{success}}%)\n+ 0.25 × Trúng cache ({{cache}}%)\n+ 0.25 × (100 − Tỉ lệ lỗi) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
|
||||
"scoreTip": "Điểm: {{score}} / 100\n\n• Tỉ lệ thành công (40%): {{success}}%\n• Trúng cache (25%): {{cache}}%\n• Tránh lỗi (25%): {{noError}}%\n• Sức khỏe bộ nhớ (10%): {{noHeap}}%",
|
||||
"cache": "Cache",
|
||||
"cacheTip": "Tỉ lệ trúng cache: {{pct}}%\nTrúng: {{hits}}\nTrượt: {{misses}}",
|
||||
"errors": "Lỗi",
|
||||
"errorsTip": "Tỉ lệ lỗi: {{pct}}%\n<5% = tốt, 5–15% = cảnh báo, >15% = nghiêm trọng",
|
||||
"cacheTip": "Tỉ lệ trúng cache transcript: {{pct}}% (chỉ để xem, không tính điểm — cache trống lại mỗi lần server khởi động)",
|
||||
"compact": "Nén",
|
||||
"compactTip": "Số lần nén transcript: {{count}}\nThu gọn cửa sổ ngữ cảnh bằng cách tóm tắt các lượt.",
|
||||
"saved": "Tiết kiệm",
|
||||
@@ -65,9 +52,10 @@
|
||||
"modelTip": "{{model}}\nĐầu vào: {{input}}\nĐầu ra: {{output}}\nĐọc cache: {{cacheRead}}\nTỉ trọng: {{pct}}%",
|
||||
"noModelData": "Chưa có dữ liệu mô hình",
|
||||
"concurrency": "Đồng thời",
|
||||
"intervals_one": "{{count}} khoảng",
|
||||
"intervals_other": "{{count}} khoảng",
|
||||
"intervals_one": "{{count}} loại agent",
|
||||
"intervals_other": "{{count}} loại agent",
|
||||
"concurrencyTip": "Đỉnh: {{peak}} phiên song song\nKhoảng có hoạt động: {{active}}\nTrung bình: {{avg}}",
|
||||
"laneTip": "{{name}}\n{{count}} agent\nChạy từ {{start}}–{{end}}% thời lượng phiên (trung bình)",
|
||||
"noConcurrencyData": "Chưa có dữ liệu đồng thời",
|
||||
"peak": "Đỉnh",
|
||||
"peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.",
|
||||
@@ -99,11 +87,101 @@
|
||||
"synchronous": "Đồng bộ",
|
||||
"autoVacuum": "Tự dọn dẹp",
|
||||
"foreignKeys": "Khóa ngoại",
|
||||
"busyTimeout": "Thời gian chờ khóa"
|
||||
"busyTimeout": "Thời gian chờ khóa",
|
||||
"groupApp": "AgentHub",
|
||||
"groupAppHint": "Tình trạng của chính dashboard: điểm, cơ sở dữ liệu, số phiên đồng thời",
|
||||
"groupMachine": "Máy chủ",
|
||||
"groupMachineHint": "Máy đang chạy AgentHub: CPU, bộ nhớ, ổ đĩa, tiến trình",
|
||||
"groupActivity": "Hoạt động agent",
|
||||
"groupActivityHint": "Agent đã dùng gì: token theo model, công cụ, subagent",
|
||||
"scoreFormula": "100 trừ đi phần mỗi mục bị trừ:",
|
||||
"scoreResult": "= {{score}} / 100",
|
||||
"check": {
|
||||
"loop": "Phản hồi AgentHub",
|
||||
"cpu": "Chờ CPU",
|
||||
"memory": "Chờ bộ nhớ",
|
||||
"io": "Chờ ổ đĩa",
|
||||
"ram": "RAM đang dùng",
|
||||
"disk": "Ổ chứa dữ liệu",
|
||||
"heap": "V8 heap",
|
||||
"hooks": "Hook gần nhất",
|
||||
"agents": "Agent lỗi 24h"
|
||||
},
|
||||
"checkTip": {
|
||||
"loop": "Event loop của AgentHub bị nghẽn bao lâu (p99 trong phút vừa qua). Mọi trang, hook và phím gõ trong terminal đều phải chờ chừng ấy.",
|
||||
"cpu": "Áp lực CPU (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ CPU rảnh — vài % trở lên là máy bắt đầu chậm.",
|
||||
"cpuLoad": "Tải mỗi lõi (1 phút) — máy này không có PSI nên ước lượng chờ CPU từ load.",
|
||||
"memory": "Áp lực bộ nhớ (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ bị treo để thu hồi hoặc swap bộ nhớ.",
|
||||
"io": "Áp lực I/O ổ đĩa (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ ổ đĩa — HDD chậm hoặc bận sẽ hiện ở đây.",
|
||||
"ram": "Bộ nhớ đang dùng (tổng trừ phần còn khả dụng).",
|
||||
"disk": "Mức đầy của ổ chứa cơ sở dữ liệu AgentHub ({{mount}}).",
|
||||
"heap": "V8 heap của AgentHub so với giới hạn.",
|
||||
"hooks": "Số phút từ hook gần nhất, chỉ tính khi có agent đang chạy: im lặng lúc đó nghĩa là dashboard không còn thấy agent.",
|
||||
"agents": "Agent kết thúc với lỗi trong 24 giờ qua (lỗi / đã kết thúc; cần ít nhất 5)."
|
||||
},
|
||||
"checkNa": "Không đo được trên máy này — không trừ điểm.",
|
||||
"checkThresholds": "Vàng từ {{warn}}{{unit}}, đỏ từ {{bad}}{{unit}}",
|
||||
"checkPenalty": "Trừ {{n}} / tối đa {{max}} điểm",
|
||||
"hooksMissing": "chưa cài",
|
||||
"hooksIdle": "nghỉ",
|
||||
"minutesAgo": "{{n}} phút trước",
|
||||
"verdict": {
|
||||
"ok": "Khỏe",
|
||||
"warn": "Suy giảm",
|
||||
"bad": "Có vấn đề"
|
||||
},
|
||||
"allOk": "Mọi mục đều ổn",
|
||||
"badCount_one": "{{count}} đỏ",
|
||||
"badCount_other": "{{count}} đỏ",
|
||||
"warnCount_one": "{{count}} vàng",
|
||||
"warnCount_other": "{{count}} vàng",
|
||||
"lagGroup": "Độ trễ",
|
||||
"systemGroup": "Tài nguyên & tín hiệu"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Đã xảy ra lỗi",
|
||||
"body": "Không thể tải trang này. Tải lại thường sẽ khắc phục — ví dụ sau khi dashboard vừa được cập nhật.",
|
||||
"reload": "Tải lại"
|
||||
},
|
||||
"host": {
|
||||
"uptimeDays": "{{d}} ngày {{h}} giờ",
|
||||
"uptimeHours": "{{h}} giờ {{m}} phút",
|
||||
"cpu": "CPU",
|
||||
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
|
||||
"memory": "Bộ nhớ",
|
||||
"swap": "Swap",
|
||||
"free": "trống",
|
||||
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
|
||||
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
|
||||
"systemGroup": "Hệ thống (root và tài khoản khác)",
|
||||
"homesBadge": "home",
|
||||
"historyTitle": "Lịch sử",
|
||||
"historyHelp": "Lưu trong bộ nhớ, mỗi {{s}} giây một điểm, từ khi máy chủ khởi động lúc {{at}}.",
|
||||
"historyCpu": "CPU (% toàn máy)",
|
||||
"historyMemory": "Bộ nhớ đã dùng (%)",
|
||||
"range": "Khoảng thời gian",
|
||||
"range1h": "1 giờ",
|
||||
"range6h": "6 giờ",
|
||||
"range24h": "24 giờ",
|
||||
"coreN": "CPU {{n}}",
|
||||
"threads": "{{n}} luồng",
|
||||
"physical": "{{n}} lõi",
|
||||
"perSecond": "{{v}}/giây",
|
||||
"temperatures": "Nhiệt độ",
|
||||
"topProcesses": "Tiến trình nặng nhất",
|
||||
"cpuModel": "CPU",
|
||||
"storageNetwork": "Ổ đĩa & mạng",
|
||||
"processCount": "{{n}} tiến trình",
|
||||
"title": "Máy chủ",
|
||||
"os": "Hệ điều hành",
|
||||
"uptime": "Thời gian chạy",
|
||||
"load": "Tải (1/5/15 phút)",
|
||||
"agenthubTip": "pid {{pid}} · Node {{node}}",
|
||||
"sampling": "Đang lấy mẫu…",
|
||||
"sampled": "mẫu {{s}} giây",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
"gitBar.switch": "Nhánh",
|
||||
"gitBar.switchApply": "Chuyển nhánh",
|
||||
"gitBar.switchCurrent": "hiện tại",
|
||||
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
|
||||
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
|
||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||
"gitBar.switchLabel": "Đổi nhánh…",
|
||||
|
||||
@@ -78,6 +78,13 @@
|
||||
"quotaWindow": {
|
||||
"wk": "Tuần",
|
||||
"mo": "Tháng"
|
||||
},
|
||||
"userQuota": "Quota {{pct}}%",
|
||||
"userQuotaTitle": "Quota chi phí của bạn: {{spend}} trên {{limit}} {{period}}",
|
||||
"userQuotaBlocked": "Bạn đã dùng hết quota chi phí ({{spend}} trên {{limit}} {{period}}): không mở phiên mới, không gửi prompt đến {{at}}",
|
||||
"userQuotaPeriod": {
|
||||
"day": "hôm nay",
|
||||
"5h": "trong 5 giờ qua"
|
||||
}
|
||||
},
|
||||
"version": "Phiên bản",
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
"tableAgents": "Agent",
|
||||
"tableCost": "Chi phí",
|
||||
"tableDirectory": "Thư mục",
|
||||
"tableUser": "Người dùng",
|
||||
"tableTool": "AI Tool",
|
||||
"tableActions": "Thao tác",
|
||||
"noOwner": "Không có chủ",
|
||||
"moreActions": "Thao tác khác",
|
||||
"viewDetails": "Xem chi tiết",
|
||||
"defaultName": "Phiên ",
|
||||
"detail": {
|
||||
"waitingBanner": {
|
||||
|
||||
@@ -1398,5 +1398,19 @@
|
||||
"apikey": "API key",
|
||||
"credentials": "Đăng nhập"
|
||||
}
|
||||
},
|
||||
"poolAccount": {
|
||||
"title": "Tài khoản pool",
|
||||
"description": "Tài khoản nào trong pool tài khoản dùng chung chạy các phiên mới của bạn. Tự động để AgentHub chọn tài khoản ít dùng nhất.",
|
||||
"automatic": "Tự động",
|
||||
"select": "Tài khoản pool của {{tool}}",
|
||||
"count_one": "{{count}} tài khoản bạn được dùng",
|
||||
"count_other": "{{count}} tài khoản bạn được dùng",
|
||||
"pinned": "Admin đã ghim bạn vào {{account}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "hết quota",
|
||||
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
|
||||
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
|
||||
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
|
||||
"ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.",
|
||||
"EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).",
|
||||
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước."
|
||||
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước.",
|
||||
"EBADQUOTA": "Nhập hạn mức USD lớn hơn 0 và chu kỳ ngày hoặc 5 giờ."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
@@ -133,7 +134,7 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
"description": "Các tài khoản mà phiên mới của từng tool chạy trên đó, mỗi AI tool một tab. Tự chọn tài khoản ít tải nhất (hoặc tài khoản user được gán), không ai phải đăng nhập riêng.",
|
||||
"description": "Mỗi pool chứa tài khoản của mọi AI tool (Claude Code, Codex, Cursor) và các member dùng chúng. Session mới của member chạy trên tài khoản dùng ít nhất trong pool của họ, không ai phải tự đăng nhập.",
|
||||
"refresh": "Đọc lại quota",
|
||||
"add": "Thêm tài khoản",
|
||||
"account": "Tài khoản",
|
||||
@@ -254,20 +255,20 @@
|
||||
"shareNamed": "Chia sẻ {{name}} vào pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Nhóm",
|
||||
"description": "Mỗi nhóm có tài khoản riêng: thành viên của nhóm chỉ chạy trên tài khoản của nhóm. User không thuộc nhóm nào dùng chung các tài khoản không nằm trong nhóm nào.",
|
||||
"new": "Tạo nhóm",
|
||||
"title": "Pool",
|
||||
"description": "Member và tài khoản chưa thuộc pool nào nằm trong pool chung.",
|
||||
"new": "Tạo pool",
|
||||
"empty": "Chưa có nhóm — mọi user dùng chung mọi tài khoản.",
|
||||
"members_one": "{{count}} thành viên",
|
||||
"members_other": "{{count}} thành viên",
|
||||
"edit": "Sửa nhóm {{name}}",
|
||||
"delete": "Xoá nhóm {{name}}",
|
||||
"deleteHint": "Các thành viên quay về pool chung. Không tài khoản nào bị xoá.",
|
||||
"delete": "Xoá pool {{name}}",
|
||||
"deleteHint": "Member và tài khoản của pool quay về pool chung. Không tài khoản nào bị xoá.",
|
||||
"noAccounts": "chưa có — thành viên tự đăng nhập",
|
||||
"saved": "Đã lưu nhóm {{name}}",
|
||||
"deleted": "Đã xoá nhóm {{name}}",
|
||||
"deleted": "Đã xoá pool {{name}}",
|
||||
"hint": "Tick các thành viên và tài khoản mà họ dùng. Mỗi user thuộc một nhóm, tick một user sẽ chuyển họ sang đây. Một tài khoản có thể dùng cho nhiều nhóm.",
|
||||
"name": "Tên nhóm",
|
||||
"name": "Tên pool",
|
||||
"memberList": "Thành viên",
|
||||
"inGroup": "đang ở {{group}}",
|
||||
"accountList": "Tài khoản",
|
||||
@@ -278,7 +279,57 @@
|
||||
"5h": "5 giờ",
|
||||
"wk": "Tuần"
|
||||
},
|
||||
"resetsIn": "reset sau {{time}} · {{at}}"
|
||||
"resetsIn": "reset sau {{time}} · {{at}}",
|
||||
"members": {
|
||||
"title": "Thành viên",
|
||||
"filter": "Hiện thành viên",
|
||||
"all": "Tất cả",
|
||||
"noGroup": "Chưa có pool",
|
||||
"search": "Tìm thành viên",
|
||||
"tickShown": "Chọn cả {{count}}",
|
||||
"noneShown": "Không có thành viên nào khớp."
|
||||
},
|
||||
"board": {
|
||||
"howTo": "Kéo tài khoản hoặc member giữa các pool; thả vào ô Tạo pool để tách ra, hoặc kéo tài khoản duy nhất của một pool sang pool khác để gộp. Chỉ lưu khi bấm Lưu.",
|
||||
"sharedHint": "Member và tài khoản chưa thuộc pool nào",
|
||||
"toShared": "Đưa {{name}} về pool chung",
|
||||
"removeAccount": "Đưa {{name}} về pool chung",
|
||||
"moveHere_one": "Chuyển {{count}} vào đây",
|
||||
"moveHere_other": "Chuyển {{count}} vào đây",
|
||||
"noMembers": "Chưa có thành viên",
|
||||
"noSharedAccounts": "Không còn tài khoản nào cho pool chung.",
|
||||
"dropMembers": "Thả thành viên vào đây",
|
||||
"untitled": "Pool mới",
|
||||
"new": "chưa lưu",
|
||||
"pick": "Chọn thành viên cho {{name}}",
|
||||
"pickHint": "Lọc theo role hoặc \"Chưa có pool\", tick người thuộc pool này. Mỗi người chỉ ở một pool, tick sẽ chuyển họ vào đây. Áp dụng lên bảng — bấm Lưu mới gửi.",
|
||||
"apply": "Áp dụng",
|
||||
"addAccount": "Chuyển tài khoản vào {{name}}",
|
||||
"addAccountOption": "+ Chuyển tài khoản hoặc profile đã lưu vào đây…",
|
||||
"profilesOf": "Profile {{tool}} đã lưu — chuyển vào pool khi Lưu",
|
||||
"profilePending": "Profile đã lưu: sẽ được chuyển vào pool khi bạn lưu",
|
||||
"pending_one": "{{count}} thay đổi chưa lưu",
|
||||
"pending_other": "{{count}} thay đổi chưa lưu",
|
||||
"needsName": "Pool nào cũng cần có tên.",
|
||||
"discard": "Bỏ thay đổi",
|
||||
"saveTitle": "Lưu các pool",
|
||||
"saveHint": "Nhập mật khẩu của bạn một lần để áp dụng mọi thay đổi bên dưới.",
|
||||
"summaryProfile": "Chuyển profile {{name}} vào pool",
|
||||
"summaryNew": "Tạo {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
|
||||
"summaryGroup": "Cập nhật {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
|
||||
"saved": "Đã lưu các pool",
|
||||
"dropAccounts": "Kéo tài khoản vào đây",
|
||||
"noAccounts": "Chưa có tài khoản",
|
||||
"accountsCount_one": "{{count}} tài khoản",
|
||||
"accountsCount_other": "{{count}} tài khoản",
|
||||
"mergeHint": "Thả để gộp {{from}} vào {{into}}",
|
||||
"merged": "Đã gộp {{from}} vào {{into}} — bấm Lưu để áp dụng",
|
||||
"summaryMerge": "Gộp {{from}} vào {{into}}: xoá pool {{from}}",
|
||||
"newDrop": "Thả vào đây để tách thành pool riêng",
|
||||
"newHint": "Hoặc kéo tài khoản / member vào đây để tách ra"
|
||||
},
|
||||
"addAccount": "Thêm tài khoản",
|
||||
"otherTools": "AI tool khác"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
@@ -315,7 +366,9 @@
|
||||
"doneIdle": "Đã khởi động lại: {{names}}",
|
||||
"errors": {
|
||||
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
|
||||
}
|
||||
},
|
||||
"sessionsCount_one": "{{count}} session",
|
||||
"sessionsCount_other": "{{count}} session"
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Mở home trong Files",
|
||||
@@ -331,5 +384,66 @@
|
||||
"digit": "Có chữ số",
|
||||
"symbol": "Có ký tự đặc biệt (vd. ! - _ @)",
|
||||
"username": "Không chứa tên đăng nhập"
|
||||
},
|
||||
"detail": {
|
||||
"open": "Xem chi tiết {{name}}",
|
||||
"lastLogin": "Đăng nhập gần nhất",
|
||||
"lastSession": "Hoạt động gần nhất",
|
||||
"never": "chưa bao giờ",
|
||||
"spendDaily": "Chi phí · 30 ngày qua",
|
||||
"noUsage": "Không có chi phí trong 30 ngày qua.",
|
||||
"pool": "Account pool & quota",
|
||||
"poolPinned": "ghim vào {{account}}",
|
||||
"poolChosen": "tự chọn {{account}}",
|
||||
"poolGroup": "nhóm {{name}}",
|
||||
"poolShared": "pool dùng chung",
|
||||
"poolNone": "Pool của tool này chưa có tài khoản nào.",
|
||||
"poolNotAllowed": "không còn được dùng",
|
||||
"poolTotal7d": "Tổng tài khoản 7 ngày: {{cost}}",
|
||||
"noPanes": "Không có gì đang chạy.",
|
||||
"recentTitle": "Session gần đây",
|
||||
"failed": "Không tải được thông tin người dùng",
|
||||
"activeLabel": "Đang chạy",
|
||||
"activeValue": "{{sessions}} session · {{panes}} pane",
|
||||
"nowTitle": "Đang chạy",
|
||||
"poolTheirs7d": "của họ 7 ngày {{cost}}"
|
||||
},
|
||||
"activity": "Hoạt động",
|
||||
"spend": "Chi phí hôm nay",
|
||||
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
|
||||
"sessionsN": "{{n}} session",
|
||||
"workspacesN": "{{n}} workspace",
|
||||
"tmuxCol": "tmux",
|
||||
"quota": {
|
||||
"title": "Quota chi phí",
|
||||
"limit": "Hạn mức (USD)",
|
||||
"period": "Chu kỳ",
|
||||
"periods": {
|
||||
"day": "mỗi ngày (từ nửa đêm giờ địa phương)",
|
||||
"5h": "mỗi 5 giờ (trượt)"
|
||||
},
|
||||
"per": {
|
||||
"day": "{{limit}} / ngày",
|
||||
"5h": "{{limit}} / 5 giờ"
|
||||
},
|
||||
"used": "{{spend}} trên {{limit}}",
|
||||
"defaultTag": "(mặc định)",
|
||||
"none": "Không giới hạn",
|
||||
"blockedUntil": "Đã hết — không mở phiên mới, không gửi prompt đến {{at}}",
|
||||
"cardTitle": "Quota chi phí mặc định",
|
||||
"cardDescription": "Áp cho mọi user không có quota riêng. Khi dùng hết, user không mở được phiên mới và prompt bị từ chối đến khi quota mở lại; pane không hook nào chặn được (Antigravity, Cursor, Codex chưa tin hook, omp dùng login riêng) sẽ bị dừng.",
|
||||
"noDefault": "Chưa có mặc định — không giới hạn",
|
||||
"editDefault": "Sửa",
|
||||
"defaultDialog": "Quota chi phí mặc định",
|
||||
"defaultOn": "Giới hạn chi phí của mỗi user",
|
||||
"defaultSaved": "Đã lưu quota chi phí mặc định",
|
||||
"enforceHint": "Vượt quota: không mở phiên mới, prompt Claude Code và Codex bị từ chối, pane của các AI tool khác bị dừng — đến khi quota mở lại.",
|
||||
"modes": {
|
||||
"defaultOf": "Mặc định ({{quota}})",
|
||||
"defaultNone": "Mặc định (không giới hạn)",
|
||||
"unlimited": "Không giới hạn",
|
||||
"custom": "Hạn mức riêng"
|
||||
},
|
||||
"now": "Hiện tại: {{spend}} trên {{limit}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @file healthScore.test.ts
|
||||
* @description Tests for lib/healthScore.ts — the System Health score: a calm
|
||||
* machine scores 100, lag (event loop, CPU / memory / I/O pressure) pulls it
|
||||
* down hard, a check without data costs nothing, CPU falls back to load per
|
||||
* core without PSI, the data disk is the one holding the database, and hooks
|
||||
* count only while an agent works.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HostStats } from "../api";
|
||||
import { healthChecks, healthScore, scoreLevel, type HealthInput } from "../healthScore";
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const NOW = Date.parse("2026-10-27T10:00:00Z");
|
||||
|
||||
const host = (over: Partial<HostStats> = {}): HostStats => ({
|
||||
at: "2026-10-27T10:00:00Z",
|
||||
hostname: "box",
|
||||
os: { platform: "linux", release: "7", distro: null, arch: "x64" },
|
||||
uptime: 1000,
|
||||
cpu: { model: null, cores: 4, load: [0.5, 0.5, 0.5] },
|
||||
memory: { total: 16 * GB, available: 12 * GB, used: 4 * GB, swapTotal: 0, swapUsed: 0 },
|
||||
disks: [
|
||||
{ device: "/dev/a", mount: "/", type: "ext4", kind: "SSD", total: 100, used: 10, free: 90 },
|
||||
{ device: "/dev/b", mount: "/data", type: "ext4", kind: "HDD", total: 100, used: 99, free: 1 },
|
||||
],
|
||||
homesMount: null,
|
||||
gpus: [],
|
||||
usage: null,
|
||||
pressure: {
|
||||
cpu: { some10: 1, some60: 1, full10: 0 },
|
||||
memory: { some10: 0, some60: 0, full10: 0 },
|
||||
io: { some10: 0, some60: 0, full10: 0 },
|
||||
},
|
||||
...over,
|
||||
});
|
||||
|
||||
const calm = (over: Partial<HealthInput> = {}): HealthInput => ({
|
||||
eventLoop: { p50: 10, p99: 20, max: 40 },
|
||||
heap: { heapUsed: 100, heapTotal: 200, heapLimit: 1000 },
|
||||
hooksInstalled: true,
|
||||
activity: {
|
||||
last_event_at: "2026-10-27T09:59:00Z",
|
||||
working_agents: 1,
|
||||
agents_24h: { completed: 50, error: 0 },
|
||||
},
|
||||
dbPath: "/home/me/.claude/agenthub/dashboard.db",
|
||||
host: host(),
|
||||
now: NOW,
|
||||
...over,
|
||||
});
|
||||
|
||||
const byKey = (input: HealthInput) => new Map(healthChecks(input).map((c) => [c.key, c]));
|
||||
|
||||
describe("healthScore", () => {
|
||||
it("scores a calm machine 100 with every check ok", () => {
|
||||
const checks = healthChecks(calm());
|
||||
expect(healthScore(checks)).toBe(100);
|
||||
expect(checks.every((c) => c.level === "ok")).toBe(true);
|
||||
expect(scoreLevel(100)).toBe("ok");
|
||||
});
|
||||
|
||||
it("drops the score when the machine lags", () => {
|
||||
const lagging = calm({
|
||||
eventLoop: { p50: 50, p99: 1200, max: 2000 },
|
||||
host: host({
|
||||
pressure: {
|
||||
cpu: { some10: 70, some60: 50, full10: 0 },
|
||||
memory: { some10: 0, some60: 0, full10: 0 },
|
||||
io: { some10: 60, some60: 40, full10: 30 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const c = byKey(lagging);
|
||||
expect(c.get("loop")?.level).toBe("bad");
|
||||
expect(c.get("cpu")?.level).toBe("bad");
|
||||
expect(c.get("io")?.level).toBe("bad");
|
||||
const score = healthScore([...c.values()]);
|
||||
expect(score).toBe(100 - 25 - 20 - 20);
|
||||
expect(scoreLevel(score)).toBe("bad");
|
||||
});
|
||||
|
||||
it("charges part of the points between amber and red", () => {
|
||||
const c = byKey(calm({ eventLoop: { p50: 10, p99: 550, max: 600 } })).get("loop");
|
||||
expect(c?.level).toBe("warn");
|
||||
expect(c?.penalty).toBe(12.5);
|
||||
});
|
||||
|
||||
it("costs nothing for what was not measured, and estimates CPU from load without PSI", () => {
|
||||
const c = byKey(
|
||||
calm({
|
||||
eventLoop: undefined,
|
||||
host: host({ pressure: null, cpu: { model: null, cores: 2, load: [5, 1, 1] } }),
|
||||
})
|
||||
);
|
||||
expect(c.get("loop")?.level).toBe("na");
|
||||
expect(c.get("memory")?.level).toBe("na");
|
||||
expect(c.get("cpu")?.detail?.source).toBe("load");
|
||||
expect(c.get("cpu")?.value).toBe(250);
|
||||
expect(c.get("cpu")?.level).toBe("bad");
|
||||
});
|
||||
|
||||
it("reads the disk that holds the database", () => {
|
||||
const c = byKey(calm({ dbPath: "/data/agenthub/dashboard.db" })).get("disk");
|
||||
expect(c?.detail?.mount).toBe("/data");
|
||||
expect(c?.level).toBe("bad");
|
||||
expect(byKey(calm({ dbPath: null })).get("disk")?.detail?.mount).toBe("/");
|
||||
});
|
||||
|
||||
it("counts silent hooks only while an agent works; missing hooks are red", () => {
|
||||
const silent = calm({
|
||||
activity: {
|
||||
last_event_at: "2026-10-27T09:20:00Z",
|
||||
working_agents: 2,
|
||||
agents_24h: { completed: 0, error: 0 },
|
||||
},
|
||||
});
|
||||
expect(byKey(silent).get("hooks")?.level).toBe("bad");
|
||||
const idle = calm({
|
||||
activity: {
|
||||
last_event_at: "2026-10-27T05:00:00Z",
|
||||
working_agents: 0,
|
||||
agents_24h: { completed: 0, error: 0 },
|
||||
},
|
||||
});
|
||||
expect(byKey(idle).get("hooks")).toMatchObject({ level: "ok", penalty: 0 });
|
||||
expect(byKey(calm({ hooksInstalled: false })).get("hooks")).toMatchObject({
|
||||
level: "bad",
|
||||
penalty: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("rates agent errors only with enough finished agents", () => {
|
||||
const few = calm({
|
||||
activity: {
|
||||
last_event_at: null,
|
||||
working_agents: 0,
|
||||
agents_24h: { completed: 1, error: 2 },
|
||||
},
|
||||
});
|
||||
expect(byKey(few).get("agents")?.level).toBe("na");
|
||||
const many = calm({
|
||||
activity: {
|
||||
last_event_at: null,
|
||||
working_agents: 0,
|
||||
agents_24h: { completed: 6, error: 4 },
|
||||
},
|
||||
});
|
||||
expect(byKey(many).get("agents")).toMatchObject({ level: "bad", value: 40 });
|
||||
});
|
||||
});
|
||||
+232
-9
@@ -545,6 +545,110 @@ export type ServerUserUsage = AuthUser & {
|
||||
/** Active login sessions (cookies not expired). */
|
||||
logins?: number;
|
||||
lastLoginAt?: string | null;
|
||||
/** This user's own spend quota setting (absent from an older server). */
|
||||
quotaSetting?: UserQuotaSetting;
|
||||
/** The quota that binds them now with its spend; null = no limit. */
|
||||
quota?: UserQuotaStatus | null;
|
||||
};
|
||||
/** A spend quota: `limit_usd` per day (from local midnight of `tz_offset`) or per sliding 5 h. */
|
||||
export type UserQuota = {
|
||||
limit_usd: number;
|
||||
period: "day" | "5h";
|
||||
/** getTimezoneOffset() minutes of the zone a `day` starts in. */
|
||||
tz_offset?: number;
|
||||
/** Where it comes from: the default, or the user's own. */
|
||||
source?: "default" | "user";
|
||||
};
|
||||
/** A user's own setting: the default applies, no limit, or a limit of their own. */
|
||||
export type UserQuotaSetting =
|
||||
{ mode: "default" } | { mode: "unlimited" } | ({ mode: "custom" } & UserQuota);
|
||||
/** A quota now (`GET /api/users`, `GET /api/me/quota`). */
|
||||
export type UserQuotaStatus = UserQuota & {
|
||||
spend: number;
|
||||
pct: number;
|
||||
/** Used up: no new session starts and prompts are refused until `resetsAt`. */
|
||||
exceeded: boolean;
|
||||
resetsAt: string | null;
|
||||
since: string;
|
||||
};
|
||||
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
|
||||
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
|
||||
/** One pool account as one user's detail sees it: the account's totals and their share. */
|
||||
export type UserPoolAccount = {
|
||||
name: string;
|
||||
kind: string;
|
||||
email: string | null;
|
||||
expired: boolean;
|
||||
/** Their group/pin lets them run on it (false = they ran on it before). */
|
||||
allowed: boolean;
|
||||
pinned: boolean;
|
||||
quota: PoolAccount["quota"] | CodexPoolAccount["quota"];
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
userSpend5h: number;
|
||||
userSpend7d: number;
|
||||
userTokens5h: number;
|
||||
userTokens7d: number;
|
||||
userRuns7d: number;
|
||||
userLivePanes: number;
|
||||
userLastAt: string | null;
|
||||
};
|
||||
/** `GET /api/users/:id/details` — the Administration page's user detail. */
|
||||
export type UserDetails = {
|
||||
user: AuthUser & { disabled: boolean; logins: number; lastLoginAt: string | null };
|
||||
sessions: { total: number; active: number; firstAt: string | null; lastAt: string | null };
|
||||
usage: {
|
||||
today: { cost: number; tokens: number };
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
/** 30 local days, the oldest first. */
|
||||
daily: { date: string; cost: number; tokens: number }[];
|
||||
byTool: UsageSlice[];
|
||||
byModel: UsageSlice[];
|
||||
};
|
||||
workspaces: {
|
||||
id: number;
|
||||
title: string;
|
||||
cwd: string;
|
||||
tool: string;
|
||||
kind: string;
|
||||
branch: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
sessions: number;
|
||||
lastActiveAt: string | null;
|
||||
livePanes: number;
|
||||
}[];
|
||||
panes: {
|
||||
id: string;
|
||||
laneId: number;
|
||||
laneTitle: string;
|
||||
laneCwd: string;
|
||||
tool: string;
|
||||
model: string | null;
|
||||
account: string | null;
|
||||
proxy: string | null;
|
||||
sessionId: string | null;
|
||||
startedAt: string;
|
||||
status: string;
|
||||
}[];
|
||||
recentSessions: (Session & { tokens?: number })[];
|
||||
pool: Partial<
|
||||
Record<
|
||||
PoolTool,
|
||||
{
|
||||
pin: string | null;
|
||||
/** The user's own choice (below a pin); absent from an older server. */
|
||||
choice?: string | null;
|
||||
group: { id: number; name: string } | null;
|
||||
accounts: UserPoolAccount[];
|
||||
}
|
||||
>
|
||||
>;
|
||||
audit: AuditEntry[];
|
||||
};
|
||||
/** One member's own tmux server (`GET /api/users/tmux`). */
|
||||
export interface MemberTmuxRow {
|
||||
@@ -578,6 +682,8 @@ export interface HostUsageGroup {
|
||||
kind: "user" | "host" | "system";
|
||||
userId?: number;
|
||||
username?: string;
|
||||
/** The user's display name, null when none is set (absent from an older server). */
|
||||
displayName?: string | null;
|
||||
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
||||
cpu: number;
|
||||
/** Resident memory, bytes (shared pages count in every process). */
|
||||
@@ -587,13 +693,31 @@ export interface HostUsageGroup {
|
||||
/** Busiest processes, by the kernel's short name only. */
|
||||
top: { pid: number; name: string; cpu: number; memory: number }[];
|
||||
}
|
||||
/** One `/proc/pressure/<resource>` reading, in % of the window. */
|
||||
export interface HostPressure {
|
||||
some10: number;
|
||||
some60: number;
|
||||
full10: number;
|
||||
}
|
||||
|
||||
/** `GET /api/host` — hardware, load, disks and per-user usage. */
|
||||
export interface HostStats {
|
||||
at: string;
|
||||
hostname: string;
|
||||
os: { platform: string; release: string; distro: string | null; arch: string };
|
||||
uptime: number;
|
||||
cpu: { model: string | null; cores: number; load: number[] };
|
||||
/** When the machine booted (ISO); absent from an older server. */
|
||||
bootTime?: string;
|
||||
cpu: {
|
||||
model: string | null;
|
||||
/** Logical CPUs (threads). */
|
||||
cores: number;
|
||||
load: number[];
|
||||
/** Physical cores from /proc/cpuinfo; null when it cannot tell. */
|
||||
physicalCores?: number | null;
|
||||
/** Busy % of each logical CPU over the sample; [] off Linux. */
|
||||
perCore?: number[];
|
||||
};
|
||||
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
|
||||
disks: {
|
||||
device: string;
|
||||
@@ -609,6 +733,46 @@ export interface HostStats {
|
||||
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
|
||||
/** null off Linux. */
|
||||
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
|
||||
/** Bytes/s per network interface over the sample (`lo` left out). */
|
||||
network?: { name: string; rx: number; tx: number }[];
|
||||
/** Bytes/s per whole disk over the sample. */
|
||||
diskIo?: { name: string; read: number; write: number }[];
|
||||
/** `/sys/class/hwmon` sensors; [] when none. */
|
||||
temperatures?: { chip: string; label: string; celsius: number }[];
|
||||
/** Linux PSI (`/proc/pressure/*`): % of the last 10 / 60 s tasks stalled on
|
||||
* each resource; null without PSI (or off Linux). */
|
||||
pressure?: {
|
||||
cpu: HostPressure | null;
|
||||
memory: HostPressure | null;
|
||||
io: HostPressure | null;
|
||||
} | null;
|
||||
/** The ten busiest processes machine-wide; `group` is a `HostUsageGroup` key. */
|
||||
topProcesses?: { pid: number; name: string; cpu: number; memory: number; group: string }[];
|
||||
/** The dashboard's own process. */
|
||||
process?: { pid: number; node: string; uptime: number; rss: number; heapUsed: number };
|
||||
}
|
||||
/** One point of `GET /api/host/history`. */
|
||||
export interface HostHistoryPoint {
|
||||
at: string;
|
||||
/** Machine CPU %, null when it could not be sampled. */
|
||||
cpu: number | null;
|
||||
/** Memory used, % of total. */
|
||||
memory: number | null;
|
||||
memoryUsed: number;
|
||||
swapUsed: number;
|
||||
load1: number | null;
|
||||
/** Per user id: CPU % and resident bytes (users with any use only). */
|
||||
users: Record<string, { cpu: number; memory: number }>;
|
||||
}
|
||||
export type HostHistoryRange = "1h" | "6h" | "24h";
|
||||
/** `GET /api/host/history` — kept in memory since the server started. */
|
||||
export interface HostHistory {
|
||||
range: HostHistoryRange;
|
||||
intervalMs: number;
|
||||
/** False when this server keeps no history (local mode, not Linux). */
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
points: HostHistoryPoint[];
|
||||
}
|
||||
|
||||
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
|
||||
@@ -634,6 +798,8 @@ export type UserPatch = {
|
||||
addWorkspaces?: number[];
|
||||
removeWorkspaces?: number[];
|
||||
signOut?: true;
|
||||
/** Their own spend quota (`default` = the default applies). */
|
||||
quota?: UserQuotaSetting;
|
||||
};
|
||||
/** GET /api/me/account — the signed-in account's own information. */
|
||||
export type MyAccount =
|
||||
@@ -671,6 +837,8 @@ export type PoolAccount = {
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
/** Readable plan of a login ("Max 5x", "Team Premium"); null for a setup-token. */
|
||||
plan?: string | null;
|
||||
};
|
||||
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */
|
||||
export type CodexPoolAccount = {
|
||||
@@ -689,6 +857,8 @@ export type CodexPoolAccount = {
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
/** A ChatGPT login's plan ("Plus", "Pro", "Team"); null for an API key. */
|
||||
plan?: string | null;
|
||||
};
|
||||
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
|
||||
export type AccountPin = {
|
||||
@@ -728,6 +898,22 @@ export type PoolGroupPatch = {
|
||||
accounts?: Partial<Record<PoolTool, string[]>>;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex" | "cursor";
|
||||
/** One tool of `GET /api/me/pool-accounts`: the accounts this user may use — never a token. */
|
||||
export type MyPoolTool = {
|
||||
tool: PoolTool;
|
||||
/** An admin's pin: while set, the user cannot choose. */
|
||||
pin: string | null;
|
||||
/** The user's own choice; null = the balancer picks. */
|
||||
choice: string | null;
|
||||
accounts: {
|
||||
name: string;
|
||||
kind: string;
|
||||
email: string | null;
|
||||
expired: boolean;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
}[];
|
||||
};
|
||||
/** A saved switch-user profile that can be moved into the pool (no token). */
|
||||
export type PoolProfile = {
|
||||
name: string;
|
||||
@@ -1051,6 +1237,8 @@ export const api = {
|
||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
/** GET /api/me/quota — this account's own spend quota now (null: no limit, or local mode). */
|
||||
quota: () => request<{ quota: UserQuotaStatus | null }>("/me/quota"),
|
||||
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
|
||||
instructions: () => request<AgentInstructionsView>("/me/instructions"),
|
||||
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
|
||||
@@ -1089,10 +1277,21 @@ export const api = {
|
||||
request<MyAccount>("/me/avatar/url", { method: "POST", body: JSON.stringify({ url }) }),
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
/** GET /api/me/pool-accounts — per tool, the pool accounts this account may use (server mode). */
|
||||
poolAccounts: () => request<{ tools: MyPoolTool[] }>("/me/pool-accounts"),
|
||||
/** PUT /api/me/pool-accounts — run new sessions of `tool` on `account` (`null` = automatic). */
|
||||
choosePoolAccount: (tool: PoolTool, account: string | null) =>
|
||||
request<{
|
||||
tool: PoolTool;
|
||||
choice: string | null;
|
||||
switched: "switched" | "kept" | "own-login" | "expired" | "next-session" | null;
|
||||
}>("/me/pool-accounts", { method: "PUT", body: JSON.stringify({ tool, account }) }),
|
||||
},
|
||||
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
|
||||
/** GET /api/host — System Health's machine cards and the Administration users table's CPU/RAM (local mode, or staff). */
|
||||
host: {
|
||||
stats: () => request<HostStats>("/host"),
|
||||
history: (range: HostHistoryRange = "1h") =>
|
||||
request<HostHistory>(`/host/history?range=${range}`),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
@@ -1101,7 +1300,18 @@ export const api = {
|
||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () =>
|
||||
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>(
|
||||
`/users?tz_offset=${new Date().getTimezoneOffset()}`
|
||||
),
|
||||
/** PUT /api/users/quota-default — the default spend quota (null = none). */
|
||||
setQuotaDefault: (quota: UserQuota | null, currentPassword: string) =>
|
||||
request<{ user: AuthUser; quotaDefault: UserQuota | null }>("/users/quota-default", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ quota, currentPassword }),
|
||||
}),
|
||||
/** GET /api/users/:id/details — one account in full (admin/analyst). */
|
||||
details: (id: number) =>
|
||||
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||
* account changes, newest first. */
|
||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||
@@ -2010,10 +2220,13 @@ export const api = {
|
||||
purged_agents: number;
|
||||
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
|
||||
gc: (body: { dryRun?: boolean } = {}) =>
|
||||
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/workspaces/gc", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>(
|
||||
"/workspaces/gc",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
},
|
||||
|
||||
// ─────────────────────────────── Workflows API ──────────────────────────────
|
||||
@@ -4107,6 +4320,8 @@ export const api = {
|
||||
request<{
|
||||
available: boolean;
|
||||
branches: string[];
|
||||
/** Fetched remote branches with no local one yet (`origin/feature`); absent from an older server. */
|
||||
remotes?: string[];
|
||||
current: string | null;
|
||||
worktrees: LaneWorktree[];
|
||||
}>(gitUrl(id, "/branches")),
|
||||
@@ -4288,7 +4503,10 @@ export const api = {
|
||||
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
|
||||
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/workspaces/${id}/memory`),
|
||||
update: (id: number, patch: Partial<Lane>) =>
|
||||
request<{ lane: Lane }>(`/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
request<{ lane: Lane }>(`/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
/**
|
||||
* POST /api/lanes/:id/stage — advance the pipeline stage.
|
||||
* @param id The lane id.
|
||||
@@ -4299,7 +4517,10 @@ export const api = {
|
||||
id: number,
|
||||
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
|
||||
) =>
|
||||
request<{ lane: Lane }>(`/workspaces/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
|
||||
request<{ lane: Lane }>(`/workspaces/${id}/stage`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
|
||||
* action-specific facts that a destructive request must echo as `expect`.
|
||||
@@ -4600,6 +4821,8 @@ export interface CcAccountIdentity {
|
||||
organization: string | null;
|
||||
seatTier: string | null;
|
||||
subscriptionType: string | null;
|
||||
/** Readable plan from tier + seat ("Max 5x", "Team Premium"); prefer it. */
|
||||
plan?: string | null;
|
||||
/** Access-token expiry, epoch ms; a switch still works past it (the refresh
|
||||
* token is stored too). */
|
||||
expiresAt: number | null;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file healthScore.ts
|
||||
* @description The Dashboard System Health score: a list of checks, each read
|
||||
* against two thresholds (`warnAt` → amber, `badAt` → red) and costing up to
|
||||
* `max` points as it climbs between them; the score is 100 minus every
|
||||
* deduction (never below 0). Most of the weight is on what makes the machine
|
||||
* or the dashboard feel slow — the event loop's p99 delay
|
||||
* (`/api/settings/info` `server.event_loop`) and Linux pressure stall
|
||||
* information for CPU, memory and disk I/O (`/api/host` `pressure`; the CPU
|
||||
* falls back to load per core without PSI) — then RAM, the data disk, the V8
|
||||
* heap, hooks going silent while agents work, and agent errors in the last
|
||||
* 24 h. A check whose input is missing is `na` and costs nothing. Pure — the
|
||||
* card in `Dashboard.tsx` only renders the result.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { HostStats } from "./api";
|
||||
|
||||
export type CheckLevel = "ok" | "warn" | "bad" | "na";
|
||||
export type CheckKey =
|
||||
"loop" | "cpu" | "memory" | "io" | "ram" | "disk" | "heap" | "hooks" | "agents";
|
||||
|
||||
export interface HealthCheck {
|
||||
key: CheckKey;
|
||||
/** "lag" checks are what a user feels as a slow machine or dashboard. */
|
||||
group: "lag" | "system";
|
||||
/** The measured value in `unit`; null when not measured. */
|
||||
value: number | null;
|
||||
unit: "ms" | "%" | "min";
|
||||
level: CheckLevel;
|
||||
warnAt: number;
|
||||
badAt: number;
|
||||
/** Points this check took off the score. */
|
||||
penalty: number;
|
||||
/** The most it can take. */
|
||||
max: number;
|
||||
/** Extra facts for the row (e.g. which mount, how many agents). */
|
||||
detail?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface HealthInput {
|
||||
/** `server.event_loop` (ms); absent on an older server. */
|
||||
eventLoop?: { p50: number; p99: number; max: number };
|
||||
heap: { heapUsed: number; heapTotal: number; heapLimit?: number };
|
||||
hooksInstalled: boolean;
|
||||
activity?: {
|
||||
last_event_at: string | null;
|
||||
working_agents: number;
|
||||
agents_24h: { completed: number; error: number };
|
||||
};
|
||||
/** The database file, to find the disk it lives on; null for a developer. */
|
||||
dbPath: string | null;
|
||||
host: HostStats | null;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/** Fewest finished agents before an error rate means anything. */
|
||||
const MIN_AGENTS = 5;
|
||||
|
||||
function check(
|
||||
key: CheckKey,
|
||||
group: HealthCheck["group"],
|
||||
value: number | null,
|
||||
unit: HealthCheck["unit"],
|
||||
warnAt: number,
|
||||
badAt: number,
|
||||
max: number,
|
||||
detail?: HealthCheck["detail"]
|
||||
): HealthCheck {
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
return { key, group, value: null, unit, level: "na", warnAt, badAt, penalty: 0, max, detail };
|
||||
}
|
||||
const level: CheckLevel = value >= badAt ? "bad" : value >= warnAt ? "warn" : "ok";
|
||||
const t = Math.max(0, Math.min(1, (value - warnAt) / (badAt - warnAt)));
|
||||
return {
|
||||
key,
|
||||
group,
|
||||
value,
|
||||
unit,
|
||||
level,
|
||||
warnAt,
|
||||
badAt,
|
||||
penalty: Math.round(max * t * 10) / 10,
|
||||
max,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
/** The mount holding `path`: the longest mount point that prefixes it, else `/`. */
|
||||
function diskOf(host: HostStats, path: string | null) {
|
||||
const disks = host.disks ?? [];
|
||||
if (path) {
|
||||
const hit = disks
|
||||
.filter((d) => path === d.mount || path.startsWith(d.mount === "/" ? "/" : `${d.mount}/`))
|
||||
.sort((a, b) => b.mount.length - a.mount.length)[0];
|
||||
if (hit) return hit;
|
||||
}
|
||||
return disks.find((d) => d.mount === "/") ?? disks[0] ?? null;
|
||||
}
|
||||
|
||||
export function healthChecks(input: HealthInput): HealthCheck[] {
|
||||
const { host } = input;
|
||||
const now = input.now ?? Date.now();
|
||||
const psi = host?.pressure ?? null;
|
||||
const out: HealthCheck[] = [];
|
||||
|
||||
// ── What lag is made of ──
|
||||
out.push(check("loop", "lag", input.eventLoop?.p99 ?? null, "ms", 100, 1000, 25));
|
||||
if (psi?.cpu) {
|
||||
out.push(check("cpu", "lag", psi.cpu.some10, "%", 20, 60, 20, { source: "psi" }));
|
||||
} else {
|
||||
const load =
|
||||
host && host.cpu.cores > 0 ? ((host.cpu.load[0] ?? 0) / host.cpu.cores) * 100 : null;
|
||||
out.push(check("cpu", "lag", load, "%", 100, 200, 20, { source: "load" }));
|
||||
}
|
||||
out.push(check("memory", "lag", psi?.memory?.some10 ?? null, "%", 5, 25, 25));
|
||||
out.push(check("io", "lag", psi?.io?.some10 ?? null, "%", 15, 50, 20));
|
||||
|
||||
// ── Capacity and the dashboard's own signals ──
|
||||
const mem = host?.memory;
|
||||
out.push(
|
||||
check(
|
||||
"ram",
|
||||
"system",
|
||||
mem && mem.total > 0 ? (mem.used / mem.total) * 100 : null,
|
||||
"%",
|
||||
90,
|
||||
98,
|
||||
10
|
||||
)
|
||||
);
|
||||
const disk = host ? diskOf(host, input.dbPath) : null;
|
||||
out.push(
|
||||
check(
|
||||
"disk",
|
||||
"system",
|
||||
disk && disk.total > 0 ? (disk.used / disk.total) * 100 : null,
|
||||
"%",
|
||||
90,
|
||||
98,
|
||||
15,
|
||||
disk ? { mount: disk.mount } : undefined
|
||||
)
|
||||
);
|
||||
const heapMax = input.heap.heapLimit || input.heap.heapTotal;
|
||||
out.push(
|
||||
check(
|
||||
"heap",
|
||||
"system",
|
||||
heapMax > 0 ? (input.heap.heapUsed / heapMax) * 100 : null,
|
||||
"%",
|
||||
70,
|
||||
90,
|
||||
15
|
||||
)
|
||||
);
|
||||
|
||||
// Hooks: not installed is always red; installed, they matter only while an
|
||||
// agent works — then minutes of silence mean the dashboard went blind.
|
||||
const a = input.activity;
|
||||
if (!input.hooksInstalled) {
|
||||
out.push({
|
||||
key: "hooks",
|
||||
group: "system",
|
||||
value: null,
|
||||
unit: "min",
|
||||
level: "bad",
|
||||
warnAt: 10,
|
||||
badAt: 30,
|
||||
penalty: 30,
|
||||
max: 30,
|
||||
detail: { state: "missing" },
|
||||
});
|
||||
} else if (!a || a.working_agents === 0 || !a.last_event_at) {
|
||||
out.push({
|
||||
...check("hooks", "system", null, "min", 10, 30, 30, { state: "idle" }),
|
||||
level: "ok",
|
||||
});
|
||||
} else {
|
||||
const age = Math.max(0, (now - Date.parse(a.last_event_at)) / 60000);
|
||||
out.push(
|
||||
check("hooks", "system", age, "min", 10, 30, 30, {
|
||||
state: "working",
|
||||
working: a.working_agents,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const done = a ? a.agents_24h.completed + a.agents_24h.error : 0;
|
||||
out.push(
|
||||
check(
|
||||
"agents",
|
||||
"system",
|
||||
a && done >= MIN_AGENTS ? (a.agents_24h.error / done) * 100 : null,
|
||||
"%",
|
||||
10,
|
||||
30,
|
||||
10,
|
||||
a ? { error: a.agents_24h.error, total: done } : undefined
|
||||
)
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function healthScore(checks: HealthCheck[]): number {
|
||||
const lost = checks.reduce((s, c) => s + c.penalty, 0);
|
||||
return Math.max(0, Math.min(100, Math.round((100 - lost) * 10) / 10));
|
||||
}
|
||||
|
||||
/** Green from 90, amber from 70, red below. */
|
||||
export function scoreLevel(score: number): Exclude<CheckLevel, "na"> {
|
||||
return score >= 90 ? "ok" : score >= 70 ? "warn" : "bad";
|
||||
}
|
||||
@@ -329,6 +329,12 @@ export const SETTINGS_INDEX: {
|
||||
],
|
||||
to: "#tool-profiles",
|
||||
},
|
||||
{
|
||||
section: "agents",
|
||||
titleKey: "poolAccount.title",
|
||||
keywords: ["pool", "account", "switch", "quota", "tài khoản", "pool account"],
|
||||
to: "#pool-account",
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||
{ section: "agents/codex", titleKey: "codexHome.title" },
|
||||
|
||||
@@ -746,6 +746,15 @@ export interface Session {
|
||||
/** ISO time the user pinned the session to the top of its workspace's
|
||||
* sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */
|
||||
pinned_at?: string | null;
|
||||
/** Server mode, `GET /api/sessions` only: the account owning the session's
|
||||
* workspace (null when no workspace owns it). Absent in local mode. */
|
||||
owner_id?: number | null;
|
||||
/** That owner's username (`users.username`). */
|
||||
owner_name?: string | null;
|
||||
/** That owner's display name (`users.display_name`); null when none is set. */
|
||||
owner_display_name?: string | null;
|
||||
/** When that owner's avatar was last set (`users.avatar_at`); null = none. */
|
||||
owner_avatar_at?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
||||
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
||||
* It is shown only when the server finds a `9router` binary installed.
|
||||
* Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it
|
||||
* Beside it every user gets **Update 9router** (`NineRouterUpdate`): shut it
|
||||
* down, `npm install -g 9router@latest`, start it again hidden.
|
||||
*
|
||||
* **Test** asks the endpoint itself, once as Claude Code would
|
||||
@@ -274,7 +274,7 @@ export function AiProxyConfig() {
|
||||
{t("aiProxy.config9router")}
|
||||
</a>
|
||||
)}
|
||||
{nineRouter && isAdmin && <NineRouterUpdate />}
|
||||
{nineRouter && <NineRouterUpdate />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
|
||||
+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>
|
||||
|
||||
@@ -73,6 +73,7 @@ import { AgentInstructionsSettings } from "../components/settings/AgentInstructi
|
||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -310,6 +311,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
{/* Every AI tool's login profiles, linked to the account pool: the
|
||||
host's logins, a staff-only read (a developer would get 403s). */}
|
||||
{seesAll && <ToolProfilesSettings />}
|
||||
{/* Every user's own pick of a pool account (server mode; hides itself). */}
|
||||
<PoolAccountSettings />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
|
||||
+272
-111
@@ -1,38 +1,58 @@
|
||||
/**
|
||||
* @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`). Each row's spend shows its spend quota (spent of the
|
||||
* limit, red once used up) and the default quota sits in its own card
|
||||
* (`UserQuota.tsx`); a user's own quota is part of Edit.
|
||||
* 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,7 +61,9 @@ import {
|
||||
api,
|
||||
type AuditEntry,
|
||||
type AuthUser,
|
||||
type MemberTmuxRow,
|
||||
type ServerUserUsage,
|
||||
type UserQuota,
|
||||
type UserStorage,
|
||||
} from "../lib/api";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
|
||||
@@ -53,6 +75,16 @@ 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 { QuotaDefaultCard, QuotaSummary } from "../components/users/UserQuota";
|
||||
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,13 +92,24 @@ 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;
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
// The default spend quota (`undefined` until loaded or from an older server).
|
||||
const [quotaDefault, setQuotaDefault] = useState<UserQuota | null | undefined>(undefined);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -75,6 +118,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) =>
|
||||
@@ -96,6 +157,7 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
.list()
|
||||
.then((r) => {
|
||||
setUsers(r.users);
|
||||
setQuotaDefault(r.quotaDefault);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
@@ -108,7 +170,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 +196,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 +213,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 +289,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 +310,141 @@ 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>
|
||||
<QuotaSummary quota={u.quota} />
|
||||
</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 +465,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>
|
||||
)}
|
||||
@@ -362,8 +495,21 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
);
|
||||
})()}
|
||||
|
||||
{quotaDefault !== undefined && (
|
||||
<QuotaDefaultCard
|
||||
quotaDefault={quotaDefault}
|
||||
isAdmin={isAdmin}
|
||||
onSaved={(message) => {
|
||||
toast.success(message);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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,9 +563,22 @@ 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}
|
||||
quotaDefault={quotaDefault ?? null}
|
||||
self={editing.id === me.id}
|
||||
users={users ?? []}
|
||||
onClose={() => setEditing(null)}
|
||||
@@ -434,47 +593,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();
|
||||
@@ -155,6 +228,8 @@ describe("Users page", () => {
|
||||
expect(confirm.hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm.hasAttribute("disabled")).toBe(false);
|
||||
// The pool board reads the user list too; count the page's own reload.
|
||||
const before = users.list.mock.calls.length;
|
||||
await ue.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(users.create).toHaveBeenCalledWith(
|
||||
@@ -163,7 +238,7 @@ describe("Users page", () => {
|
||||
)
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
|
||||
expect(users.list).toHaveBeenCalledTimes(before + 1); // reloaded after the change
|
||||
});
|
||||
|
||||
it("reports a wrong password in the edit dialog and keeps it open", async () => {
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.27 (rebuild client to match)
|
||||
UI build v2.8.31 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+22
-12
@@ -89,13 +89,13 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Claude `subscriptionType` is the readable plan — "Max 5x", "Team Premium"; for Codex the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email`, `plan` (a login's readable plan — "Max 5x", "Team Premium" — else null; Codex rows carry a ChatGPT login's plan) and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
|
||||
|
||||
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
||||
|
||||
@@ -105,6 +105,9 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
| `PUT /api/me/prefs` `{prefs: {<key>: "<value>" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB |
|
||||
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
|
||||
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
|
||||
| `GET /api/me/quota` | server mode: `{quota: {limit_usd, period: "day"\|"5h", tz_offset, source: "default"\|"user", spend, pct, exceeded, resetsAt, since} \| null}` — the account's own [spend quota](SERVER-MODE.md#spend-quotas) now; `null` without one, and always in local mode |
|
||||
| `GET /api/me/pool-accounts` | server mode: `{tools: [{tool, pin, choice, accounts: [{name, kind, email, expired, quotaPercent, full}]}]}` — per tool (`claude`, `codex`, `cursor`) with at least one account the account's [pool group](SERVER-MODE.md#a-users-own-pool-account) allows; never a token. Local mode: `{tools: []}` |
|
||||
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
|
||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
|
||||
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
|
||||
@@ -117,7 +120,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
|
||||
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
||||
|
||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
|
||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). It also carries what the Dashboard's System Health score reads beyond `/api/host`: `server.event_loop` `{p50, p99, max}` (event-loop delay over the last minute, ms — the same figures as `agenthub_event_loop_delay_seconds` in `/api/metrics`) and `activity` `{last_event_at, working_agents, agents_24h: {completed, error}}` (newest event, agents working now, agents that ended in the last 24 h — scoped to the caller's rows). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -939,8 +942,11 @@ read the disk instead for a folder that is not a git repo (below).
|
||||
GET /api/lanes/:id/git/branches
|
||||
```
|
||||
|
||||
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
|
||||
— the local branches of the workspace's own repo, for the branch switcher. The repo
|
||||
`{"available": true, "branches": [...], "remotes": ["origin/feat/y"], "current": "feat/x", "worktrees": [...]}`
|
||||
— the local branches of the workspace's own repo, for the branch switcher, and
|
||||
`remotes`: the fetched remote-tracking branches that have no local branch of
|
||||
that name yet (the remotes' `HEAD` left out). The switcher lists them under
|
||||
"On the remote" and re-reads the list after every fetch and pull. The repo
|
||||
comes from the workspace row, never from the query (that is what `GET
|
||||
/api/lanes/branches` is for, which takes a candidate path for a workspace that does
|
||||
not exist yet; in server mode with `AGENTHUB_USERS_ROOT` set, a developer may name
|
||||
@@ -1056,7 +1062,7 @@ cannot collide on `index.lock`.
|
||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
|
||||
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
|
||||
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
|
||||
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones | `{"branch": "...", "create": false}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
|
||||
@@ -1866,6 +1872,10 @@ classDiagram
|
||||
+string pinned_at "null unless pinned"
|
||||
+number cost
|
||||
+number agent_count
|
||||
+number owner_id "server mode: workspace owner; null if none"
|
||||
+string owner_name "server mode: owner's username"
|
||||
+string owner_display_name "server mode"
|
||||
+string owner_avatar_at "server mode"
|
||||
}
|
||||
|
||||
SessionListResponse --> Session
|
||||
@@ -2578,7 +2588,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
|
||||
GET /api/pricing/quota[?force=1]
|
||||
```
|
||||
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's read from the rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; `subscriptionType` alone says `team` for both seats and keeps `pro` after an upgrade), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
||||
#### Analytics
|
||||
|
||||
```http
|
||||
@@ -2894,7 +2904,7 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
|
||||
|
||||
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity. On **macOS** the live `claudeAiOauth` is NOT that file — the `claude` CLI keeps it in the login Keychain (generic password, service `Claude Code-credentials`, account `$USER`), read/written via `/usr/bin/security`; the file is only a fallback (and is kept in step when one already exists). `AGENTHUB_CC_KEYCHAIN=0` pins the file.
|
||||
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active, mismatch }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active, mismatch }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `plan` — the readable plan from tier + seat —, `expiresAt`).
|
||||
|
||||
**Who is logged in is decided by the TOKEN, not by `.claude.json`.** The two can disagree — a running Claude Code session can leave the credentials store holding one account's token while `oauthAccount` still names another, and every request is then billed to the token's account. So `GET /profiles`, `POST /profiles`, `POST /profiles/activate` and `POST /profiles/usage` first ask Anthropic who owns the live token (`GET https://api.anthropic.com/api/oauth/profile`, `cc-usage.liveOwner`, cached per token — an access token never changes hands). `active` (and each item's `active`) is that owner; `mismatch` is `{ configEmail, configUuid }` when the config names someone else, else `null`. Offline, or with an expired live token, there is no owner and the config decides, as before.
|
||||
|
||||
@@ -3006,7 +3016,7 @@ GET /api/ai-tools/:id/config/:i { path, format, text, sha }
|
||||
PUT /api/ai-tools/:id/config/:i Body: { text, baseSha } → { sha, backup }
|
||||
POST /api/ai-tools/:id/install Start the catalog's install job → 202 { job }
|
||||
POST /api/ai-tools/:id/update Start the catalog's update job → 202 { job }
|
||||
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater
|
||||
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater (any signed-in user; other /ai-tools writes are an admin's)
|
||||
GET /api/ai-tools/job The current or last job, or null
|
||||
GET /api/ai-tools/versions { [id]: { npmManaged, installed, latest, updateAvailable } }
|
||||
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
|
||||
@@ -3034,7 +3044,7 @@ The login profiles of the AI tools other than Claude Code (`server/lib/tool-prof
|
||||
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
||||
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
|
||||
GET /api/ai-proxies/9router/update versions, running state, `blocker`, current job (`?fresh=1` re-asks npm)
|
||||
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (admin)
|
||||
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (any signed-in user)
|
||||
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
|
||||
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
||||
@@ -3057,7 +3067,7 @@ A **proxy profile** is an endpoint that stands in front of Claude Code or Codex
|
||||
|
||||
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
|
||||
|
||||
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, an admin's only — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
|
||||
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, shown to every user — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`, but their `POST …/update` is in `MEMBER_WRITE_EXCEPTIONS`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
|
||||
1. **shutdown** — a running `9router.service` user unit is `systemctl --user stop`ped; otherwise this account's processes whose argv names the install's `cli.js` get SIGTERM, then they and their descendants SIGKILL after 5 s; the step waits until the port is free. Skipped when nothing ran.
|
||||
2. **update** — `npm install -g --prefix <the install's own prefix> --prefer-online 9router@latest`; a prefix this account cannot write runs it under `sudo -n` with `--cache /var/cache/agenthub-9router-npm` (root's HOME is inaccessible inside the AgentHub unit; the sudoers rule must name that exact argv), only when `sudo -n <npm> --version` succeeds (else `ENOPERM` with `command: "sudo npm install -g 9router@latest"`).
|
||||
3. **relaunch** — only when 9router was running before: `systemctl --user start 9router.service`, or `node cli.js --tray --no-browser --skip-update -p <old port> [-H <old host>]` detached in its own `systemd-run --user --scope` (plain detached spawn without one); done once the port answers. Runs even after a failed update.
|
||||
@@ -3161,7 +3171,7 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
|
||||
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
|
||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||
|
||||
+137
-13
@@ -109,7 +109,11 @@ start (`server/db.js`), and `member` is no longer accepted.
|
||||
`/api/settings`, `/updates`, `/codex-config`,
|
||||
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
||||
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`) — except
|
||||
the two update buttons any signed-in user may press, `POST
|
||||
/api/ai-tools/update-all` and `POST /api/ai-proxies/9router/update`
|
||||
(`MEMBER_WRITE_EXCEPTIONS`; both run only what the catalog / the fixed 9router
|
||||
job names) — plus a write
|
||||
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)) and the
|
||||
host-wide parts of `/cc-config` (see
|
||||
[Your own Claude Code config](#your-own-claude-code-config)). A
|
||||
@@ -166,20 +170,48 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days
|
||||
(booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the
|
||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||
redirects there) shows it on its **Users** tab, with the audit log under it.
|
||||
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
||||
**Server** tab: the machine's hostname, OS, uptime, CPU model, cores and
|
||||
redirects there) shows it — one page, no tabs: the users table, the account
|
||||
pool, then the audit log. The machine itself is on the Dashboard's
|
||||
**System Health** tab (below).
|
||||
The table's spend column is today's (the browser's local day), with 7 and 30
|
||||
days under it. Clicking a user expands a short detail right under their row
|
||||
(`/admin?user=<id>`, `GET /api/users/:id/details`): last sign-in, last
|
||||
activity and what runs now, then spend over 30 days by AI tool and top
|
||||
models, the account pool per tool (pin or group, each account's quota left
|
||||
beside their own 7-day share of its spend), their live panes and
|
||||
workspaces, and their five newest sessions.
|
||||
- `GET /api/host` (every signed-in user — `/host` is deliberately NOT
|
||||
staff-only, so a developer also sees other users' usernames, CPU / RAM share
|
||||
and busiest process names) is the machine on the Dashboard's **System
|
||||
Health** tab — its **Machine** group, between **AgentHub** (score, storage,
|
||||
concurrency) and **Agent activity** (tokens by model, tools, subagents): the machine's hostname, OS, uptime, CPU model, cores and
|
||||
load, memory and swap, every real disk (HDD or SSD, how full; the one
|
||||
holding the users' homes is marked), any NVIDIA GPU, and each user's live
|
||||
share of it — CPU % of the whole machine (100 % = every core busy) and
|
||||
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
|
||||
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
|
||||
as a share of the homes disk. A process counts for a user when it runs as
|
||||
their Linux account (S3) or, for the host account, when it inherited a
|
||||
workspace's `AGENTHUB_LANE_ID` — so without S3 a user's share is only what
|
||||
runs in their workspaces' panes; the rest is **Host account** or
|
||||
**System**. Read-only (`server/lib/host-stats.js`); Linux only for the
|
||||
per-user part. The page re-reads every 5 s while shown.
|
||||
**System**. The same 1 s sample also gives every CPU thread's busy %,
|
||||
network download/upload per interface (no `lo`), read/write per whole
|
||||
disk, and the ten busiest processes machine-wide with who they count for;
|
||||
plus `/sys/class/hwmon` temperatures, physical cores, boot time and the
|
||||
AgentHub process's own pid, Node version, uptime and RSS — laid out in the
|
||||
tab's own card style (`client/src/components/health/HostHealth.tsx`): the
|
||||
machine card (with the V8 heap from `/api/settings/info`), then CPU threads,
|
||||
temperatures / GPUs, disks with their I/O and network, then history and the
|
||||
busiest processes. Read-only (`server/lib/host-stats.js`); Linux only for
|
||||
the per-user part. The tab re-reads it every 5 s while shown.
|
||||
- `GET /api/host/history?range=1h|6h|24h` (every signed-in user) is the tab's
|
||||
**History** charts: machine CPU % and memory % (plus swap, load and each
|
||||
user's CPU/memory in the payload), one point every 30 s for 24 h. Every
|
||||
server on Linux (local or server mode) keeps it IN MEMORY from the moment it starts
|
||||
(`startHistory`, server/index.js) — a restart starts it empty, and nothing
|
||||
is written to disk.
|
||||
- `GET /api/users/storage` (admin/analyst) is the page's **Storage** column:
|
||||
how much disk each user's home takes (their Linux home under S3, their
|
||||
folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads
|
||||
@@ -563,7 +595,7 @@ agenthub accounts pin cursor-team mia --tool cursor
|
||||
## Account pool groups
|
||||
|
||||
By default every user shares every pool account. **Groups** split the pool
|
||||
by team (Users page → **Account pool → Groups**, server mode only,
|
||||
by team (Users page → **Account pool**, where a group is called a pool, server mode only,
|
||||
`server/lib/pool-groups.js`):
|
||||
|
||||
- A group has a name, members and accounts of every pool tool (Claude, Codex,
|
||||
@@ -581,6 +613,49 @@ by team (Users page → **Account pool → Groups**, server mode only,
|
||||
an account takes it out of every group; deleting a group sends its members
|
||||
back to the shared pool and removes no account.
|
||||
|
||||
On the Users page a group is shown as a **pool**: the **Account pool** card
|
||||
is one board with a column for the shared pool and one per pool, each
|
||||
holding its accounts of every tool (grouped by tool, each a card with its
|
||||
plan — "Max 5x", "Team Premium", a ChatGPT plan —, live panes and quota) and
|
||||
its members. Drag an account or a member from one
|
||||
column to another — in the UI an account sits in ONE pool, so a drop moves
|
||||
it (onto the shared pool takes it off every pool). Dropping one on the
|
||||
trailing **New pool** column SPLITS it off into a new pool of its own;
|
||||
dragging a pool's ONLY account into another column MERGES that pool there —
|
||||
its members come along and the emptied pool is deleted on save. A column's **+ Move an
|
||||
account or saved profile here** menu does the same without dragging and also
|
||||
lists the saved login profiles, which are moved into the pool on save; **Pick
|
||||
members** ticks members by role (Admin / Developer / Analyst) or **No pool**
|
||||
in a dialog; **Add account** in the card's header adds a new account for a
|
||||
chosen tool (it lands in the shared pool). Nothing is sent until **Save**,
|
||||
which asks for the password once and applies every change (profiles moved
|
||||
in, then new pools created, then each changed pool set exactly, then merged
|
||||
pools deleted); a failed
|
||||
step keeps the board so a retry resumes where it stopped. Tools with no pool
|
||||
(Oh My Pi, any other installed tool) keep a tab each under **Other AI
|
||||
tools**. An account's Edit (pencil) uses the same role / No pool list.
|
||||
|
||||
### A user's own pool account
|
||||
|
||||
When the pool holds more than one account a user may use, they pick which one
|
||||
their own new sessions run on: **Settings → AI tools → Pool account**, one
|
||||
select per tool (Claude, Codex, Cursor), every role, server mode only
|
||||
(`GET/PUT /api/me/pool-accounts`, `account_choices`, `server/lib/account-pins.js`).
|
||||
|
||||
- The list is only what their group allows (or the shared pool); a choice
|
||||
outside it is refused (`ENOACCOUNT`), and one later taken from the group
|
||||
falls back to the balancer.
|
||||
- **Automatic** (the default) leaves it to the balancer.
|
||||
- An admin's pin wins and locks the select (`ELOCKED`); the choice comes back
|
||||
once the pin is removed.
|
||||
- A Claude login goes into the user's home at once, like an admin's switch,
|
||||
so running panes follow it; Codex and Cursor take it at the next session (a
|
||||
ChatGPT login still waits while another holds the home).
|
||||
- It is the user's own change: the login cookie only (a pane token cannot),
|
||||
audited, no password step-up. The Users page's user detail shows it as
|
||||
"chose <account>". Oh My Pi has no pool to choose from (its sharing is the
|
||||
host's one login).
|
||||
|
||||
The tab bar also shows Oh My Pi (its login sharing, below) and every other
|
||||
installed tool, which says it has no pool (Antigravity keeps its Google login
|
||||
in the system keyring; there is no key AgentHub could hand a session).
|
||||
@@ -635,6 +710,55 @@ omp login # e.g. anthropic
|
||||
> runs; it cannot carry a login away. A member who edits the `agenthub`
|
||||
> provider out of their `models.yml` gets it back at their next omp pane.
|
||||
|
||||
## Spend quotas
|
||||
|
||||
An admin limits what each user may spend, in dollars, on the Users page
|
||||
(`server/lib/user-quota.js`):
|
||||
|
||||
- **Default spend quota** card: one limit every user without their own gets
|
||||
(`PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null}`).
|
||||
- **Edit user → Spend quota**: *Default*, *No limit*, or *Their own limit*
|
||||
(`PATCH /api/users/:id` `{quota: {mode: "default" | "unlimited" | "custom", …}}`).
|
||||
- A period is `day` (from local midnight of the zone the admin's browser was
|
||||
in when they saved it) or `5h` (sliding: the current UTC hour and the four
|
||||
before it — spend booked in hour H counts until H + 5 h).
|
||||
- Both writes need the admin's own password again and are audited, like
|
||||
every account change; each one re-applies at once (`sweep`).
|
||||
|
||||
Spend is the users page's own figure: the tokens of the sessions the user
|
||||
OWNS through a workspace, priced by `calculateCost`. The Users table shows
|
||||
each user's spend against their quota; the user sees their own as **Quota N%**
|
||||
in the status bar (`GET /api/me/quota`).
|
||||
|
||||
Once a user's spend reaches the limit, until the window opens again:
|
||||
|
||||
- **No new session.** Every start, resume or prompt from the workspace home
|
||||
goes through `spawnRun`, which answers `429 EQUOTA` with the reason.
|
||||
- **Claude Code and Codex prompts are refused** by the hooks. Every pane
|
||||
carries `AGENTHUB_QUOTA_FILE`. The dashboard writes that file every minute:
|
||||
the host's under `<data dir>/user-quota/u<id>.json`, a member's (S3) in
|
||||
their handoff dir through `write-as`. While it says `blocked`,
|
||||
`scripts/hook-handler.js` exits 2 on `UserPromptSubmit` with the reason on
|
||||
stderr, and the CLI drops the prompt and shows why. The hook reads only that
|
||||
local file and never asks the dashboard. It fails open: a missing or
|
||||
unreadable file, or a passed `until`, lets the prompt through. Codex takes
|
||||
this only with AgentHub's Codex hooks installed and trusted.
|
||||
- **A member's omp on the shared login** (above) loses its gateway key: the
|
||||
run's key is left out of `clients.json` until the quota opens. Its pane stays.
|
||||
- **Every other pane of theirs is ended.** This covers Antigravity, Cursor, a
|
||||
Codex without trusted hooks (a member's Codex always), an omp on its own
|
||||
login, and any other catalog tool. Plain shells are left alone.
|
||||
|
||||
Limits of the check:
|
||||
|
||||
- Tokens land after a turn, so a user can finish one turn past the limit
|
||||
(omp/Codex journals are polled, so a little more).
|
||||
- A user's own terminal outside AgentHub carries no flag file.
|
||||
- Without S3 every pane runs as the host account, which can edit the flag.
|
||||
|
||||
Turn the minute sweep off with `DASHBOARD_QUOTA_SWEEP_MS=0`. The `EQUOTA`
|
||||
refusal at start does not depend on it.
|
||||
|
||||
## One Linux user per account
|
||||
|
||||
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
||||
@@ -916,12 +1040,12 @@ a pane really is. Each case below is refused or bounded, with a test in
|
||||
an older server. Until `agenthub server install-system` has installed
|
||||
it, calls use `TMUX_BIN` directly, as before. A member's server keeps its
|
||||
version until their last pane closes; the next start runs `TMUX_BIN`.
|
||||
- **Administration → Server → Members' tmux** (admin/analyst read, admin acts): each member's
|
||||
- **Administration → users table, tmux column** (admin/analyst read, admin acts): each member's
|
||||
running server with its version beside the installed one (an *older than
|
||||
installed* badge), its sessions and whether an agent of theirs is working.
|
||||
**Restart** ends one member's server — every pane of theirs closes, their
|
||||
The row's **Restart** button ends one member's server — every pane of theirs closes, their
|
||||
Claude sessions resume from the workspace — and **Restart N idle members**
|
||||
does it for every outdated member with no agent working; both take the
|
||||
(above the table) does it for every outdated member with no agent working; both take the
|
||||
admin's password again and are audited per member. A server that will not
|
||||
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
|
||||
- **A member's huge or sparse files.** Transcripts and journals are read line
|
||||
@@ -999,7 +1123,7 @@ agenthub server install-system
|
||||
|
||||
A member server that a newer tmux already reached before `tmux-member` was
|
||||
installed can stay wedged: every call on that socket — `tmux-member`'s too —
|
||||
prints `server exited unexpectedly`. If Administration → Server → Members' tmux shows it with
|
||||
prints `server exited unexpectedly`. If the users table's tmux column on Administration shows it with
|
||||
no panes, stop that one server (its pid is the `ah-<name>` process running
|
||||
`tmux -S <USERS_ROOT>/<name>/.agenthub/tmux/sock`) with `sudo kill -9 <pid>`;
|
||||
the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`.
|
||||
|
||||
+417
-7
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.27
|
||||
version: 2.8.30
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -71,7 +71,7 @@ tags:
|
||||
- name: Artifacts
|
||||
description: 'What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller''s own account only. Every write is same-origin.'
|
||||
- name: Host
|
||||
description: 'Administration → Server: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, staff only in server mode.'
|
||||
description: 'Dashboard → System Health: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, every signed-in user.'
|
||||
- name: Locks
|
||||
description: Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop.
|
||||
- name: Projects
|
||||
@@ -262,6 +262,21 @@ components:
|
||||
agent_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
owner_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: 'Server mode, list only: the account owning the session''s workspace (its lane, else the lane of a dashboard run holding it); null when none. Absent in local mode.'
|
||||
owner_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: That owner's username.
|
||||
owner_display_name:
|
||||
type: string
|
||||
nullable: true
|
||||
owner_avatar_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_activity:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -1578,6 +1593,32 @@ components:
|
||||
type: string
|
||||
ws_connections:
|
||||
type: integer
|
||||
event_loop:
|
||||
type: object
|
||||
description: Event-loop delay over the last minute in ms (p50, p99, max) — how long requests, hooks and terminal echo waited.
|
||||
properties:
|
||||
p50:
|
||||
type: number
|
||||
p99:
|
||||
type: number
|
||||
max:
|
||||
type: number
|
||||
activity:
|
||||
type: object
|
||||
description: 'For the System Health score: when the newest event (hook) landed, agents working now, and how the agents that ended in the last 24 h ended (scoped to the caller''s rows).'
|
||||
properties:
|
||||
last_event_at:
|
||||
type: string
|
||||
nullable: true
|
||||
working_agents:
|
||||
type: integer
|
||||
agents_24h:
|
||||
type: object
|
||||
properties:
|
||||
completed:
|
||||
type: integer
|
||||
error:
|
||||
type: integer
|
||||
transcript_cache:
|
||||
type: object
|
||||
required:
|
||||
@@ -4616,6 +4657,73 @@ components:
|
||||
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardEvent'
|
||||
UserQuota:
|
||||
type: object
|
||||
description: 'A spend quota: `limit_usd` per `period` — `day` from the local midnight of `tz_offset` (getTimezoneOffset() minutes), or `5h`, the current UTC hour and the four before it.'
|
||||
required:
|
||||
- limit_usd
|
||||
- period
|
||||
properties:
|
||||
limit_usd:
|
||||
type: number
|
||||
minimum: 0
|
||||
exclusiveMinimum: true
|
||||
period:
|
||||
type: string
|
||||
enum:
|
||||
- day
|
||||
- 5h
|
||||
tz_offset:
|
||||
type: integer
|
||||
minimum: -840
|
||||
maximum: 840
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- default
|
||||
- user
|
||||
readOnly: true
|
||||
UserQuotaSetting:
|
||||
type: object
|
||||
description: 'A user''s own quota setting: `default` (the default applies), `unlimited`, or `custom` with its own limit.'
|
||||
required:
|
||||
- mode
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum:
|
||||
- default
|
||||
- unlimited
|
||||
- custom
|
||||
limit_usd:
|
||||
type: number
|
||||
period:
|
||||
type: string
|
||||
enum:
|
||||
- day
|
||||
- 5h
|
||||
tz_offset:
|
||||
type: integer
|
||||
UserQuotaStatus:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
- type: object
|
||||
properties:
|
||||
spend:
|
||||
type: number
|
||||
description: USD spent in the current window
|
||||
pct:
|
||||
type: number
|
||||
exceeded:
|
||||
type: boolean
|
||||
description: 'Used up: new panes are refused (EQUOTA) and prompts refused by the hooks'
|
||||
resetsAt:
|
||||
type: string
|
||||
nullable: true
|
||||
description: When it opens again
|
||||
since:
|
||||
type: string
|
||||
description: Start of the current window
|
||||
AccountPin:
|
||||
type: object
|
||||
required:
|
||||
@@ -10934,6 +11042,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: 'EQUOTA (server mode): the workspace owner''s spend quota is used up until it resets.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected failure (EINTERNAL).
|
||||
content:
|
||||
@@ -12162,8 +12276,8 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: The lane repo's own local branches
|
||||
description: Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
|
||||
summary: The lane repo's own local branches, and the remote ones not checked out yet
|
||||
description: Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
|
||||
operationId: listLaneOwnBranches
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -12173,7 +12287,7 @@ paths:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: '{available, branches, current, worktrees}.'
|
||||
description: '{available, branches, remotes, current, worktrees}.'
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/commit-message:
|
||||
@@ -14412,6 +14526,30 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/quota:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: getMyQuota
|
||||
summary: The account's own spend quota now (null without one, and in local mode)
|
||||
responses:
|
||||
'200':
|
||||
description: Quota
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
quota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuotaStatus'
|
||||
nullable: true
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/instructions:
|
||||
get:
|
||||
tags:
|
||||
@@ -14499,6 +14637,144 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/pool-accounts:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: The account pool as this account may use it (server mode)
|
||||
description: 'Per tool with at least one account the account''s pool group allows: those accounts (never a token), an admin''s pin and the account''s own choice. Local mode: {tools: []}.'
|
||||
responses:
|
||||
'200':
|
||||
description: Tools
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- tools
|
||||
properties:
|
||||
tools:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- tool
|
||||
- pin
|
||||
- choice
|
||||
- accounts
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
enum:
|
||||
- claude
|
||||
- codex
|
||||
- cursor
|
||||
pin:
|
||||
type: string
|
||||
nullable: true
|
||||
choice:
|
||||
type: string
|
||||
nullable: true
|
||||
accounts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
nullable: true
|
||||
expired:
|
||||
type: boolean
|
||||
quotaPercent:
|
||||
type: number
|
||||
nullable: true
|
||||
full:
|
||||
type: boolean
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
put:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Run this account's new sessions of a tool on one pool account (null = automatic)
|
||||
description: Below an admin's pin, above the balancer. A Claude login goes into the account's home at once (`switched`); Codex and Cursor take it at the next session. Login cookie only, audited.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- tool
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
enum:
|
||||
- claude
|
||||
- codex
|
||||
- cursor
|
||||
account:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: Chosen
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
choice:
|
||||
type: string
|
||||
nullable: true
|
||||
switched:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- switched
|
||||
- kept
|
||||
- own-login
|
||||
- expired
|
||||
- next-session
|
||||
'400':
|
||||
description: EBADTOOL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOACCOUNT — not in the pool, or not one the account's group may use
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ELOCKED — an admin pinned this account's sessions of the tool
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/onboarded:
|
||||
post:
|
||||
tags:
|
||||
@@ -14787,6 +15063,16 @@ paths:
|
||||
type: number
|
||||
tokens:
|
||||
type: integer
|
||||
quotaSetting:
|
||||
$ref: '#/components/schemas/UserQuotaSetting'
|
||||
quota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuotaStatus'
|
||||
nullable: true
|
||||
quotaDefault:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
nullable: true
|
||||
'403':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
@@ -15014,6 +15300,38 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/details:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: getServerUserDetails
|
||||
summary: 'One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account''s quota beside this user''s own 5 h / 7 d share. Never a token'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: tz_offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
description: The browser's getTimezoneOffset(), for the daily buckets
|
||||
responses:
|
||||
'200':
|
||||
description: Account details
|
||||
'403':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}:
|
||||
get:
|
||||
tags:
|
||||
@@ -15092,6 +15410,8 @@ paths:
|
||||
type: boolean
|
||||
enum:
|
||||
- true
|
||||
quota:
|
||||
$ref: '#/components/schemas/UserQuotaSetting'
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
@@ -15105,7 +15425,7 @@ paths:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
'400':
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -15202,6 +15522,72 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/quota-default:
|
||||
put:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: setDefaultUserQuota
|
||||
summary: 'Admin: the default spend quota for every user without one of their own (`quota: null` removes it). A user over their quota can start no pane (429 EQUOTA) and their Claude Code/Codex prompts are refused by the hooks'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- quota
|
||||
- currentPassword
|
||||
properties:
|
||||
quota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
nullable: true
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Saved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
quotaDefault:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
nullable: true
|
||||
'400':
|
||||
description: EBADQUOTA
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER / ENOLANE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ESELF — your own role or disable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/password:
|
||||
post:
|
||||
tags:
|
||||
@@ -17749,13 +18135,37 @@ paths:
|
||||
description: 'Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).'
|
||||
responses:
|
||||
'200':
|
||||
description: '`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.'
|
||||
description: '`{at, hostname, os, uptime, bootTime, cpu (+physicalCores, perCore), memory, disks, homesMount, gpus, usage, network, diskIo, temperatures, pressure, topProcesses, process}` — see docs/API.md.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
'500':
|
||||
description: EHOST
|
||||
/api/host/history:
|
||||
get:
|
||||
tags:
|
||||
- Host
|
||||
summary: The machine's CPU/memory history since the server started
|
||||
operationId: getHostHistory
|
||||
description: 'In memory only: one point every 30 s for 24 h, kept by a server-mode server on Linux (empty otherwise, `running: false`). Averaged down to at most 360 points. Server mode: admin/analyst only.'
|
||||
parameters:
|
||||
- name: range
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- 1h
|
||||
- 6h
|
||||
- 24h
|
||||
default: 1h
|
||||
responses:
|
||||
'200':
|
||||
description: '`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<userId>: {cpu, memory}}}]}`.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
/api/lanes:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.27",
|
||||
"version": "2.8.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.27",
|
||||
"version": "2.8.31",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.27",
|
||||
"version": "2.8.31",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -16,9 +16,18 @@
|
||||
* for the response is what made Claude Code sit at "running hooks" for seconds
|
||||
* whenever a dashboard was busy, slow, or wedged.
|
||||
*
|
||||
* The one thing it may refuse: in server mode a pane carries
|
||||
* `AGENTHUB_QUOTA_FILE` (server/lib/user-quota.js), and while that file says
|
||||
* its owner is over their spend quota a Claude Code or Codex
|
||||
* `UserPromptSubmit` exits 2 with the reason on stderr — both CLIs then drop
|
||||
* the prompt and show why. Decided from that local file alone, never by
|
||||
* asking the dashboard, and fail-open: no file, an unreadable one or an
|
||||
* expired `until` lets the prompt through. Antigravity never gets a refusal.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
|
||||
const hookType = process.argv[2] || "unknown";
|
||||
@@ -44,11 +53,36 @@ function resolvePorts() {
|
||||
|
||||
const ports = resolvePorts();
|
||||
|
||||
/** The reason to refuse this prompt (spend quota used up), or null to let it through. */
|
||||
function quotaRefusal() {
|
||||
const file = process.env.AGENTHUB_QUOTA_FILE;
|
||||
if (!file || hookType !== "UserPromptSubmit" || tool === "antigravity") return null;
|
||||
try {
|
||||
if (fs.statSync(file).size > 64 * 1024) return null;
|
||||
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (flag?.blocked !== true) return null;
|
||||
const until = Date.parse(flag.until);
|
||||
if (Number.isFinite(until) && until <= Date.now()) return null;
|
||||
return typeof flag.message === "string" && flag.message
|
||||
? flag.message
|
||||
: "AgentHub: your spend quota is used up.";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refusal = quotaRefusal();
|
||||
|
||||
let input = "";
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => (input += chunk));
|
||||
process.stdin.on("end", () => {
|
||||
if (refusal) {
|
||||
// The prompt never runs, so it is not reported either.
|
||||
process.stderr.write(`${refusal}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
let parsedData;
|
||||
try {
|
||||
parsedData = JSON.parse(input);
|
||||
|
||||
@@ -2040,6 +2040,16 @@ describe("Transcript cache integration", () => {
|
||||
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
|
||||
});
|
||||
|
||||
it("reports event-loop delay and the last hook / agents for the health score", async () => {
|
||||
const res = await fetch("/api/settings/info");
|
||||
const loop = res.body.server.event_loop;
|
||||
for (const k of ["p50", "p99", "max"]) assert.ok(Number.isFinite(loop[k]), `event_loop.${k}`);
|
||||
const a = res.body.activity;
|
||||
assert.ok(a.last_event_at === null || typeof a.last_event_at === "string");
|
||||
assert.ok(Number.isInteger(a.working_agents));
|
||||
assert.ok(Number.isInteger(a.agents_24h.completed) && Number.isInteger(a.agents_24h.error));
|
||||
});
|
||||
|
||||
it("should evict cache entry on SessionEnd", async () => {
|
||||
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -623,6 +623,18 @@ describe("skills from files or written", () => {
|
||||
["s/.env", "K=1"],
|
||||
]);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
const word = await upload("/api/artifacts/skill/upload", [
|
||||
["w/SKILL.md", "---\ndescription: W\n---\n"],
|
||||
["w/api-token.json", "{}"],
|
||||
]);
|
||||
assert.equal(word.body.error.code, "ESECRET");
|
||||
// The word alone in a doc or a script is skill content, not a key.
|
||||
const docs = await upload("/api/artifacts/skill/upload", [
|
||||
["tokens/SKILL.md", "---\ndescription: Design tokens\n---\n"],
|
||||
["tokens/design-tokens.md", "# tokens"],
|
||||
["tokens/scripts/count_tokens.py", "print(1)"],
|
||||
]);
|
||||
assert.equal(docs.status, 201, JSON.stringify(docs.body));
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], {
|
||||
description: "d",
|
||||
});
|
||||
|
||||
@@ -444,3 +444,21 @@ describe("cc-profiles — identity and token disagree", () => {
|
||||
assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" });
|
||||
});
|
||||
});
|
||||
|
||||
it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => {
|
||||
const { planLabel } = profiles;
|
||||
// An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth.
|
||||
assert.strictEqual(planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), "Max 5x");
|
||||
assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x");
|
||||
assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro");
|
||||
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
|
||||
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
|
||||
assert.strictEqual(planLabel(team, { seatTier: "team_premium" }), "Team Premium");
|
||||
// `team_tier_1` names no kind; a Max-sized limit is what a premium seat gives.
|
||||
assert.strictEqual(
|
||||
planLabel(team, { seatTier: "team_tier_1", userRateLimitTier: "default_claude_max_5x" }),
|
||||
"Team Premium"
|
||||
);
|
||||
assert.strictEqual(planLabel(team, null), "Team");
|
||||
assert.strictEqual(planLabel(null, null), null);
|
||||
});
|
||||
|
||||
@@ -138,7 +138,10 @@ describe("collectChanges — work range", () => {
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
// `--untracked-files=normal` folded these into one "docs/" row that
|
||||
// opened nothing; the panel needs the files, like JetBrains and VS Code.
|
||||
assert.deepEqual(out.files.map((f) => f.path).sort(), ["docs/deep/notes.txt", "docs/report.md"]);
|
||||
assert.deepEqual(out.files.map((f) => f.path).sort(), [
|
||||
"docs/deep/notes.txt",
|
||||
"docs/report.md",
|
||||
]);
|
||||
assert.ok(out.files.every((f) => f.untracked && f.added === 1));
|
||||
assert.equal((await gitFacts(LANE)).untracked, 2);
|
||||
});
|
||||
@@ -244,10 +247,7 @@ describe("collectChanges — branch range", () => {
|
||||
|
||||
it("falls back to the default branch without a base, and refuses a base that is gone", async () => {
|
||||
// No recorded base (an adopted folder): what origin/HEAD names.
|
||||
assert.equal(
|
||||
(await collectChanges(LANE, { range: "branch", base: null })).base,
|
||||
"development"
|
||||
);
|
||||
assert.equal((await collectChanges(LANE, { range: "branch", base: null })).base, "development");
|
||||
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
|
||||
code: "ENOBASE",
|
||||
});
|
||||
@@ -374,6 +374,48 @@ describe("runGitAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists a branch only the remote has after a fetch, and switching tracks it", async () => {
|
||||
const { listRemoteBranches } = require("../lib/worktree");
|
||||
// Someone else pushes a new branch; this clone has never seen it.
|
||||
gc(OTHER, "checkout", "-qb", "feat/remote-only");
|
||||
write(OTHER, "r.txt", "remote\n");
|
||||
gc(OTHER, "add", "-A");
|
||||
gc(OTHER, "commit", "-qm", "remote work");
|
||||
gc(OTHER, "push", "-q", "origin", "feat/remote-only");
|
||||
assert.equal(
|
||||
(await listRemoteBranches(LANE)).some((r) => r.name === "origin/feat/remote-only"),
|
||||
false
|
||||
);
|
||||
await runGitAction(LANE, "fetch");
|
||||
const remote = await listRemoteBranches(LANE);
|
||||
assert.ok(remote.some((r) => r.name === "origin/feat/remote-only"));
|
||||
// origin/HEAD and branches that already have a local one are left out.
|
||||
assert.ok(!remote.some((r) => r.branch === "HEAD" || r.branch === "development"));
|
||||
|
||||
const result = await runGitAction(LANE, "switch", { branch: "origin/feat/remote-only" });
|
||||
assert.equal(result.branch, "feat/remote-only");
|
||||
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/remote-only");
|
||||
assert.equal(
|
||||
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
|
||||
"origin/feat/remote-only"
|
||||
);
|
||||
assert.ok(!(await listRemoteBranches(LANE)).some((r) => r.branch === "feat/remote-only"));
|
||||
gc(LANE, "checkout", "-q", "development");
|
||||
});
|
||||
|
||||
it("switches to a bare remote branch name when exactly one remote has it", async () => {
|
||||
gc(OTHER, "checkout", "-qb", "feat/bare");
|
||||
gc(OTHER, "push", "-q", "origin", "feat/bare");
|
||||
await runGitAction(LANE, "fetch");
|
||||
const result = await runGitAction(LANE, "switch", { branch: "feat/bare" });
|
||||
assert.equal(result.branch, "feat/bare");
|
||||
assert.equal(
|
||||
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
|
||||
"origin/feat/bare"
|
||||
);
|
||||
gc(LANE, "checkout", "-q", "development");
|
||||
});
|
||||
|
||||
it("refuses a branch another worktree holds", async () => {
|
||||
const wt = path.join(ROOT, "sibling");
|
||||
g(LANE, "worktree", "add", "-q", wt, "development");
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
/**
|
||||
* @file host-stats.test.js
|
||||
* @description Tests for server/lib/host-stats.js — the Server tab's hardware
|
||||
* snapshot and per-user CPU/memory attribution (`GET /api/host`).
|
||||
* snapshot and per-user CPU/memory attribution (`GET /api/host`), the
|
||||
* /proc and /sys parsers behind its per-core, network, disk I/O and
|
||||
* temperature figures, the PSI pressure parser, and the in-memory history (`GET /api/host/history`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hostStats, mountOf, __setDeps } = require("../lib/host-stats");
|
||||
const {
|
||||
hostStats,
|
||||
hostHistory,
|
||||
mountOf,
|
||||
parseCpuStat,
|
||||
parsePressure,
|
||||
parseNetDev,
|
||||
parseDiskstats,
|
||||
physicalCores,
|
||||
temperatures,
|
||||
downsample,
|
||||
__setDeps,
|
||||
__setHistory,
|
||||
} = require("../lib/host-stats");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const linux = process.platform === "linux";
|
||||
|
||||
@@ -68,3 +86,132 @@ test(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("parsePressure reads some/full averages and refuses a missing file", () => {
|
||||
assert.deepEqual(
|
||||
parsePressure(
|
||||
"some avg10=12.50 avg60=3.00 avg300=1.00 total=1\nfull avg10=4.25 avg60=1.00 avg300=0.50 total=1\n"
|
||||
),
|
||||
{ some10: 12.5, some60: 3, full10: 4.25 }
|
||||
);
|
||||
// The cpu file of an older kernel has no `full` line.
|
||||
assert.deepEqual(parsePressure("some avg10=1.00 avg60=2.00 avg300=0.00 total=1\n"), {
|
||||
some10: 1,
|
||||
some60: 2,
|
||||
full10: 0,
|
||||
});
|
||||
assert.equal(parsePressure(null), null);
|
||||
assert.equal(parsePressure("garbage"), null);
|
||||
});
|
||||
|
||||
test("parseCpuStat reads the machine line and every core", () => {
|
||||
const r = parseCpuStat(
|
||||
"cpu 10 0 10 70 10 0 0 0 0 0\ncpu0 5 0 5 35 5 0 0 0\ncpu1 5 0 5 35 5 0 0 0\nintr 1 2\n"
|
||||
);
|
||||
assert.deepEqual(r.all, { total: 100, idle: 80 });
|
||||
assert.equal(r.cores.length, 2);
|
||||
assert.deepEqual(r.cores[1], { total: 50, idle: 40 });
|
||||
assert.deepEqual(parseCpuStat(""), { all: null, cores: [] });
|
||||
});
|
||||
|
||||
test("parseNetDev skips lo and reads rx/tx bytes", () => {
|
||||
const text = [
|
||||
"Inter-| Receive | Transmit",
|
||||
" face |bytes packets|bytes packets",
|
||||
" lo: 100 1 0 0 0 0 0 0 100 1 0 0 0 0 0 0",
|
||||
"enp4s0: 5000 10 0 0 0 0 0 0 2000 6 0 0 0 0 0 0",
|
||||
].join("\n");
|
||||
const m = parseNetDev(text);
|
||||
assert.equal(m.has("lo"), false);
|
||||
assert.deepEqual(m.get("enp4s0"), { rx: 5000, tx: 2000 });
|
||||
});
|
||||
|
||||
test("parseDiskstats keeps whole disks and converts sectors to bytes", () => {
|
||||
const text = [
|
||||
" 7 0 loop0 14 0 34 12 0 0 0 0 0 11 12",
|
||||
" 8 0 sda 100 0 2048 50 20 0 1024 9 0 30 40",
|
||||
" 8 1 sda1 90 0 2000 40 20 0 1000 9 0 30 40",
|
||||
].join("\n");
|
||||
const m = parseDiskstats(text, (n) => n === "sda");
|
||||
assert.deepEqual([...m.keys()], ["sda"]);
|
||||
assert.deepEqual(m.get("sda"), { read: 2048 * 512, write: 1024 * 512 });
|
||||
});
|
||||
|
||||
test("physicalCores counts distinct physical/core id pairs", () => {
|
||||
const info = [0, 1].flatMap((core) =>
|
||||
[0, 1].map((ht) => `processor\t: ${core * 2 + ht}\nphysical id\t: 0\ncore id\t\t: ${core}\n`)
|
||||
);
|
||||
assert.equal(physicalCores(info.join("\n")), 2);
|
||||
assert.equal(physicalCores(""), null);
|
||||
});
|
||||
|
||||
test("temperatures reads hwmon inputs with their labels", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hwmon-"));
|
||||
try {
|
||||
const chip = path.join(root, "hwmon0");
|
||||
fs.mkdirSync(chip);
|
||||
fs.writeFileSync(path.join(chip, "name"), "coretemp\n");
|
||||
fs.writeFileSync(path.join(chip, "temp1_input"), "45500\n");
|
||||
fs.writeFileSync(path.join(chip, "temp1_label"), "Package id 0\n");
|
||||
fs.writeFileSync(path.join(chip, "temp2_input"), "38000\n");
|
||||
assert.deepEqual(await temperatures(root), [
|
||||
{ chip: "coretemp", label: "Package id 0", celsius: 45.5 },
|
||||
{ chip: "coretemp", label: "temp2", celsius: 38 },
|
||||
]);
|
||||
assert.deepEqual(await temperatures(path.join(root, "missing")), []);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hostHistory filters by range and downsample averages buckets", () => {
|
||||
const now = Date.now();
|
||||
const pt = (minsAgo, cpu) => ({
|
||||
at: new Date(now - minsAgo * 60e3).toISOString(),
|
||||
cpu,
|
||||
memory: 50,
|
||||
memoryUsed: 100,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: { 3: { cpu, memory: 10 } },
|
||||
});
|
||||
__setHistory([pt(300, 90), pt(30, 10), pt(1, 20)]);
|
||||
const h1 = hostHistory("1h");
|
||||
assert.equal(h1.range, "1h");
|
||||
assert.deepEqual(
|
||||
h1.points.map((p) => p.cpu),
|
||||
[10, 20]
|
||||
);
|
||||
assert.equal(hostHistory("6h").points.length, 3);
|
||||
assert.equal(hostHistory("bogus").range, "1h");
|
||||
const d = downsample([pt(3, 10), pt(2, 20), pt(1, 30), pt(0, 40)], 2);
|
||||
assert.deepEqual(
|
||||
d.map((p) => p.cpu),
|
||||
[15, 35]
|
||||
);
|
||||
assert.equal(d[0].users[3].cpu, 15);
|
||||
__setHistory([]);
|
||||
});
|
||||
|
||||
test(
|
||||
"hostStats adds per-core, network, disk I/O, top processes and process facts",
|
||||
{ skip: !linux },
|
||||
async () => {
|
||||
__setDeps({
|
||||
users: () => [],
|
||||
memberNameForUid: () => null,
|
||||
laneOwner: () => null,
|
||||
homesRoot: () => "/",
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
assert.equal(r.cpu.perCore.length, r.cpu.cores);
|
||||
assert.ok(Array.isArray(r.network) && Array.isArray(r.diskIo));
|
||||
assert.ok(r.network.every((n) => n.name !== "lo"));
|
||||
assert.ok(r.topProcesses.length > 0 && r.topProcesses.length <= 10);
|
||||
assert.ok(r.topProcesses.every((p) => typeof p.group === "string" && !("argv" in p)));
|
||||
assert.equal(r.process.pid, process.pid);
|
||||
assert.ok(r.process.rss > 0);
|
||||
assert.ok(typeof r.bootTime === "string");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1122,6 +1122,7 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
const r = await request("GET", `/api/lanes/${laneId}/git/branches`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.branches, ["feat/api-changes"]);
|
||||
assert.deepEqual(r.body.remotes, []); // no remote at all
|
||||
assert.equal(r.body.current, "feat/api-changes");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @file pool-account-choice.test.js
|
||||
* @description A user's own choice of pool account (account-pins.js
|
||||
* `setChoice`/`preferredFor`, `/api/me/pool-accounts`), fake accounts only:
|
||||
* the balancer runs the user's new panes on the chosen account; an admin's pin
|
||||
* wins and locks the choice (ELOCKED); only accounts the user's group allows
|
||||
* may be chosen (ENOACCOUNT), and one later taken from the group falls back
|
||||
* to the balancer; a removed account drops its choices; the routes list no
|
||||
* token, need a login cookie and refuse in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pool-choice-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const groups = require("../lib/pool-groups");
|
||||
const cursorPool = require("../lib/cursor-pool");
|
||||
const claudePool = require("../lib/claude-pool");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
|
||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
let server;
|
||||
let BASE;
|
||||
let MIA;
|
||||
let LEO;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: BASE,
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
MIA = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
LEO = (await auth.createUser("leo", "member-pass-2", "developer")).id;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare("DELETE FROM pool_groups").run();
|
||||
db.prepare("DELETE FROM account_pins").run();
|
||||
db.prepare("DELETE FROM account_choices").run();
|
||||
for (const dir of [cursorPool.poolDir(), claudePool.poolDir()]) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
for (const c of ["a", "b", "c"]) cursorPool.addAccount(c, { apiKey: KEY(c) });
|
||||
});
|
||||
|
||||
describe("pool account choice: the balancer", () => {
|
||||
it("runs the user's new panes on the account they chose, others unchanged", () => {
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "a");
|
||||
pins.setChoice(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "a");
|
||||
claudePool.addAccount("one", TOKEN("o"));
|
||||
claudePool.addAccount("two", TOKEN("t"));
|
||||
pins.setChoice(MIA, "claude", "two");
|
||||
assert.equal(claudePool.pickAccount(null, MIA).name, "two");
|
||||
pins.setChoice(MIA, "cursor", null);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "a", "automatic again");
|
||||
});
|
||||
|
||||
it("lets an admin's pin win, and refuses a choice while pinned", () => {
|
||||
pins.setChoice(MIA, "cursor", "b");
|
||||
pins.setPin(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
assert.throws(() => pins.setChoice(MIA, "cursor", "a"), { code: "ELOCKED" });
|
||||
pins.setPin(MIA, "cursor", null);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "b", "the choice comes back");
|
||||
});
|
||||
|
||||
it("allows only the group's accounts, and falls back once one leaves it", () => {
|
||||
const g = groups.createGroup("A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b", "c"]);
|
||||
assert.throws(() => pins.setChoice(MIA, "cursor", "a"), { code: "ENOACCOUNT" });
|
||||
assert.throws(() => pins.setChoice(MIA, "cursor", "zz"), { code: "ENOACCOUNT" });
|
||||
pins.setChoice(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
assert.equal(pins.preferredFor(MIA, "cursor"), null);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "b");
|
||||
});
|
||||
|
||||
it("drops a removed account's choices", () => {
|
||||
pins.setChoice(MIA, "cursor", "b");
|
||||
cursorPool.removeAccount("b");
|
||||
assert.equal(pins.choiceFor(MIA, "cursor"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pool account choice: /api/me/pool-accounts", () => {
|
||||
it("lists only the tools and accounts the user may use, never a token", async () => {
|
||||
const g = groups.createGroup("A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
const cookie = await login("mia", "member-pass-1");
|
||||
const r = await call("GET", "/api/me/pool-accounts", { cookie });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(
|
||||
r.body.tools.map((t) => t.tool),
|
||||
["cursor"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
r.body.tools[0].accounts.map((a) => a.name),
|
||||
["b"]
|
||||
);
|
||||
assert.ok(!JSON.stringify(r.body).includes(KEY("b")));
|
||||
});
|
||||
|
||||
it("chooses, goes back to automatic, refuses while pinned, and is audited", async () => {
|
||||
const cookie = await login("leo", "member-pass-2");
|
||||
let r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: "c" },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.choice, "c");
|
||||
assert.equal(r.body.switched, "next-session");
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "c");
|
||||
r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: "nope" },
|
||||
});
|
||||
assert.equal(r.status, 404);
|
||||
assert.equal(r.body.error.code, "ENOACCOUNT");
|
||||
r = await call("PUT", "/api/me/pool-accounts", { cookie, body: { tool: "x", account: "c" } });
|
||||
assert.equal(r.status, 400);
|
||||
pins.setPin(LEO, "cursor", "a");
|
||||
r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: "b" },
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ELOCKED");
|
||||
pins.setPin(LEO, "cursor", null);
|
||||
r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: null },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.choice, null);
|
||||
const audited = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path LIKE '%pool account%'")
|
||||
.get().n;
|
||||
assert.ok(audited >= 2);
|
||||
});
|
||||
|
||||
it("needs a login", async () => {
|
||||
const r = await call("PUT", "/api/me/pool-accounts", {
|
||||
body: { tool: "cursor", account: "a" },
|
||||
});
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
});
|
||||
@@ -782,6 +782,25 @@ describe("pty-run", () => {
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
|
||||
|
||||
// A named conversation: agy's `--conversation <id>`, not `--continue`.
|
||||
tmux.__reset();
|
||||
const named = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
laneId: 14,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
resumeSessionId: "70785283-bcc4-43a4-a1ea-02d844d5df5b",
|
||||
initialPrompt: "go on",
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(named), [
|
||||
at("agy"),
|
||||
"--conversation",
|
||||
"70785283-bcc4-43a4-a1ea-02d844d5df5b",
|
||||
"-i",
|
||||
"go on",
|
||||
]);
|
||||
});
|
||||
|
||||
// A catalog tool has no "resume THIS session" argv; starting it fresh under
|
||||
@@ -792,7 +811,7 @@ describe("pty-run", () => {
|
||||
pty.spawnRun({
|
||||
laneId: 15,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
tool: "septool",
|
||||
resumeSessionId: "abc12345",
|
||||
}),
|
||||
{ code: "EUNSUPPORTED" }
|
||||
|
||||
@@ -222,7 +222,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
assert.strictEqual(live.label, "Victor");
|
||||
assert.strictEqual(live.account, "victor@x.y");
|
||||
assert.strictEqual(live.ownerUuid, undefined);
|
||||
assert.strictEqual(live.plan, "max");
|
||||
assert.strictEqual(live.plan, "Max");
|
||||
assert.deepStrictEqual(
|
||||
claude.filter((t) => !t.active).map((t) => t.label),
|
||||
["Thắng"]
|
||||
|
||||
@@ -137,6 +137,47 @@ describe("server mode", () => {
|
||||
assert.equal(a.status, 404);
|
||||
});
|
||||
|
||||
it("lets any user press Update all AI tools and Update 9router, nothing else there", async () => {
|
||||
// The guard alone, called directly: a real request would start the job.
|
||||
const member = await loginAs("mia", "member-pass-1");
|
||||
const guard = (method, p) => {
|
||||
let passed = false;
|
||||
let status = null;
|
||||
const req = { method, path: p, headers: { cookie: member }, socket: {} };
|
||||
const res = {
|
||||
status(s) {
|
||||
status = s;
|
||||
return this;
|
||||
},
|
||||
json() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
auth.authGuard(req, res, () => {
|
||||
passed = true;
|
||||
});
|
||||
return passed ? "next" : status;
|
||||
};
|
||||
for (const p of [
|
||||
"/ai-tools/update-all",
|
||||
"/AI-Tools/update-all/",
|
||||
"/ai-proxies/9router/update",
|
||||
"/ai-routers/9router/update",
|
||||
]) {
|
||||
assert.equal(guard("POST", p), "next", p);
|
||||
}
|
||||
for (const [method, p] of [
|
||||
["POST", "/ai-tools/claude/update"],
|
||||
["POST", "/ai-tools/claude/install"],
|
||||
["PUT", "/ai-tools/claude"],
|
||||
["PUT", "/ai-tools/omp-sharing"],
|
||||
["DELETE", "/ai-tools/update-all"],
|
||||
["POST", "/ai-proxies/9router/update/x"],
|
||||
]) {
|
||||
assert.equal(guard(method, p), 403, `${method} ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes hooks from this machine only", async () => {
|
||||
const remote = await call("POST", "/api/hooks/event", {
|
||||
body: {},
|
||||
|
||||
@@ -209,6 +209,12 @@ describe("server mode: list and aggregate routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("names each listed session's owner: a stamped lane, else a run's lane", async () => {
|
||||
const { sessions } = await get("/api/sessions", "boss");
|
||||
const owners = Object.fromEntries(sessions.map((s) => [s.id, s.owner_name]));
|
||||
assert.deepEqual(owners, { sA: "alice", sA2: "alice", sB: "bob", sHost: null });
|
||||
});
|
||||
|
||||
it("keeps a member out of another's rows through filters and POST", async () => {
|
||||
// A filter naming another's session is refused, not answered empty.
|
||||
assert.equal((await call("GET", "/api/events?session_id=sB", { as: "alice" })).status, 404);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @file server-mode-user-details.test.js
|
||||
* @description `GET /api/users/:id/details` — the Administration page's user
|
||||
* detail: the account, its workspaces and live panes, newest sessions, spend
|
||||
* per day / AI tool / model, the audit entries naming it, and per pool tool
|
||||
* the accounts it may run on with its own share of each. Staff only, 404 for
|
||||
* an unknown user, never a token or a password hash.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-details-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
// The quota read would go to the network.
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const TOKEN = `sk-ant-oat01-${"a".repeat(40)}`;
|
||||
const ids = {};
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
const lane = db
|
||||
.prepare("INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)")
|
||||
.run(ids.mia).lastInsertRowid;
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sm', 'mia work', 'active', '/w/mia', ?)"
|
||||
).run(lane);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('sc', 'mia codex', 'completed', '/w/mia', ?, 'codex')"
|
||||
).run(lane);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
for (const [sid, tokens] of [
|
||||
["sm", 1000000],
|
||||
["sc", 500000],
|
||||
]) {
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||
VALUES (?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(sid, tokens);
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(sid, hour, tokens);
|
||||
}
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const add = await call("POST", "/api/accounts", {
|
||||
cookie: boss,
|
||||
body: { tool: "claude", name: "team-1", token: TOKEN, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(add.status, 201);
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, account)
|
||||
VALUES ('agenthub-lane-1', 'sm', 'interactive', '/w/mia', 'running', ?, 'claude', 'team-1')`
|
||||
).run(lane);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("GET /api/users/:id/details", () => {
|
||||
it("shows staff one user's workspaces, panes, sessions, spend and pool share", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("GET", `/api/users/${ids.mia}/details`, { cookie: boss });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
const d = r.body;
|
||||
assert.equal(d.user.username, "mia");
|
||||
assert.equal(d.sessions.total, 2);
|
||||
assert.equal(d.sessions.active, 1);
|
||||
assert.deepEqual(
|
||||
d.workspaces.map((w) => [w.title, w.sessions, w.livePanes]),
|
||||
[["Mia", 2, 1]]
|
||||
);
|
||||
assert.deepEqual(
|
||||
d.panes.map((p) => [p.id, p.account]),
|
||||
[["agenthub-lane-1", "team-1"]]
|
||||
);
|
||||
assert.deepEqual(d.recentSessions.map((s) => s.id).sort(), ["sc", "sm"]);
|
||||
assert.ok(d.recentSessions.every((s) => s.tokens > 0 && s.cost > 0));
|
||||
|
||||
assert.equal(d.usage.last30d.tokens, 1500000);
|
||||
assert.equal(d.usage.today.tokens, 1500000);
|
||||
assert.equal(d.usage.daily.length, 30);
|
||||
assert.equal(d.usage.daily.at(-1).tokens, 1500000, "today is the last bar");
|
||||
assert.deepEqual(
|
||||
d.usage.byTool.map((b) => [b.key, b.tokens, b.sessions]),
|
||||
[
|
||||
["claude", 1000000, 1],
|
||||
["codex", 500000, 1],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
d.usage.byModel.map((b) => b.key),
|
||||
["claude-sonnet-4-5-20250929"]
|
||||
);
|
||||
|
||||
const team = d.pool.claude.accounts.find((a) => a.name === "team-1");
|
||||
assert.equal(team.allowed, true);
|
||||
assert.equal(team.userRuns7d, 1);
|
||||
assert.equal(team.userLivePanes, 1);
|
||||
assert.equal(team.userTokens7d, 1000000, "only the hours its run held the session");
|
||||
assert.equal(d.pool.claude.pin, null);
|
||||
assert.ok(!JSON.stringify(d).includes(TOKEN), "never a token");
|
||||
assert.ok(!JSON.stringify(d).includes("scrypt"), "never a password hash");
|
||||
});
|
||||
|
||||
it("lists the audit entries naming the user", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
await call("GET", "/api/sessions/sm", { cookie: boss });
|
||||
const d = (await call("GET", `/api/users/${ids.mia}/details`, { cookie: boss })).body;
|
||||
assert.ok(d.audit.some((a) => a.reader === "boss" && a.owner === "mia"));
|
||||
});
|
||||
|
||||
it("is staff only and 404s an unknown user", async () => {
|
||||
const ana = await login("ana", "analyst-pass-1");
|
||||
assert.equal((await call("GET", `/api/users/${ids.mia}/details`, { cookie: ana })).status, 200);
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
assert.equal((await call("GET", `/api/users/${ids.mia}/details`, { cookie: mia })).status, 403);
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
assert.equal((await call("GET", "/api/users/9999/details", { cookie: boss })).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@
|
||||
* workspaces, sessions and recent spend to an admin/analyst and refuses a
|
||||
* member; an admin/analyst reading a member's session is written to the audit
|
||||
* log (`GET /api/users/audit`), while a member reading their own, or a read of
|
||||
* an ownerless session, is not.
|
||||
* an ownerless session, is not; and `GET /api/host` (System Health's machine)
|
||||
* answers every signed-in user, a developer included.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -101,6 +102,18 @@ describe("staff views", () => {
|
||||
assert.equal(member.status, 403);
|
||||
});
|
||||
|
||||
it("shows every signed-in user the machine (System Health), names included", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const r = await call("/api/host", mia);
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.body.hostname);
|
||||
if (r.body.usage) {
|
||||
const g = r.body.usage.groups.find((x) => x.kind === "user" && x.username === "mia");
|
||||
assert.ok(g && "displayName" in g, "a user's group carries their display name");
|
||||
}
|
||||
assert.equal((await call("/api/host/history?range=1h", mia)).status, 200);
|
||||
});
|
||||
|
||||
it("audits a staff read of a member's session, and nothing else", async () => {
|
||||
const admin = await login("boss", "admin-pass-1");
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* @file user-quota.test.js
|
||||
* @description Spend quotas per user (server/lib/user-quota.js): the default
|
||||
* and a user's own setting, spend over a day and a sliding 5 h window, the
|
||||
* admin routes (password again), `/api/me/quota`, the EQUOTA refusal of a new
|
||||
* pane, the sweep's flag files and the panes it ends, the omp gateway keys it
|
||||
* leaves out, and the hook handler refusing a prompt from the flag alone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "user-quota-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.DASHBOARD_QUOTA_SWEEP_MS = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const quota = require("../lib/user-quota");
|
||||
const runs = require("../lib/pty-run");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const ids = {};
|
||||
let laneId;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
|
||||
|
||||
/** `tokens` output tokens of Sonnet 4.5 ($15 / M) booked to `sid` in the hour of `at`. */
|
||||
function spend(sid, tokens, at = Date.now()) {
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||
VALUES (?, 'claude-sonnet-4-5-20250929', ?)
|
||||
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
|
||||
).run(sid, tokens);
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(sid, hourOf(at), tokens);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
ids.lou = (await auth.createUser("lou", "member-pass-2", "developer")).id;
|
||||
laneId = Number(
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)"
|
||||
)
|
||||
.run(ids.mia).lastInsertRowid
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sm', 'mia work', 'active', '/w/mia', ?, ?)"
|
||||
).run(laneId, new Date().toISOString());
|
||||
spend("sm", 1_000_000); // $15 now
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
it("a user without a row of their own gets the default; unlimited and custom override it", () => {
|
||||
assert.equal(quota.effective(ids.mia), null);
|
||||
quota.setDefault({ limit_usd: 10, period: "day", tz_offset: -420 });
|
||||
assert.deepEqual(quota.effective(ids.mia), {
|
||||
source: "default",
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
tz_offset: -420,
|
||||
});
|
||||
quota.setUserQuota(ids.mia, { mode: "unlimited" });
|
||||
assert.equal(quota.effective(ids.mia), null);
|
||||
assert.deepEqual(quota.getOverride(ids.mia), { mode: "unlimited" });
|
||||
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 50, period: "5h" });
|
||||
assert.equal(quota.effective(ids.mia).source, "user");
|
||||
assert.equal(quota.effective(ids.mia).limit_usd, 50);
|
||||
quota.setUserQuota(ids.mia, { mode: "default" });
|
||||
assert.equal(quota.effective(ids.mia).source, "default");
|
||||
quota.setDefault(null);
|
||||
assert.equal(quota.effective(ids.mia), null);
|
||||
});
|
||||
|
||||
it("refuses a bad limit, period or mode", () => {
|
||||
for (const bad of [
|
||||
{ mode: "custom", limit_usd: 0, period: "day" },
|
||||
{ mode: "custom", limit_usd: 5, period: "week" },
|
||||
{ mode: "custom", limit_usd: 5, period: "day", tz_offset: 9999 },
|
||||
{ mode: "nope" },
|
||||
]) {
|
||||
assert.throws(() => quota.setUserQuota(ids.mia, bad), { code: "EBADQUOTA" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("spend windows", () => {
|
||||
it("a day starts at the zone's local midnight and resets at the next one", () => {
|
||||
const now = Date.parse("2026-10-28T03:30:00Z"); // 10:30 at UTC+7
|
||||
const w = quota.windowOf({ period: "day", tz_offset: -420 }, now);
|
||||
assert.equal(w.since, "2026-10-27T17");
|
||||
assert.equal(new Date(w.nextReset).toISOString(), "2026-10-28T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("5 h is the current hour and the four before it; it opens as the oldest hours leave", () => {
|
||||
const now = Date.parse("2026-10-28T10:20:00Z");
|
||||
assert.equal(quota.windowOf({ period: "5h" }, now).since, "2026-10-28T06");
|
||||
const byHour = new Map([
|
||||
["2026-10-28T06", 4],
|
||||
["2026-10-28T08", 3],
|
||||
["2026-10-28T10", 2],
|
||||
]);
|
||||
// 9 spent, limit 6: 06's $4 leaving brings it to 5 < 6 → at 06 + 5 h.
|
||||
assert.equal(
|
||||
new Date(quota.slidingReset(byHour, 9, 6)).toISOString(),
|
||||
"2026-10-28T11:00:00.000Z"
|
||||
);
|
||||
// limit 3: everything up to 08 must go → 08 + 5 h.
|
||||
assert.equal(
|
||||
new Date(quota.slidingReset(byHour, 9, 3)).toISOString(),
|
||||
"2026-10-28T13:00:00.000Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("counts the spend of the sessions a user owns, inside the window only", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('old', 'old', 'completed', '/w/mia', ?, ?)"
|
||||
).run(laneId, new Date().toISOString());
|
||||
spend("old", 1_000_000, Date.now() - 8 * HOUR); // outside 5 h
|
||||
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 100, period: "5h" });
|
||||
const s = quota.statusFor(ids.mia);
|
||||
assert.equal(s.spend, 15);
|
||||
assert.equal(s.exceeded, false);
|
||||
assert.equal(s.resetsAt, null);
|
||||
assert.equal(quota.statusFor(ids.lou), null);
|
||||
quota.setUserQuota(ids.mia, { mode: "default" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("routes", () => {
|
||||
it("an admin sets the default with their password; the list shows each user's quota", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const noPass = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: 10, period: "day" } },
|
||||
});
|
||||
assert.equal(noPass.status, 403);
|
||||
const bad = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: -1, period: "day" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADQUOTA");
|
||||
const ok = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: 10, period: "day" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
assert.equal(ok.body.quotaDefault.limit_usd, 10);
|
||||
|
||||
const list = await call("GET", "/api/users", { cookie: boss });
|
||||
assert.equal(list.body.quotaDefault.limit_usd, 10);
|
||||
const mia = list.body.users.find((u) => u.id === ids.mia);
|
||||
assert.deepEqual(mia.quotaSetting, { mode: "default" });
|
||||
assert.equal(mia.quota.spend, 15);
|
||||
assert.equal(mia.quota.exceeded, true);
|
||||
assert.ok(mia.quota.resetsAt);
|
||||
});
|
||||
|
||||
it("a developer reads their own quota and may not change any", async () => {
|
||||
const me = await login("mia", "member-pass-1");
|
||||
const r = await call("GET", "/api/me/quota", { cookie: me });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.quota.exceeded, true);
|
||||
const put = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: me,
|
||||
body: { quota: null, currentPassword: "member-pass-1" },
|
||||
});
|
||||
assert.equal(put.status, 403);
|
||||
});
|
||||
|
||||
it("PATCH /api/users/:id {quota} overrides one user's, audited", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { quota: { mode: "custom", limit_usd: 40, period: "day" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.equal(quota.statusFor(ids.mia).exceeded, false);
|
||||
const audit = db
|
||||
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(ids.mia);
|
||||
assert.match(audit.path, /spend quota \$40\/day/);
|
||||
const bad = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { quota: { mode: "lots" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
quota.setUserQuota(ids.mia, { mode: "default" }); // back to the $10 default: over
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforcement", () => {
|
||||
it("refuses a new pane of an owner over quota with EQUOTA and a reason", () => {
|
||||
assert.throws(
|
||||
() => quota.assertSpawnAllowed(ids.mia),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EQUOTA");
|
||||
assert.match(err.message, /\$15\.00 of \$10\.00 per day/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
quota.assertSpawnAllowed(ids.lou); // under the default: no throw
|
||||
quota.assertSpawnAllowed(null); // a workspace with no owner
|
||||
});
|
||||
|
||||
it("gives a pane its owner's flag file in server mode only", () => {
|
||||
const env = quota.flagEnv(ids.mia, null);
|
||||
assert.match(env.AGENTHUB_QUOTA_FILE, /user-quota[/\\]u\d+\.json$/);
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
assert.deepEqual(quota.flagEnv(ids.mia, null), {});
|
||||
quota.assertSpawnAllowed(ids.mia); // local mode never refuses
|
||||
assert.equal(quota.statusAll().size, 0);
|
||||
} finally {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
}
|
||||
});
|
||||
|
||||
it("sweep: writes the flag, ends the panes no hook can stop, keeps Claude's, clears on raise", () => {
|
||||
const realList = runs.listRuns;
|
||||
const realKill = runs.killRun;
|
||||
const killed = [];
|
||||
runs.listRuns = () => [
|
||||
{ id: `agenthub-lane-${laneId}`, laneId, status: "running", tool: "claude" },
|
||||
{ id: `agenthub-lane-${laneId}-1`, laneId, status: "running", tool: "cursor" },
|
||||
];
|
||||
runs.killRun = (id) => killed.push(id) > 0;
|
||||
try {
|
||||
quota.__reset();
|
||||
const r = quota.sweep();
|
||||
assert.deepEqual(r.blocked, [ids.mia]);
|
||||
assert.deepEqual(killed, [`agenthub-lane-${laneId}-1`]);
|
||||
const file = quota.flagEnv(ids.mia, null).AGENTHUB_QUOTA_FILE;
|
||||
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(flag.blocked, true);
|
||||
assert.match(flag.message, /quota is used up/);
|
||||
assert.ok(Date.parse(flag.until) > Date.now());
|
||||
// Lou never went over: no file is written for them.
|
||||
assert.equal(fs.existsSync(quota.flagEnv(ids.lou, null).AGENTHUB_QUOTA_FILE), false);
|
||||
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}-3`), true);
|
||||
|
||||
quota.setUserQuota(ids.mia, { mode: "unlimited" });
|
||||
killed.length = 0;
|
||||
quota.sweep();
|
||||
assert.deepEqual(killed, []);
|
||||
assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).blocked, false);
|
||||
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}`), false);
|
||||
} finally {
|
||||
runs.listRuns = realList;
|
||||
runs.killRun = realKill;
|
||||
quota.setUserQuota(ids.mia, { mode: "default" });
|
||||
quota.__reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("the omp gateway loses a blocked owner's key, never the queue's", () => {
|
||||
const { clientsFor } = require("../lib/omp-share");
|
||||
const t = { standby: ["s1"], used: { a: { hash: "ha" }, b: { hash: "hb" } } };
|
||||
const out = clientsFor(t, (id) => id === "a");
|
||||
assert.equal(out.a, undefined);
|
||||
assert.deepEqual(out.b, { tokenSha256: "hb" });
|
||||
assert.equal(Object.keys(out).filter((k) => k.startsWith("standby-")).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook handler", () => {
|
||||
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
|
||||
const flag = path.join(TMP, "flag.json");
|
||||
const run = (args, file) =>
|
||||
spawnSync(process.execPath, [HANDLER, ...args], {
|
||||
input: "{}",
|
||||
env: { ...process.env, AGENTHUB_QUOTA_FILE: file, CLAUDE_DASHBOARD_PORT: "1" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
it("refuses a Claude Code or Codex prompt while the flag says blocked", () => {
|
||||
fs.writeFileSync(
|
||||
flag,
|
||||
JSON.stringify({ blocked: true, until: "2999-01-01T00:00:00Z", message: "quota used up" })
|
||||
);
|
||||
for (const args of [["UserPromptSubmit"], ["UserPromptSubmit", "codex"]]) {
|
||||
const r = run(args, flag);
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /quota used up/);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets everything else through: other events, Antigravity, an expired or missing flag", () => {
|
||||
assert.equal(run(["PreToolUse"], flag).status, 0);
|
||||
assert.equal(run(["UserPromptSubmit", "antigravity"], flag).status, 0);
|
||||
assert.equal(run(["UserPromptSubmit"], path.join(TMP, "none.json")).status, 0);
|
||||
fs.writeFileSync(flag, JSON.stringify({ blocked: true, until: "2000-01-01T00:00:00Z" }));
|
||||
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
|
||||
fs.writeFileSync(flag, "{not json");
|
||||
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
|
||||
`resume` when a start carries `resumeSessionId`, and only for an id that starts
|
||||
with a letter or digit. A tool without it (and without a `launcher`) refuses a
|
||||
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
|
||||
Built-in: omp and Cursor (`--resume`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
|
||||
Built-in: omp and Cursor (`--resume`), Antigravity (`--conversation`: an agy session's id is its conversation id). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
|
||||
|
||||
## Install / update (A2)
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--conversation"
|
||||
],
|
||||
"bypass": [
|
||||
"--dangerously-skip-permissions"
|
||||
],
|
||||
|
||||
@@ -1972,6 +1972,18 @@ db.exec(`
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
`);
|
||||
// A user's OWN choice of one pool account per tool (Settings → AI tools →
|
||||
// Pool account, account-pins.js `setChoice`): below an admin's pin, above the
|
||||
// balancer, and only among the accounts the user's group may use.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS account_choices (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
chosen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
`);
|
||||
// Account pool groups (server/lib/pool-groups.js): a team's members run on the
|
||||
// team's own pool accounts. One group per user; an account, named like a pin,
|
||||
// may serve several groups. Accounts no group lists are the shared pool.
|
||||
@@ -1992,6 +2004,23 @@ db.exec(`
|
||||
PRIMARY KEY (group_id, tool, account)
|
||||
);
|
||||
`);
|
||||
// Spend quotas (server/lib/user-quota.js): user_id 0 is the default every
|
||||
// user without a row of their own gets; a row whose limit_usd is NULL means
|
||||
// "no limit". No foreign key (0 is no user) — a deleted user's row goes with
|
||||
// the trigger. Additive.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_quotas (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
limit_usd REAL,
|
||||
period TEXT NOT NULL DEFAULT 'day' CHECK(period IN ('day','5h')),
|
||||
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS trg_user_quotas_user_gone AFTER DELETE ON users
|
||||
BEGIN
|
||||
DELETE FROM user_quotas WHERE user_id = OLD.id;
|
||||
END;
|
||||
`);
|
||||
// A database from before the Cursor pool: its `tool` CHECK allowed only
|
||||
// claude/codex. Rebuilt with every pin kept.
|
||||
{
|
||||
|
||||
@@ -872,6 +872,26 @@ function startBackgroundServices() {
|
||||
}, POOL_REFRESH_MS);
|
||||
if (poolTimer.unref) poolTimer.unref();
|
||||
}
|
||||
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
|
||||
// flag files their hooks read, the panes no hook can stop. Off with
|
||||
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
|
||||
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
|
||||
if (
|
||||
Number.isFinite(QUOTA_SWEEP_MS) &&
|
||||
QUOTA_SWEEP_MS > 0 &&
|
||||
require("./lib/auth").isServerMode()
|
||||
) {
|
||||
const quotaSweep = () => {
|
||||
try {
|
||||
require("./lib/user-quota").sweep();
|
||||
} catch (err) {
|
||||
console.warn("user quota sweep failed:", err.message);
|
||||
}
|
||||
};
|
||||
const quotaTimer = setInterval(quotaSweep, QUOTA_SWEEP_MS);
|
||||
if (quotaTimer.unref) quotaTimer.unref();
|
||||
setImmediate(quotaSweep);
|
||||
}
|
||||
// Another dashboard on the same database (hooks reach only one of them)
|
||||
// moving a lane: re-broadcast it here. One pragma per beat when nothing
|
||||
// changed — and only while such a dashboard runs at all: alone on its data
|
||||
@@ -1332,6 +1352,8 @@ if (require.main === module) {
|
||||
require("./lib/omp-share")
|
||||
.start()
|
||||
.catch((err) => console.warn("omp share failed to start:", err.message));
|
||||
// System Health's CPU/memory history (in memory; Linux only, inside).
|
||||
require("./lib/host-stats").startHistory();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(listenFailure(err, PORT));
|
||||
|
||||
+105
-3
@@ -13,6 +13,14 @@
|
||||
*
|
||||
* `setAccountUsers` is the Users page's Edit of one account: who runs on it.
|
||||
*
|
||||
* A user may also CHOOSE an account of their own (`account_choices`, Settings
|
||||
* → AI tools → Pool account, `/api/me/pool-accounts`): `preferredFor` — the
|
||||
* balancers' question — answers the admin's pin first, then that choice. A
|
||||
* choice is only ever among the accounts the user's group may use
|
||||
* (pool-groups.js `allowed`), checked when it is made and again at every
|
||||
* spawn, so an account later taken from the group falls back to the balancer.
|
||||
* A pinned user cannot choose (`ELOCKED`): the admin's pin is the decision.
|
||||
*
|
||||
* Server mode only: in local mode there are no users and nothing is pinned.
|
||||
* Removing the account drops its pins (`dropAccount`); deleting the user drops
|
||||
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
|
||||
@@ -45,6 +53,95 @@ function pinFor(ownerId, tool) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The account `userId` chose for `tool` themselves, or null. */
|
||||
function choiceFor(userId, tool) {
|
||||
if (!require("./auth").isServerMode() || !Number.isInteger(userId)) return null;
|
||||
try {
|
||||
return (
|
||||
db()
|
||||
.prepare("SELECT account FROM account_choices WHERE user_id = ? AND tool = ?")
|
||||
.get(userId, tool)?.account ?? null
|
||||
);
|
||||
} catch {
|
||||
return null; // a spawn never fails over a choice
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account the balancer should prefer for `ownerId`'s new `tool` pane: the
|
||||
* admin's pin, else the owner's own choice while their group still allows it.
|
||||
*/
|
||||
function preferredFor(ownerId, tool) {
|
||||
const pin = pinFor(ownerId, tool);
|
||||
if (pin) return pin;
|
||||
const choice = choiceFor(ownerId, tool);
|
||||
if (!choice) return null;
|
||||
return require("./pool-groups").allowed(ownerId, tool)(choice) ? choice : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `userId`'s own choice of `account` for `tool`, or back to the balancer
|
||||
* (`account` null). Refused while an admin pins them (`ELOCKED`), and for an
|
||||
* account not in the pool or not their group's (`ENOACCOUNT`).
|
||||
*/
|
||||
function setChoice(userId, tool, account) {
|
||||
validTool(tool);
|
||||
if (!Number.isInteger(userId)) throw err("ENOUSER", `no user ${userId}`);
|
||||
if (pinFor(userId, tool)) {
|
||||
throw err("ELOCKED", `an admin has pinned your ${tool} sessions to one account`);
|
||||
}
|
||||
if (account == null || account === "") {
|
||||
db().prepare("DELETE FROM account_choices WHERE user_id = ? AND tool = ?").run(userId, tool);
|
||||
return null;
|
||||
}
|
||||
const name = String(account);
|
||||
if (!poolOf(tool).hasAccount(name) || !require("./pool-groups").allowed(userId, tool)(name)) {
|
||||
throw err("ENOACCOUNT", `no ${tool} account ${name} you may use`);
|
||||
}
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO account_choices (user_id, tool, account) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, tool) DO UPDATE SET account = excluded.account,
|
||||
chosen_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
|
||||
)
|
||||
.run(userId, tool, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Settings → AI tools → Pool account shows `userId`: per tool with at
|
||||
* least one account they may use, those accounts (never a token), the admin's
|
||||
* pin and their own choice.
|
||||
*/
|
||||
function myAccounts(userId) {
|
||||
const groups = require("./pool-groups");
|
||||
const out = [];
|
||||
for (const tool of TOOLS) {
|
||||
let status = [];
|
||||
try {
|
||||
status = poolOf(tool).poolStatus();
|
||||
} catch {
|
||||
status = [];
|
||||
}
|
||||
const ok = groups.allowed(userId, tool);
|
||||
const pin = pinFor(userId, tool);
|
||||
const accounts = status
|
||||
.filter((a) => ok(a.name))
|
||||
.map((a) => ({
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
email: a.email ?? null,
|
||||
expired: Boolean(a.expired),
|
||||
quotaPercent: a.quotaPercent ?? null,
|
||||
full: Boolean(a.full),
|
||||
}));
|
||||
if (accounts.length === 0) continue;
|
||||
const choice = choiceFor(userId, tool);
|
||||
out.push({ tool, pin, choice: choice && ok(choice) ? choice : null, accounts });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every pin of `tool`, with its user's name: `[{userId, username, account, pinnedAt}]`. */
|
||||
function listPins(tool) {
|
||||
return db()
|
||||
@@ -120,23 +217,24 @@ function setAccountUsers(tool, account, userIds) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a pin take effect now where it can: a Claude login is written into
|
||||
* Make a pin — or the user's own choice — take effect now where it can: a Claude login is written into
|
||||
* the user's home at once (claude-pool `switchHome`), so their running panes
|
||||
* move to it without a restart. Anything else goes in at the next spawn.
|
||||
* Answers what happened (see `switchHome`).
|
||||
*/
|
||||
async function applyPin(userId, tool) {
|
||||
const pin = pinFor(userId, tool);
|
||||
const pin = preferredFor(userId, tool);
|
||||
if (!pin || tool !== "claude") return "next-session";
|
||||
const user = db().prepare("SELECT username FROM users WHERE id = ?").get(userId);
|
||||
const member = user ? require("./run-as").accountForUser(user.username) : null;
|
||||
return poolOf(tool).switchHome(member, pin);
|
||||
}
|
||||
|
||||
/** A removed account takes its pins — and its place in every group — with it. */
|
||||
/** A removed account takes its pins, choices — and its place in every group — with it. */
|
||||
function dropAccount(tool, account) {
|
||||
try {
|
||||
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
|
||||
db().prepare("DELETE FROM account_choices WHERE tool = ? AND account = ?").run(tool, account);
|
||||
} catch {
|
||||
/* no database (a bare CLI test): nothing pinned */
|
||||
}
|
||||
@@ -147,6 +245,10 @@ module.exports = {
|
||||
TOOLS,
|
||||
poolOf,
|
||||
pinFor,
|
||||
choiceFor,
|
||||
preferredFor,
|
||||
setChoice,
|
||||
myAccounts,
|
||||
listPins,
|
||||
setPin,
|
||||
setAccountUsers,
|
||||
|
||||
+12
-3
@@ -76,9 +76,18 @@ const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
||||
const SNAPSHOT_RE = /^[A-Za-z0-9_-]{32}$/;
|
||||
// A skill may not carry a login or a key: refused, never copied.
|
||||
const SECRET_NAME_RE =
|
||||
/(^\.env(\..*)?$|credential|secret|password|token|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|\.netrc$)/i;
|
||||
// A skill may not carry a login or a key: refused, never copied. A key file's
|
||||
// name alone proves it; a word like "token" does not — `design-tokens.md` or
|
||||
// `tokenizer.py` is skill content — so the word rule skips docs and source.
|
||||
const SECRET_FILE_RE =
|
||||
/(^\.env(\..*)?$|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|^\.?netrc$|^\.credentials\.json$|^\.git-credentials$)/i;
|
||||
const SECRET_WORD_RE = /(credential|secret|password|token)/i;
|
||||
const CONTENT_EXT_RE =
|
||||
/\.(md|mdx|markdown|rst|html?|css|js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|bash|zsh|ps1|lua|sql|svg|png|jpe?g|gif|webp)$/i;
|
||||
const SECRET_NAME_RE = {
|
||||
test: (name) =>
|
||||
SECRET_FILE_RE.test(name) || (SECRET_WORD_RE.test(name) && !CONTENT_EXT_RE.test(name)),
|
||||
};
|
||||
|
||||
const root = () => path.join(getDataDir(), "artifacts");
|
||||
const installsOffFlag = () => path.join(root(), "installs-off");
|
||||
|
||||
+27
-5
@@ -13,7 +13,8 @@
|
||||
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
|
||||
* its own bearer token), and everything else only with a valid login.
|
||||
* Writes under `ADMIN_WRITE_PREFIXES` — the host account's own
|
||||
* configuration — are an `admin`'s; an `analyst` and a `developer` write
|
||||
* configuration — are an `admin`'s (bar `MEMBER_WRITE_EXCEPTIONS`: Update
|
||||
* all AI tools and Update 9router); an `analyst` and a `developer` write
|
||||
* only their own rows (`scope.js`).
|
||||
* - Accounts are made from the host's shell (`agenthub users …`) or by an
|
||||
* admin on the Users page (`server/routes/users.js`), where every change
|
||||
@@ -80,11 +81,27 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
"/accounts",
|
||||
// Alert rules and budgets fire for every user's sessions.
|
||||
"/alerts/rules",
|
||||
// Stopping, updating and restarting the host's 9router (both mounts).
|
||||
// Stopping and restarting the host's 9router (both mounts) — its Update is
|
||||
// in MEMBER_WRITE_EXCEPTIONS.
|
||||
"/ai-proxies/9router",
|
||||
"/ai-routers/9router",
|
||||
];
|
||||
|
||||
/**
|
||||
* The writes under `ADMIN_WRITE_PREFIXES` every signed-in user may still make,
|
||||
* matched exactly (method + lower-cased path, a trailing slash ignored): the
|
||||
* two "bring the host's tools up to date" buttons. Each runs only what a
|
||||
* release catalog file / the fixed 9router job names — nothing from the
|
||||
* request — so letting a member press it cannot widen what it runs. Install,
|
||||
* a single tool's update, enable/default and every other `/ai-tools` write
|
||||
* stay an admin's.
|
||||
*/
|
||||
const MEMBER_WRITE_EXCEPTIONS = new Set([
|
||||
"POST /ai-tools/update-all",
|
||||
"POST /ai-proxies/9router/update",
|
||||
"POST /ai-routers/9router/update",
|
||||
]);
|
||||
|
||||
/**
|
||||
* API prefixes a developer may not reach at all, reads included: the host's own
|
||||
* configuration and logs, and every view across all users (export, metrics,
|
||||
@@ -104,8 +121,8 @@ const STAFF_ONLY_PREFIXES = [
|
||||
"/alerts/rules",
|
||||
"/users",
|
||||
"/accounts",
|
||||
// The machine's hardware and every user's CPU/memory share.
|
||||
"/host",
|
||||
// `/host` (the machine and each user's CPU/memory share) is NOT here: the
|
||||
// Dashboard's System Health tab shows it to every signed-in user.
|
||||
];
|
||||
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
||||
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
||||
@@ -738,7 +755,11 @@ function authGuard(req, res, next) {
|
||||
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
|
||||
}
|
||||
// An analyst writes like a developer: only their own rows (scope.js).
|
||||
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
|
||||
if (
|
||||
user.role !== "admin" &&
|
||||
ADMIN_WRITE_PREFIXES.some(under) &&
|
||||
!MEMBER_WRITE_EXCEPTIONS.has(`${req.method} ${lp.replace(/\/+$/, "")}`)
|
||||
) {
|
||||
return deny(res, 403, "EADMIN", "only an admin may change this");
|
||||
}
|
||||
return next();
|
||||
@@ -771,6 +792,7 @@ module.exports = {
|
||||
COOKIE,
|
||||
ROLES,
|
||||
ADMIN_WRITE_PREFIXES,
|
||||
MEMBER_WRITE_EXCEPTIONS,
|
||||
STAFF_ONLY_PREFIXES,
|
||||
apiPath,
|
||||
newPaneToken,
|
||||
|
||||
@@ -179,6 +179,47 @@ function readActiveAuth() {
|
||||
return { oauth: (creds && creds.claudeAiOauth) || null, account, userID, keychain };
|
||||
}
|
||||
|
||||
const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
|
||||
|
||||
/**
|
||||
* The plan's name as the account's owner knows it. `subscriptionType` alone is
|
||||
* too coarse — it says `team` for both seat kinds, and it is written at sign-in,
|
||||
* so an account upgraded from Pro to Max keeps saying `pro` — while the rate
|
||||
* limit tier (`default_claude_max_5x`) and the seat (`team_standard`) carry the
|
||||
* real one. "Max 5x", "Team Premium", "Team Standard", else the type itself.
|
||||
*/
|
||||
function planLabel(oauth, account) {
|
||||
const sub = String(oauth?.subscriptionType || "").toLowerCase();
|
||||
// The token's tier, else the user's own: on a Team account the token may
|
||||
// carry the organization's tier while the member's seat is the larger one.
|
||||
const tier = `${oauth?.rateLimitTier || ""} ${account?.userRateLimitTier || ""}`;
|
||||
const seat = String(account?.seatTier || "").toLowerCase();
|
||||
const max = /max_(\d+)x/i.exec(tier);
|
||||
const org = String(account?.organizationType || "");
|
||||
const kind =
|
||||
sub === "team" || sub === "enterprise"
|
||||
? sub
|
||||
: /team/.test(org) || seat.startsWith("team")
|
||||
? "team"
|
||||
: /enterprise/.test(org)
|
||||
? "enterprise"
|
||||
: null;
|
||||
if (kind) {
|
||||
// A premium seat lifts the member onto a Max-sized limit; the seat name
|
||||
// says so for `team_premium`, the tier for `team_tier_1`.
|
||||
const level = /premium/.test(seat)
|
||||
? "Premium"
|
||||
: /standard/.test(seat)
|
||||
? "Standard"
|
||||
: max
|
||||
? "Premium"
|
||||
: null;
|
||||
return [cap(kind), level].filter(Boolean).join(" ");
|
||||
}
|
||||
if (max) return `Max ${max[1]}x`;
|
||||
return sub ? cap(sub) : null;
|
||||
}
|
||||
|
||||
/** Token-free description of an account, safe to send to the browser. */
|
||||
function identityOf({ oauth, account }) {
|
||||
return {
|
||||
@@ -188,6 +229,8 @@ function identityOf({ oauth, account }) {
|
||||
organization: (account && account.organizationName) || null,
|
||||
seatTier: (account && account.seatTier) || null,
|
||||
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
||||
/** The readable plan (`planLabel`) — what the UI shows. */
|
||||
plan: planLabel(oauth, account),
|
||||
/** When the stored access token expires; a switch still works after that
|
||||
* (the refresh token is stored too), it just refreshes on first use. */
|
||||
expiresAt: (oauth && oauth.expiresAt) || null,
|
||||
@@ -544,6 +587,7 @@ module.exports = {
|
||||
writeProfile,
|
||||
liveCredentialsFor,
|
||||
readActiveAuth,
|
||||
planLabel,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
@@ -435,6 +435,8 @@ function poolStatus() {
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
|
||||
// "Max 5x", "Team Premium"…; a setup-token says nothing of its plan.
|
||||
plan: a.kind === "login" ? require("./cc-profiles").planLabel(a.oauth, a.account) : null,
|
||||
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
|
||||
addedAt: a.addedAt,
|
||||
expiresAt: expiresAt(a),
|
||||
@@ -507,9 +509,9 @@ function pick(member, ownerId = null) {
|
||||
// in their home stays: one .credentials.json per home.
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "claude");
|
||||
usable = usable.filter((a) => allowed(a.name));
|
||||
// A pinned token goes in by env and wins outright; a pinned login waits
|
||||
// while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "claude");
|
||||
// A pinned (or the owner's own chosen) token goes in by env and wins
|
||||
// outright; a pinned login waits while another login holds this home.
|
||||
const pin = require("./account-pins").preferredFor(ownerId, "claude");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
|
||||
if (live) return live;
|
||||
|
||||
@@ -245,6 +245,7 @@ function poolStatus() {
|
||||
return {
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
plan: a.kind === "chatgpt" ? planOf(a.auth) : null,
|
||||
addedAt: a.addedAt,
|
||||
livePanes: livePanes(a.name),
|
||||
...usageOf(a.name),
|
||||
@@ -285,6 +286,19 @@ function identity(auth) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The ChatGPT plan its id_token names, title-cased ("Plus", "Pro", "Team"), or null. */
|
||||
function planOf(auth) {
|
||||
try {
|
||||
const c = JSON.parse(
|
||||
Buffer.from(String(auth.tokens.id_token).split(".")[1], "base64url").toString("utf8")
|
||||
);
|
||||
const p = c[AUTH_CLAIM]?.chatgpt_plan_type;
|
||||
return typeof p === "string" && p ? p.charAt(0).toUpperCase() + p.slice(1) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `disk` (a member's auth.json) may replace pool account `a`: AgentHub
|
||||
* placed `a` in that home itself (`placedIn`), its `last_refresh` is no later
|
||||
@@ -362,9 +376,9 @@ function pick(member, ownerId = null) {
|
||||
// in their home stays: one auth.json per home.
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "codex");
|
||||
usable = usable.filter((a) => allowed(a.name));
|
||||
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
|
||||
// waits while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "codex");
|
||||
// A pinned (or the owner's own chosen) API key goes in by env and wins
|
||||
// outright; a pinned ChatGPT login waits while another login holds this home.
|
||||
const pin = require("./account-pins").preferredFor(ownerId, "codex");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned && (pinned.kind === "apikey" || !live || live === pinned)) return pinned;
|
||||
if (live) return live;
|
||||
|
||||
@@ -176,6 +176,16 @@ function readChunk(file, offset, size) {
|
||||
return { text: buf.toString("utf8", 0, end), bytes: end, more: offset + end < size && end > 0 };
|
||||
}
|
||||
|
||||
/** A regular file, by `stat`: `existsSync` is access(2), blind to the
|
||||
* CAP_DAC_READ_SEARCH that reaches a member's 0700 CODEX_HOME (S3). */
|
||||
function isFile(p) {
|
||||
try {
|
||||
return fs.statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every total vector in a parent journal: the history a fork copied. Streamed. */
|
||||
async function parentTotals(file) {
|
||||
const keys = new Set();
|
||||
@@ -498,7 +508,7 @@ function ingestCodexUsage({
|
||||
if (walkedAll) return null;
|
||||
const known = threadPath.get(id)?.path;
|
||||
const root = path.join(home || getCodexHome(), "sessions") + path.sep;
|
||||
if (known && known.startsWith(root) && fs.existsSync(known)) return known;
|
||||
if (known && known.startsWith(root) && isFile(known)) return known;
|
||||
walkedAll = true;
|
||||
for (const f of allRolloutFiles(home)) {
|
||||
const m = FILE_NAME.exec(path.basename(f.path));
|
||||
|
||||
@@ -118,7 +118,7 @@ function pick(ownerId = null) {
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
|
||||
const usable = allAccounts().filter((a) => allowed(a.name));
|
||||
if (usable.length === 0) return null;
|
||||
const pin = require("./account-pins").pinFor(ownerId, "cursor");
|
||||
const pin = require("./account-pins").preferredFor(ownerId, "cursor");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned) return pinned;
|
||||
return usable
|
||||
|
||||
+26
-1
@@ -55,6 +55,7 @@ const {
|
||||
git: rawGit,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
listRemoteBranches,
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
operationInProgress,
|
||||
@@ -532,7 +533,31 @@ async function runGitAction(dir, action, body = {}) {
|
||||
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
|
||||
return done(action, await git(dir, ["switch", "-c", branch]), branch);
|
||||
}
|
||||
if (!exists) throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
|
||||
if (!exists) {
|
||||
// A branch only a remote has (fetched, never checked out here): its local
|
||||
// tracking branch is created and switched to. Named either as the remote
|
||||
// ref (`origin/feature`) or bare (`feature`, when exactly one remote has
|
||||
// it). The remote ref is looked up among the fetched ones, never passed
|
||||
// through from the request.
|
||||
const remote = (await listRemoteBranches(dir)).filter(
|
||||
(r) => r.name === branch || r.branch === branch
|
||||
);
|
||||
const pick = remote.find((r) => r.name === branch) || (remote.length === 1 ? remote[0] : null);
|
||||
if (!pick) {
|
||||
if (remote.length > 1) {
|
||||
throw bad(
|
||||
"EAMBIGUOUS",
|
||||
`branch ${branch} is on several remotes (${remote.map((r) => r.name).join(", ")}): pick one`
|
||||
);
|
||||
}
|
||||
throw bad("ENOBRANCH", `branch ${branch} does not exist locally or on a remote`);
|
||||
}
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`]),
|
||||
pick.branch
|
||||
);
|
||||
}
|
||||
const busyAt = await branchCheckedOutAt(dir, branch);
|
||||
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
|
||||
return done(action, await git(dir, ["switch", branch]), branch);
|
||||
|
||||
+383
-34
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file host-stats.js
|
||||
* @description The Administration page's Server tab (`GET /api/host`, staff
|
||||
* only): the machine's hardware and load, and how much of its CPU and memory
|
||||
* @description The Dashboard System Health tab's machine cards (`GET /api/host`,
|
||||
* every signed-in user): the machine's hardware and load, and how much of its CPU and memory
|
||||
* each AgentHub user is using right now. Read-only — nothing here signals,
|
||||
* renices or kills a process.
|
||||
*
|
||||
@@ -17,9 +17,22 @@
|
||||
* children carry it too); else `host` (the dashboard and everything else the
|
||||
* host account runs) or `system` (root and every other account). Only the
|
||||
* kernel's `comm` name is reported for a process — never its argv or env,
|
||||
* which can hold a prompt or a key. Every read is async, and one sample is
|
||||
* which can hold a prompt or a key. The same sample window also gives per-core
|
||||
* CPU % (`/proc/stat` cpuN), network rx/tx per interface (`/proc/net/dev`, no
|
||||
* `lo`), disk read/write per whole disk (`/proc/diskstats`), and the ten
|
||||
* busiest processes machine-wide with their group; temperatures come from
|
||||
* `/sys/class/hwmon`, physical cores from `/proc/cpuinfo`, and the dashboard's
|
||||
* own pid/Node/uptime/RSS from `process`, and pressure stall information
|
||||
* (`/proc/pressure/{cpu,memory,io}`, PSI — the share of time tasks waited on
|
||||
* that resource, i.e. how laggy the machine feels; `null` on a kernel without
|
||||
* it). Every read is async, and one sample is
|
||||
* shared by concurrent callers and cached for `FRESH_MS`. Linux only: other
|
||||
* platforms get the `os` facts with `usage: null`.
|
||||
* platforms get the `os` facts with `usage: null` and empty lists.
|
||||
*
|
||||
* History (`GET /api/host/history`): `startHistory` (every server on Linux,
|
||||
* server/index.js) keeps an in-memory ring of machine CPU/memory/swap/load1
|
||||
* and per-user CPU/memory, one point every 30 s for 24 h, from the cached
|
||||
* snapshot; it starts empty at every server start and is never written to disk.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -32,6 +45,10 @@ const { execFile } = require("node:child_process");
|
||||
const SAMPLE_MS = 1000;
|
||||
const FRESH_MS = 3000;
|
||||
const TOP_PER_GROUP = 5;
|
||||
const TOP_PROCESSES = 10;
|
||||
const HISTORY_EVERY_MS = 30_000;
|
||||
const HISTORY_KEEP_MS = 24 * 3600_000;
|
||||
const HISTORY_MAX_POINTS = 360;
|
||||
// File systems that hold data; pseudo and loop mounts are left out.
|
||||
const REAL_FS = new Set([
|
||||
"ext2",
|
||||
@@ -71,13 +88,154 @@ async function memory() {
|
||||
};
|
||||
}
|
||||
|
||||
/** The machine's jiffies from `/proc/stat`'s `cpu` line: `{ total, idle }`. */
|
||||
async function cpuTimes() {
|
||||
const text = await readText("/proc/stat");
|
||||
const line = text && text.split("\n").find((l) => l.startsWith("cpu "));
|
||||
if (!line) return null;
|
||||
const n = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
return { total: n.reduce((a, b) => a + b, 0), idle: n[3] + (n[4] || 0) };
|
||||
/**
|
||||
* `/proc/stat`'s jiffies: the machine's `cpu` line and each `cpuN` line, each
|
||||
* `{ total, idle }` (idle = idle + iowait). `{ all: null, cores: [] }` when
|
||||
* there is no `cpu` line.
|
||||
*/
|
||||
function parseCpuStat(text) {
|
||||
const times = (line) => {
|
||||
const n = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
return { total: n.reduce((a, b) => a + b, 0), idle: n[3] + (n[4] || 0) };
|
||||
};
|
||||
const lines = (text || "").split("\n");
|
||||
const all = lines.find((l) => l.startsWith("cpu "));
|
||||
return {
|
||||
all: all ? times(all) : null,
|
||||
cores: lines.filter((l) => /^cpu\d+ /.test(l)).map(times),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One `/proc/pressure/<resource>` file: `{some10, some60, full10}` — the % of
|
||||
* the last 10 s / 60 s that at least one task (`some`) or every task (`full`)
|
||||
* was stalled on it. `null` when the file is missing or unreadable.
|
||||
*/
|
||||
function parsePressure(text) {
|
||||
if (!text) return null;
|
||||
const line = (kind) => {
|
||||
const m = new RegExp(`^${kind} avg10=([\\d.]+) avg60=([\\d.]+)`, "m").exec(text);
|
||||
return m ? { avg10: Number(m[1]), avg60: Number(m[2]) } : null;
|
||||
};
|
||||
const some = line("some");
|
||||
if (!some) return null;
|
||||
return { some10: some.avg10, some60: some.avg60, full10: line("full")?.avg10 ?? 0 };
|
||||
}
|
||||
|
||||
/** PSI for cpu, memory and io; `null` when the kernel has none of them. */
|
||||
async function pressure() {
|
||||
const [cpu, memory, io] = await Promise.all(
|
||||
["cpu", "memory", "io"].map((r) => readText(`/proc/pressure/${r}`).then(parsePressure))
|
||||
);
|
||||
return cpu || memory || io ? { cpu, memory, io } : null;
|
||||
}
|
||||
|
||||
/** Busy % between two `{ total, idle }` readings. */
|
||||
const busyPct = (a, b) => {
|
||||
const total = b.total - a.total;
|
||||
return total > 0 ? round(((total - (b.idle - a.idle)) / total) * 100) : 0;
|
||||
};
|
||||
|
||||
/** `/proc/net/dev`: `Map<iface, { rx, tx }>` in bytes, `lo` left out. */
|
||||
function parseNetDev(text) {
|
||||
const out = new Map();
|
||||
for (const line of (text || "").split("\n").slice(2)) {
|
||||
const i = line.indexOf(":");
|
||||
if (i < 0) continue;
|
||||
const name = line.slice(0, i).trim();
|
||||
if (!name || name === "lo") continue;
|
||||
const f = line
|
||||
.slice(i + 1)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(Number);
|
||||
out.set(name, { rx: f[0] || 0, tx: f[8] || 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/proc/diskstats`: `Map<device, { read, write }>` in bytes (sectors × 512)
|
||||
* for whole disks only (`isDisk(name)`), never loop/ram/zram/optical.
|
||||
*/
|
||||
function parseDiskstats(text, isDisk = () => true) {
|
||||
const out = new Map();
|
||||
for (const line of (text || "").split("\n")) {
|
||||
const f = line.trim().split(/\s+/);
|
||||
if (f.length < 10) continue;
|
||||
const name = f[2];
|
||||
if (/^(loop|ram|zram|sr|fd)\d*/.test(name) || !isDisk(name)) continue;
|
||||
out.set(name, { read: Number(f[5]) * 512, write: Number(f[9]) * 512 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whole disks: the names under `/sys/block` (partitions are not there). */
|
||||
function wholeDisks() {
|
||||
try {
|
||||
return new Set(fs.readdirSync("/sys/block"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-key `{a, b}` byte counters → per-second rates over `ms`. */
|
||||
function rates(m0, m1, ms, keys) {
|
||||
const sec = Math.max(0.001, ms / 1000);
|
||||
const out = [];
|
||||
for (const [name, v1] of m1) {
|
||||
const v0 = m0.get(name);
|
||||
if (!v0) continue;
|
||||
const row = { name };
|
||||
for (const k of keys) row[k] = Math.max(0, Math.round((v1[k] - v0[k]) / sec));
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temperatures from `/sys/class/hwmon/*`: `[{ chip, label, celsius }]`, one
|
||||
* per `tempN_input` (label from `tempN_label`, else `tempN`). [] when none.
|
||||
*/
|
||||
async function temperatures(root = "/sys/class/hwmon") {
|
||||
let chips;
|
||||
try {
|
||||
chips = await fsp.readdir(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const c of chips.sort()) {
|
||||
const dir = path.join(root, c);
|
||||
const chip = ((await readText(path.join(dir, "name"))) || c).trim();
|
||||
let files;
|
||||
try {
|
||||
files = await fsp.readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of files.filter((x) => /^temp\d+_input$/.test(x)).sort()) {
|
||||
const raw = Number(((await readText(path.join(dir, f))) || "").trim());
|
||||
if (!Number.isFinite(raw) || raw === 0) continue;
|
||||
const id = f.replace("_input", "");
|
||||
const label = ((await readText(path.join(dir, `${id}_label`))) || id).trim();
|
||||
out.push({ chip, label, celsius: round(raw / 1000) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Physical cores from `/proc/cpuinfo` (distinct physical id + core id), or null. */
|
||||
function physicalCores(text) {
|
||||
const seen = new Set();
|
||||
let phys = "0";
|
||||
for (const line of (text || "").split("\n")) {
|
||||
const m = /^(physical id|core id)\s*:\s*(\d+)/.exec(line);
|
||||
if (!m) continue;
|
||||
if (m[1] === "physical id") phys = m[2];
|
||||
else seen.add(`${phys}:${m[2]}`);
|
||||
}
|
||||
return seen.size || null;
|
||||
}
|
||||
|
||||
/** `HDD`/`SSD` for a `/dev/...` device, or null when /sys cannot tell. */
|
||||
@@ -269,14 +427,47 @@ let deps = {
|
||||
wait: (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
};
|
||||
|
||||
/** Per-user CPU and memory from two samples. */
|
||||
async function usage() {
|
||||
const t0 = await cpuTimes();
|
||||
const p0 = await processes();
|
||||
/** The counters one sample reads: CPU jiffies, network, disk I/O, processes. */
|
||||
async function counters(isDisk) {
|
||||
const [stat, net, io] = await Promise.all([
|
||||
readText("/proc/stat"),
|
||||
readText("/proc/net/dev"),
|
||||
readText("/proc/diskstats"),
|
||||
]);
|
||||
return {
|
||||
at: Date.now(),
|
||||
cpu: parseCpuStat(stat),
|
||||
net: parseNetDev(net),
|
||||
io: parseDiskstats(io, isDisk),
|
||||
procs: await processes(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two samples `SAMPLE_MS` apart: per-user CPU and memory (`usage`), and from
|
||||
* the same window per-core CPU, network and disk I/O rates and the busiest
|
||||
* processes machine-wide.
|
||||
*/
|
||||
async function sampleWindow(mem) {
|
||||
const disksSet = wholeDisks();
|
||||
const isDisk = (n) => !disksSet || disksSet.has(n);
|
||||
const s0 = await counters(isDisk);
|
||||
await deps.wait(SAMPLE_MS);
|
||||
const t1 = await cpuTimes();
|
||||
const p1 = await processes();
|
||||
const mem = await memory();
|
||||
const s1 = await counters(isDisk);
|
||||
const ms = s1.at - s0.at;
|
||||
const cores = s1.cpu.cores.map((c, i) => (s0.cpu.cores[i] ? busyPct(s0.cpu.cores[i], c) : 0));
|
||||
const use = await usage(s0.cpu.all, s1.cpu.all, s0.procs, s1.procs, mem);
|
||||
return {
|
||||
usage: use ? use.usage : null,
|
||||
topProcesses: use ? use.top : [],
|
||||
cores,
|
||||
network: rates(s0.net, s1.net, ms, ["rx", "tx"]),
|
||||
diskIo: rates(s0.io, s1.io, ms, ["read", "write"]),
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-user CPU and memory from two samples (`usage`) and the busiest processes (`top`). */
|
||||
async function usage(t0, t1, p0, p1, mem) {
|
||||
if (!t0 || !t1) return null;
|
||||
const totalDelta = Math.max(1, t1.total - t0.total);
|
||||
const hostUid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
@@ -286,6 +477,7 @@ async function usage() {
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
const ownerCache = new Map();
|
||||
const groups = new Map();
|
||||
const all = [];
|
||||
const group = (key, extra) => {
|
||||
if (!groups.has(key))
|
||||
groups.set(key, { key, cpu: 0, memory: 0, processes: 0, top: [], ...extra });
|
||||
@@ -301,7 +493,7 @@ async function usage() {
|
||||
const member = deps.memberNameForUid(p.uid);
|
||||
if (member && byUsername.has(member)) {
|
||||
const u = byUsername.get(member);
|
||||
g = group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username });
|
||||
g = group(`user:${u.id}`, userExtra(u));
|
||||
} else if (p.uid === hostUid) {
|
||||
const laneId = await laneIdOf(pid);
|
||||
let ownerId = null;
|
||||
@@ -318,9 +510,7 @@ async function usage() {
|
||||
ownerId = ownerCache.get(laneId);
|
||||
}
|
||||
const u = ownerId != null ? byId.get(ownerId) : null;
|
||||
g = u
|
||||
? group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username })
|
||||
: group("host", { kind: "host" });
|
||||
g = u ? group(`user:${u.id}`, userExtra(u)) : group("host", { kind: "host" });
|
||||
} else {
|
||||
g = group("system", { kind: "system" });
|
||||
}
|
||||
@@ -329,6 +519,7 @@ async function usage() {
|
||||
g.memory += p.rss;
|
||||
g.processes += 1;
|
||||
g.top.push({ pid, name: p.comm, cpu, memory: p.rss });
|
||||
all.push({ pid, name: p.comm, cpu, memory: p.rss, group: g.key });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -342,9 +533,7 @@ async function usage() {
|
||||
if (!groups.has(`user:${u.id}`))
|
||||
list.push({
|
||||
key: `user:${u.id}`,
|
||||
kind: "user",
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
...userExtra(u),
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
memoryPercent: 0,
|
||||
@@ -353,24 +542,40 @@ async function usage() {
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
|
||||
all.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
|
||||
return {
|
||||
cpu: round(((totalDelta - (t1.idle - t0.idle)) / totalDelta) * 100),
|
||||
sampleMs: SAMPLE_MS,
|
||||
groups: list,
|
||||
usage: {
|
||||
cpu: round(((totalDelta - (t1.idle - t0.idle)) / totalDelta) * 100),
|
||||
sampleMs: SAMPLE_MS,
|
||||
groups: list,
|
||||
},
|
||||
top: all.slice(0, TOP_PROCESSES).map((t) => ({ ...t, cpu: round(t.cpu) })),
|
||||
};
|
||||
}
|
||||
|
||||
const round = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
/** A user's group fields: id, username and display name (every viewer reads them). */
|
||||
const userExtra = (u) => ({
|
||||
kind: "user",
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName ?? null,
|
||||
});
|
||||
|
||||
async function collect() {
|
||||
const linux = process.platform === "linux";
|
||||
const cpus = os.cpus();
|
||||
const [mem, diskList, gpuList, use] = await Promise.all([
|
||||
memory(),
|
||||
const mem = await memory();
|
||||
const [diskList, gpuList, win, temps, cpuinfo, psi] = await Promise.all([
|
||||
linux ? disks() : [],
|
||||
linux ? gpus() : [],
|
||||
linux ? usage() : null,
|
||||
linux ? sampleWindow(mem) : null,
|
||||
linux ? temperatures() : [],
|
||||
linux ? readText("/proc/cpuinfo") : null,
|
||||
linux ? pressure() : null,
|
||||
]);
|
||||
const pmem = process.memoryUsage();
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
hostname: os.hostname(),
|
||||
@@ -381,12 +586,31 @@ async function collect() {
|
||||
arch: os.arch(),
|
||||
},
|
||||
uptime: os.uptime(),
|
||||
cpu: { model: cpus[0]?.model?.trim() || null, cores: cpus.length, load: os.loadavg() },
|
||||
bootTime: new Date(Date.now() - os.uptime() * 1000).toISOString(),
|
||||
cpu: {
|
||||
model: cpus[0]?.model?.trim() || null,
|
||||
cores: cpus.length,
|
||||
load: os.loadavg(),
|
||||
physicalCores: physicalCores(cpuinfo),
|
||||
perCore: win ? win.cores : [],
|
||||
},
|
||||
memory: mem,
|
||||
disks: diskList,
|
||||
homesMount: mountOf(diskList, deps.homesRoot()),
|
||||
gpus: gpuList,
|
||||
usage: use,
|
||||
usage: win ? win.usage : null,
|
||||
network: win ? win.network : [],
|
||||
diskIo: win ? win.diskIo : [],
|
||||
temperatures: temps,
|
||||
pressure: psi,
|
||||
topProcesses: win ? win.topProcesses : [],
|
||||
process: {
|
||||
pid: process.pid,
|
||||
node: process.version,
|
||||
uptime: Math.round(process.uptime()),
|
||||
rss: pmem.rss,
|
||||
heapUsed: pmem.heapUsed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,9 +633,134 @@ async function hostStats() {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
// An in-memory ring of machine-level samples, one every HISTORY_EVERY_MS for
|
||||
// HISTORY_KEEP_MS, started by every server on Linux (server/index.js). It
|
||||
// starts empty at every server start: nothing is written to disk.
|
||||
|
||||
let history = [];
|
||||
let historyTimer = null;
|
||||
|
||||
/** One history point from a snapshot. */
|
||||
function historyPoint(s) {
|
||||
const users = {};
|
||||
for (const g of s.usage?.groups || []) {
|
||||
if (g.kind === "user" && (g.cpu || g.memory))
|
||||
users[g.userId] = { cpu: g.cpu, memory: g.memory };
|
||||
}
|
||||
return {
|
||||
at: s.at,
|
||||
cpu: s.usage ? s.usage.cpu : null,
|
||||
memory: round((s.memory.used / Math.max(1, s.memory.total)) * 100),
|
||||
memoryUsed: s.memory.used,
|
||||
swapUsed: s.memory.swapUsed,
|
||||
load1: round(s.cpu.load[0] || 0),
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
/** Take one sample now (reusing a fresh snapshot) and drop what is too old. */
|
||||
async function sampleHistory() {
|
||||
try {
|
||||
const point = historyPoint(await hostStats());
|
||||
const cutoff = Date.now() - HISTORY_KEEP_MS;
|
||||
history = [...history.filter((p) => Date.parse(p.at) >= cutoff), point];
|
||||
} catch {
|
||||
/* a failed sample is a gap, never a crash */
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the sampler (Linux only; idempotent). The timer never holds the process open. */
|
||||
function startHistory() {
|
||||
if (historyTimer || process.platform !== "linux") return;
|
||||
void sampleHistory();
|
||||
historyTimer = setInterval(() => void sampleHistory(), HISTORY_EVERY_MS);
|
||||
historyTimer.unref?.();
|
||||
}
|
||||
|
||||
function stopHistory() {
|
||||
if (historyTimer) clearInterval(historyTimer);
|
||||
historyTimer = null;
|
||||
}
|
||||
|
||||
const RANGES = { "1h": 3600_000, "6h": 6 * 3600_000, "24h": 24 * 3600_000 };
|
||||
|
||||
/**
|
||||
* The points of the last `range` (`1h`, `6h`, `24h`; else 1h), averaged down
|
||||
* to at most HISTORY_MAX_POINTS. `startedAt` = the oldest point kept, so the
|
||||
* UI can say the history begins there.
|
||||
*/
|
||||
function hostHistory(range) {
|
||||
const span = RANGES[range] || RANGES["1h"];
|
||||
const since = Date.now() - span;
|
||||
const pts = history.filter((p) => Date.parse(p.at) >= since);
|
||||
return {
|
||||
range: RANGES[range] ? range : "1h",
|
||||
intervalMs: HISTORY_EVERY_MS,
|
||||
running: historyTimer != null,
|
||||
startedAt: history[0]?.at ?? null,
|
||||
points: downsample(pts, HISTORY_MAX_POINTS),
|
||||
};
|
||||
}
|
||||
|
||||
/** Average consecutive points into at most `max` buckets (numbers only; `at` = the bucket's last). */
|
||||
function downsample(pts, max) {
|
||||
if (pts.length <= max) return pts;
|
||||
const per = Math.ceil(pts.length / max);
|
||||
const out = [];
|
||||
for (let i = 0; i < pts.length; i += per) {
|
||||
const b = pts.slice(i, i + per);
|
||||
const avg = (k) => {
|
||||
const v = b.map((p) => p[k]).filter((x) => x != null);
|
||||
return v.length ? round(v.reduce((a, x) => a + x, 0) / v.length) : null;
|
||||
};
|
||||
const users = {};
|
||||
for (const p of b)
|
||||
for (const [id, u] of Object.entries(p.users)) {
|
||||
const t = (users[id] ||= { cpu: 0, memory: 0 });
|
||||
t.cpu += u.cpu / b.length;
|
||||
t.memory += u.memory / b.length;
|
||||
}
|
||||
for (const u of Object.values(users)) {
|
||||
u.cpu = round(u.cpu);
|
||||
u.memory = Math.round(u.memory);
|
||||
}
|
||||
out.push({
|
||||
at: b[b.length - 1].at,
|
||||
cpu: avg("cpu"),
|
||||
memory: avg("memory"),
|
||||
memoryUsed: Math.round(avg("memoryUsed") ?? 0),
|
||||
swapUsed: Math.round(avg("swapUsed") ?? 0),
|
||||
load1: avg("load1"),
|
||||
users,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function __setDeps(over) {
|
||||
deps = { ...deps, ...over };
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { hostStats, mountOf, __setDeps };
|
||||
/** Tests: replace the history ring. */
|
||||
function __setHistory(points) {
|
||||
history = points;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hostStats,
|
||||
hostHistory,
|
||||
startHistory,
|
||||
stopHistory,
|
||||
mountOf,
|
||||
parseCpuStat,
|
||||
parsePressure,
|
||||
parseNetDev,
|
||||
parseDiskstats,
|
||||
physicalCores,
|
||||
temperatures,
|
||||
downsample,
|
||||
__setDeps,
|
||||
__setHistory,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @file loop-delay.js
|
||||
* @description How long this server's event loop was held up — what an
|
||||
* operator sees as a laggy dashboard or terminal: every request, hook and
|
||||
* keystroke echo waits for it. One `monitorEventLoopDelay` histogram for the
|
||||
* whole process, sampled every 20 ms and reset each minute, so `loopDelay()`
|
||||
* describes the last full minute (the running one until the first minute
|
||||
* ends). Read by `GET /api/metrics` (Prometheus gauges) and
|
||||
* `GET /api/settings/info` (the System Health score).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { monitorEventLoopDelay } = require("perf_hooks");
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
const histogram = monitorEventLoopDelay({ resolution: 20 });
|
||||
histogram.enable();
|
||||
|
||||
const read = () => ({
|
||||
p50: histogram.percentile(50),
|
||||
p99: histogram.percentile(99),
|
||||
max: histogram.max,
|
||||
});
|
||||
|
||||
let lastWindow = null; // the last full minute, reported until the next one ends
|
||||
setInterval(() => {
|
||||
lastWindow = read();
|
||||
histogram.reset();
|
||||
}, WINDOW_MS).unref();
|
||||
|
||||
/** `{p50, p99, max}` in nanoseconds over the last minute. */
|
||||
function loopDelay() {
|
||||
return lastWindow || read();
|
||||
}
|
||||
|
||||
module.exports = { loopDelay, WINDOW_MS };
|
||||
+17
-3
@@ -108,16 +108,29 @@ function refill(t) {
|
||||
while (t.standby.length < STANDBY) t.standby.push(newToken());
|
||||
}
|
||||
|
||||
/** The gateway's view: every queued key and every key a run holds — hashes only. */
|
||||
function clientsFor(t) {
|
||||
/**
|
||||
* The gateway's view: every queued key and every key a run holds — hashes
|
||||
* only. A run whose workspace owner is over their spend quota
|
||||
* (user-quota.js) is left out until it opens again: its omp can no longer
|
||||
* reach a model, while its pane and key stay.
|
||||
*/
|
||||
function clientsFor(t, isBlocked = quotaBlocked) {
|
||||
const out = {};
|
||||
for (const s of t.standby) out[`standby-${sha(s).slice(0, 16)}`] = { tokenSha256: sha(s) };
|
||||
for (const [id, u] of Object.entries(t.used)) {
|
||||
if (u && typeof u.hash === "string") out[id] = { tokenSha256: u.hash };
|
||||
if (u && typeof u.hash === "string" && !isBlocked(id)) out[id] = { tokenSha256: u.hash };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function quotaBlocked(runId) {
|
||||
try {
|
||||
return require("./user-quota").isRunBlocked(runId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let lastClients = null;
|
||||
|
||||
function save(t) {
|
||||
@@ -519,6 +532,7 @@ module.exports = {
|
||||
takeToken,
|
||||
pruneTokens,
|
||||
clientsFor,
|
||||
clientsFor,
|
||||
readTokens,
|
||||
PROVIDER,
|
||||
TOKEN_ENV,
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* A Claude session counts only for the hours one of THIS account's runs held
|
||||
* it (start hour to end hour), so a session resumed on another account is
|
||||
* not booked twice; Codex has no hourly rows, so its session counts whole
|
||||
* once a run of the account started in the window, as before.
|
||||
* once a run of the account started in the window, as before. With `ownerId`
|
||||
* only the runs of that user's workspaces count — one user's share of the
|
||||
* account, for the Administration page's user detail.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,21 +21,28 @@ const tokensOf = (r) =>
|
||||
|
||||
/**
|
||||
* `{spend5h, spend7d, tokens5h, tokens7d}` of `account` on `tool`. With
|
||||
* `wholeSession`, a session's every row counts in a window its run started in.
|
||||
* `wholeSession`, a session's every row counts in a window its run started in;
|
||||
* with `ownerId`, only runs on that user's workspaces.
|
||||
*/
|
||||
function accountUsage(tool, account, { wholeSession = false, now = Date.now() } = {}) {
|
||||
function accountUsage(
|
||||
tool,
|
||||
account,
|
||||
{ wholeSession = false, now = Date.now(), ownerId = null } = {}
|
||||
) {
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
|
||||
const since7d = new Date(now - WINDOWS["7d"] * 3600e3).toISOString();
|
||||
const owned = ownerId == null ? "" : "AND lane_id IN (SELECT id FROM lanes WHERE owner_id = ?)";
|
||||
const ownedArgs = ownerId == null ? [] : [ownerId];
|
||||
const runs = db
|
||||
.prepare(
|
||||
`SELECT session_id, substr(started_at, 1, 13) AS start_hour, started_at,
|
||||
substr(COALESCE(ended_at, '9999'), 1, 13) AS end_hour
|
||||
FROM dashboard_runs
|
||||
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
|
||||
AND COALESCE(ended_at, '9999') >= ?`
|
||||
AND COALESCE(ended_at, '9999') >= ? ${owned}`
|
||||
)
|
||||
.all(tool, account, since7d);
|
||||
.all(tool, account, since7d, ...ownedArgs);
|
||||
const out = { spend5h: 0, spend7d: 0, tokens5h: 0, tokens7d: 0 };
|
||||
if (runs.length === 0) return out;
|
||||
const runsOf = new Map();
|
||||
@@ -41,8 +50,8 @@ function accountUsage(tool, account, { wholeSession = false, now = Date.now() }
|
||||
const rows = hourlyTokenRows(
|
||||
`WHERE s.id IN (SELECT session_id FROM dashboard_runs
|
||||
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
|
||||
AND COALESCE(ended_at, '9999') >= ?)`,
|
||||
[tool, account, since7d]
|
||||
AND COALESCE(ended_at, '9999') >= ? ${owned})`,
|
||||
[tool, account, since7d, ...ownedArgs]
|
||||
);
|
||||
const rules = stmts.listPricing.all();
|
||||
for (const [label, hours] of Object.entries(WINDOWS)) {
|
||||
|
||||
@@ -471,6 +471,8 @@ function spawnRun(args) {
|
||||
// their .mcp.json) or a member in someone else's.
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
|
||||
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js) — or the one the
|
||||
@@ -684,6 +686,9 @@ function spawnRun(args) {
|
||||
for (const k of Object.keys(env)) delete env[k];
|
||||
Object.assign(env, own);
|
||||
}
|
||||
// Server mode: where this pane's hooks look for its owner's quota flag
|
||||
// (user-quota.js) — a refused prompt is decided by the hook, locally.
|
||||
Object.assign(env, require("./user-quota").flagEnv(laneOwner, member));
|
||||
|
||||
// `after-start` tools take no prompt on their command line: paste it once
|
||||
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
|
||||
|
||||
+4
-4
@@ -36,7 +36,7 @@
|
||||
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
||||
*
|
||||
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
||||
* subscription's name as the tool reports it: Claude's `subscriptionType`,
|
||||
* subscription's name: Claude's `planLabel` (tier + seat, not the coarse `subscriptionType`),
|
||||
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
|
||||
* `expiresAt`, when its stored access token runs out — Claude's
|
||||
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
|
||||
@@ -60,7 +60,7 @@ const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
||||
const { readActiveAuth, readProfile, listProfiles, planLabel } = require("./cc-profiles");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
const { getCursorDir } = require("./cursor-usage");
|
||||
@@ -142,7 +142,7 @@ async function liveClaude(readAuth = readActiveAuth) {
|
||||
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
||||
who = {
|
||||
account: account?.emailAddress || account?.displayName || null,
|
||||
plan: oauth.subscriptionType || null,
|
||||
plan: planLabel(oauth, account),
|
||||
expiresAt: iso(oauth.expiresAt),
|
||||
};
|
||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||
@@ -172,7 +172,7 @@ async function liveClaude(readAuth = readActiveAuth) {
|
||||
async function profileClaude(item) {
|
||||
const who = {
|
||||
account: item.email || item.displayName || null,
|
||||
plan: item.subscriptionType || null,
|
||||
plan: item.plan || item.subscriptionType || null,
|
||||
expiresAt: iso(item.expiresAt),
|
||||
};
|
||||
let creds = null;
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* @file user-quota.js
|
||||
* @description Spend quotas per user (server mode only; local mode has one
|
||||
* user and never reads any of this). An admin sets a DEFAULT quota (row
|
||||
* `user_id = 0` of `user_quotas`) and may override it per user — a limit of
|
||||
* their own, or none at all (`limit_usd` NULL). A quota is `limit_usd` per
|
||||
* `period`: `day` (from the local midnight of `tz_offset`, the admin's
|
||||
* browser's getTimezoneOffset() when they saved it) or `5h` (a sliding
|
||||
* window: the current UTC hour and the four before it — spend booked in hour
|
||||
* H counts until H + 5 h). Spend is the users page's own booking
|
||||
* (`hourlyTokenRows` over the sessions a user OWNS through a workspace,
|
||||
* priced by `calculateCost`), never a second formula.
|
||||
*
|
||||
* A user over their quota cannot chat on until it resets:
|
||||
* - `assertSpawnAllowed` refuses every new pane (`EQUOTA`, 429) — start,
|
||||
* resume and a prompt from the workspace home all pass `spawnRun`;
|
||||
* - every pane carries `AGENTHUB_QUOTA_FILE` (`flagEnv`), and `sweep` writes
|
||||
* that file `{blocked, until, message}` — the host's under the data dir,
|
||||
* a member's (S3) in their handoff dir through `write-as` — so the hook
|
||||
* handler refuses a `UserPromptSubmit` of Claude Code or Codex locally,
|
||||
* without asking the dashboard (scripts/hook-handler.js; fail-open);
|
||||
* - a member's omp on the shared login loses its gateway key while blocked
|
||||
* (`isRunBlocked`, omp-share.js `clientsFor`);
|
||||
* - a pane no hook can stop (Antigravity, Cursor, a Codex without trusted
|
||||
* hooks, an omp on its own login, any other catalog tool) is ended.
|
||||
* Spend lands after a turn, so a user may end one turn past the limit.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const PERIODS = ["day", "5h"];
|
||||
const DEFAULT_ID = 0;
|
||||
const HOUR_MS = 3_600_000;
|
||||
const WINDOW_HOURS = 5;
|
||||
|
||||
const db = () => require("../db").db;
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
const isServerMode = () => require("./auth").isServerMode();
|
||||
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
|
||||
const hourMs = (h) => Date.parse(`${h}:00:00.000Z`);
|
||||
const round = (n) => Math.round(n * 10000) / 10000;
|
||||
|
||||
// ── settings ──────────────────────────────────────────────────────────────
|
||||
|
||||
function row(userId) {
|
||||
return db()
|
||||
.prepare("SELECT limit_usd, period, tz_offset FROM user_quotas WHERE user_id = ?")
|
||||
.get(userId);
|
||||
}
|
||||
|
||||
const asConfig = (r, source) =>
|
||||
r && r.limit_usd != null
|
||||
? { source, limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset }
|
||||
: null;
|
||||
|
||||
/** The default quota, or null when there is none. */
|
||||
function getDefault() {
|
||||
return asConfig(row(DEFAULT_ID), "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's own setting: `default` (no row — the default applies),
|
||||
* `unlimited`, or `custom` with its limit.
|
||||
*/
|
||||
function getOverride(userId) {
|
||||
const r = row(userId);
|
||||
if (!r) return { mode: "default" };
|
||||
if (r.limit_usd == null) return { mode: "unlimited" };
|
||||
return { mode: "custom", limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset };
|
||||
}
|
||||
|
||||
/** The quota that binds `userId` now, or null (no limit). */
|
||||
function effective(userId) {
|
||||
const r = row(userId);
|
||||
if (r) return asConfig(r, "user");
|
||||
return getDefault();
|
||||
}
|
||||
|
||||
/** `{limit_usd, period, tz_offset}` checked; throws EBADQUOTA. */
|
||||
function validate(cfg) {
|
||||
const limit = Number(cfg?.limit_usd);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || limit > 1_000_000) {
|
||||
throw fail("EBADQUOTA", "limit_usd must be a positive number of dollars");
|
||||
}
|
||||
if (!PERIODS.includes(cfg.period)) {
|
||||
throw fail("EBADQUOTA", `period must be one of: ${PERIODS.join(", ")}`);
|
||||
}
|
||||
const tz = cfg.tz_offset == null ? 0 : cfg.tz_offset;
|
||||
if (!Number.isInteger(tz) || Math.abs(tz) > 14 * 60) {
|
||||
throw fail("EBADQUOTA", "tz_offset must be minutes within ±840");
|
||||
}
|
||||
return { limit_usd: limit, period: cfg.period, tz_offset: tz };
|
||||
}
|
||||
|
||||
function upsert(userId, limit, period, tz) {
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO user_quotas (user_id, limit_usd, period, tz_offset, updated_at)
|
||||
VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET limit_usd = excluded.limit_usd,
|
||||
period = excluded.period, tz_offset = excluded.tz_offset,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(userId, limit, period, tz);
|
||||
}
|
||||
|
||||
/** Set the default quota (`null` removes it: no limit for anyone without their own). */
|
||||
function setDefault(cfg) {
|
||||
if (cfg == null) {
|
||||
db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(DEFAULT_ID);
|
||||
return null;
|
||||
}
|
||||
const v = validate(cfg);
|
||||
upsert(DEFAULT_ID, v.limit_usd, v.period, v.tz_offset);
|
||||
return getDefault();
|
||||
}
|
||||
|
||||
/** A user's setting checked: `{mode}` or `{mode: "custom", limit_usd, period, tz_offset}`. */
|
||||
function validateUserQuota(q) {
|
||||
const mode = q?.mode;
|
||||
if (mode === "default" || mode === "unlimited") return { mode };
|
||||
if (mode === "custom") return { mode, ...validate(q) };
|
||||
throw fail("EBADQUOTA", "quota.mode must be one of: default, unlimited, custom");
|
||||
}
|
||||
|
||||
/** A user's setting: `{mode: "default" | "unlimited" | "custom", …}`. */
|
||||
function setUserQuota(userId, q) {
|
||||
if (!Number.isInteger(userId) || userId <= 0) throw fail("EBADQUOTA", "no such user");
|
||||
const v = validateUserQuota(q);
|
||||
if (v.mode === "default") {
|
||||
db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(userId);
|
||||
} else if (v.mode === "unlimited") {
|
||||
upsert(userId, null, "day", 0);
|
||||
} else {
|
||||
upsert(userId, v.limit_usd, v.period, v.tz_offset);
|
||||
}
|
||||
return getOverride(userId);
|
||||
}
|
||||
|
||||
/** `$5/day`, `$2.50/5h`, `no limit` — for the audit log. */
|
||||
function describe(q) {
|
||||
if (!q || q.mode === "unlimited") return "no limit";
|
||||
if (q.mode === "default") return "the default";
|
||||
return `$${q.limit_usd}/${q.period}`;
|
||||
}
|
||||
|
||||
// ── spend ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The window a quota counts over at `now`: `since` (a UTC hour,
|
||||
* `YYYY-MM-DDTHH`, as hourly token rows are keyed) and, for a day, when the
|
||||
* next one starts. Buckets are whole UTC hours, so a half-hour zone's day
|
||||
* starts up to 30 min early.
|
||||
*/
|
||||
function windowOf(cfg, now) {
|
||||
if (cfg.period === "5h") {
|
||||
return { since: hourOf(now - (WINDOW_HOURS - 1) * HOUR_MS), nextReset: null };
|
||||
}
|
||||
const off = cfg.tz_offset || 0;
|
||||
const local = new Date(now - off * 60_000);
|
||||
local.setUTCHours(0, 0, 0, 0);
|
||||
const start = local.getTime() + off * 60_000;
|
||||
return { since: hourOf(start), nextReset: start + 24 * HOUR_MS };
|
||||
}
|
||||
|
||||
/**
|
||||
* When a 5 h quota opens again: the oldest hours leave the window first, so
|
||||
* it is the end of the first hour whose leaving takes the spend under the limit.
|
||||
*/
|
||||
function slidingReset(byHour, spend, limit) {
|
||||
let left = spend;
|
||||
for (const [h, cost] of [...byHour].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
||||
left -= cost;
|
||||
if (left < limit) return hourMs(h) + WINDOW_HOURS * HOUR_MS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every user's quota and spend now: Map(user id → status). Only users with a
|
||||
* quota are listed. One token read for all of them.
|
||||
* @returns {Map<number, {limit_usd, period, tz_offset, source, spend, pct,
|
||||
* exceeded: boolean, resetsAt: string|null, since: string}>}
|
||||
*/
|
||||
function statusAll({ now = Date.now(), userIds = null } = {}) {
|
||||
const out = new Map();
|
||||
if (!isServerMode()) return out;
|
||||
const ids =
|
||||
userIds ||
|
||||
db()
|
||||
.prepare("SELECT id FROM users WHERE disabled = 0")
|
||||
.all()
|
||||
.map((r) => r.id);
|
||||
const quotas = [];
|
||||
for (const id of ids) {
|
||||
const cfg = effective(id);
|
||||
if (cfg) quotas.push({ id, cfg, win: windowOf(cfg, now) });
|
||||
}
|
||||
if (!quotas.length) return out;
|
||||
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
|
||||
const { OWNED_SESSIONS } = require("../routes/users");
|
||||
const rules = require("../db").stmts.listPricing.all();
|
||||
const oldest = quotas.reduce((a, q) => (q.win.since < a ? q.win.since : a), quotas[0].win.since);
|
||||
const owner = new Map();
|
||||
for (const r of db().prepare(OWNED_SESSIONS).all()) {
|
||||
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
|
||||
}
|
||||
// A session that spent in the window was touched in it.
|
||||
const rows = hourlyTokenRows(
|
||||
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`,
|
||||
[oldest]
|
||||
);
|
||||
const byUser = new Map();
|
||||
for (const r of rows) {
|
||||
const uid = owner.get(r.session_id);
|
||||
if (uid == null) continue;
|
||||
if (!byUser.has(uid)) byUser.set(uid, []);
|
||||
byUser.get(uid).push(r);
|
||||
}
|
||||
for (const { id, cfg, win } of quotas) {
|
||||
const mine = (byUser.get(id) || []).filter((r) => r.date >= win.since);
|
||||
const spend = mine.length ? calculateCost(mine, rules).total_cost : 0;
|
||||
const exceeded = spend >= cfg.limit_usd;
|
||||
let resetsAt = win.nextReset;
|
||||
if (cfg.period === "5h") {
|
||||
const hours = new Map();
|
||||
for (const r of mine) {
|
||||
if (!hours.has(r.date)) hours.set(r.date, []);
|
||||
hours.get(r.date).push(r);
|
||||
}
|
||||
const byHour = new Map(
|
||||
[...hours].map(([h, list]) => [h, calculateCost(list, rules).total_cost])
|
||||
);
|
||||
resetsAt = exceeded ? slidingReset(byHour, spend, cfg.limit_usd) : null;
|
||||
}
|
||||
out.set(id, {
|
||||
...cfg,
|
||||
spend: round(spend),
|
||||
pct: round((spend / cfg.limit_usd) * 100),
|
||||
exceeded,
|
||||
resetsAt: resetsAt ? new Date(resetsAt).toISOString() : null,
|
||||
since: `${win.since}:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One user's status, or null without a quota. */
|
||||
function statusFor(userId, opts = {}) {
|
||||
if (userId == null) return null;
|
||||
return statusAll({ ...opts, userIds: [userId] }).get(userId) || null;
|
||||
}
|
||||
|
||||
/** "HH:MM" (and the date when it is not today) in the quota's own zone. */
|
||||
function localTime(iso, tzOffset, now = Date.now()) {
|
||||
if (!iso) return null;
|
||||
const off = (tzOffset || 0) * 60_000;
|
||||
const at = new Date(Date.parse(iso) - off).toISOString();
|
||||
const today = new Date(now - off).toISOString().slice(0, 10);
|
||||
const utc = -(tzOffset || 0) / 60;
|
||||
const zone = `UTC${utc >= 0 ? "+" : ""}${utc}`;
|
||||
return `${at.slice(0, 10) === today ? "" : `${at.slice(0, 10)} `}${at.slice(11, 16)} ${zone}`;
|
||||
}
|
||||
|
||||
/** The text a refused prompt or start shows. */
|
||||
function message(s, now = Date.now()) {
|
||||
const per = s.period === "5h" ? "5 hours" : "day";
|
||||
const when = localTime(s.resetsAt, s.tz_offset, now);
|
||||
return (
|
||||
`AgentHub: your spend quota is used up — $${s.spend.toFixed(2)} of $${s.limit_usd.toFixed(2)} per ${per}.` +
|
||||
(when ? ` It opens again at ${when}.` : "") +
|
||||
" Ask an admin to raise it if you need more."
|
||||
);
|
||||
}
|
||||
|
||||
/** Throws EQUOTA when the workspace owner is over their quota. No-op in local mode. */
|
||||
function assertSpawnAllowed(ownerId) {
|
||||
if (!isServerMode() || ownerId == null) return;
|
||||
const s = statusFor(ownerId);
|
||||
if (s?.exceeded) throw Object.assign(fail("EQUOTA", message(s)), { quota: s });
|
||||
}
|
||||
|
||||
// ── the flag file the hook handler reads ─────────────────────────────────
|
||||
|
||||
const FLAG = "quota.json";
|
||||
const hostDir = () => path.join(require("./claude-home").getDataDir(), "user-quota");
|
||||
const hostFlag = (userId) => path.join(hostDir(), `u${userId}.json`);
|
||||
|
||||
/**
|
||||
* The env a pane of `ownerId` gets: where its hooks look for the flag —
|
||||
* the member's handoff dir (S3: the pane runs as them) or the host's data dir.
|
||||
*/
|
||||
function flagEnv(ownerId, member) {
|
||||
if (!isServerMode() || ownerId == null) return {};
|
||||
const file = member ? path.join(require("./run-as").handoffDir(member), FLAG) : hostFlag(ownerId);
|
||||
return { AGENTHUB_QUOTA_FILE: file };
|
||||
}
|
||||
|
||||
function writeHostFlag(userId, text) {
|
||||
fs.mkdirSync(hostDir(), { recursive: true, mode: 0o700 });
|
||||
const file = hostFlag(userId);
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
/** Write a user's flag everywhere their panes may read it. Best effort per place. */
|
||||
function writeFlags(user, s, now) {
|
||||
const body = s?.exceeded
|
||||
? { blocked: true, until: s.resetsAt, message: message(s, now) }
|
||||
: { blocked: false };
|
||||
const text = `${JSON.stringify(body)}\n`;
|
||||
try {
|
||||
writeHostFlag(user.id, text);
|
||||
} catch (err) {
|
||||
console.warn(`user quota: flag for ${user.username} not written:`, err.message);
|
||||
}
|
||||
let acct = null;
|
||||
try {
|
||||
acct = require("./run-as").accountForUser(user.username);
|
||||
} catch {
|
||||
acct = null;
|
||||
}
|
||||
if (!acct) return;
|
||||
try {
|
||||
require("./run-as").handoffWrite(acct, FLAG, text);
|
||||
} catch (err) {
|
||||
console.warn(`user quota: ${user.username}'s flag not written:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── the sweep ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Users over their quota now (by the last sweep). */
|
||||
let blocked = new Set();
|
||||
/** user id → the flag text last written, so an unchanged state writes nothing. */
|
||||
const written = new Map();
|
||||
|
||||
function laneOwner(laneId) {
|
||||
return db().prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id ?? null;
|
||||
}
|
||||
|
||||
/** Is run `runId`'s workspace owner over quota (by the last sweep)? omp-share asks. */
|
||||
function isRunBlocked(runId) {
|
||||
if (!blocked.size) return false;
|
||||
const laneId = require("./pty-run").laneIdFromRunId(runId);
|
||||
return laneId != null && blocked.has(laneOwner(laneId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Can a hook refuse this pane's next prompt? Claude Code always (the plugin's
|
||||
* hooks, a member's managed-settings hooks); Codex only on the host's own
|
||||
* CODEX_HOME with our UserPromptSubmit hook installed and trusted (members
|
||||
* get no Codex hooks); a member's omp on the shared login through its
|
||||
* gateway key. Everything else must be ended.
|
||||
*/
|
||||
function hookCanStop(run, member) {
|
||||
if (run.tool === "claude" || run.tool === "shell") return true;
|
||||
if (run.tool === "codex" && !member) {
|
||||
try {
|
||||
const ev = require("./codex-hooks").status().events?.UserPromptSubmit;
|
||||
return !!(ev?.installed && ev?.trusted);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (run.tool === "omp" && member) {
|
||||
try {
|
||||
const share = require("./omp-share");
|
||||
return share.available() && share.enabled();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** End the live panes of `userIds` that no hook can stop. Returns the run ids ended. */
|
||||
function endUnstoppable(userIds) {
|
||||
if (!userIds.size) return [];
|
||||
const runs = require("./pty-run");
|
||||
const runAs = require("./run-as");
|
||||
const ended = [];
|
||||
for (const run of runs.listRuns()) {
|
||||
if (run.status !== "running" || !userIds.has(laneOwner(run.laneId))) continue;
|
||||
if (hookCanStop(run, runAs.accountForLane(run.laneId))) continue;
|
||||
try {
|
||||
if (runs.killRun(run.id)) ended.push(run.id);
|
||||
} catch (err) {
|
||||
console.warn(`user quota: ${run.id} not ended:`, err.message);
|
||||
}
|
||||
}
|
||||
return ended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute every user's state, rewrite the flags that changed, end the
|
||||
* panes no hook can stop and re-list the omp gateway's keys. Called every
|
||||
* minute by the server and right after an admin changes a quota.
|
||||
*/
|
||||
function sweep({ now = Date.now() } = {}) {
|
||||
if (!isServerMode()) return { blocked: [] };
|
||||
const users = db().prepare("SELECT id, username FROM users").all();
|
||||
const status = statusAll({ now });
|
||||
const next = new Set();
|
||||
for (const u of users) {
|
||||
const s = status.get(u.id) || null;
|
||||
if (s?.exceeded) next.add(u.id);
|
||||
const text = s?.exceeded ? `${s.resetsAt}|${s.limit_usd}` : "open";
|
||||
// A user never blocked has no flag to clear: nothing is written for them.
|
||||
if (written.get(u.id) === text || (!written.has(u.id) && !s?.exceeded && !hadFlag(u.id))) {
|
||||
continue;
|
||||
}
|
||||
writeFlags(u, s, now);
|
||||
written.set(u.id, text);
|
||||
}
|
||||
const changed = next.size !== blocked.size || [...next].some((id) => !blocked.has(id));
|
||||
blocked = next;
|
||||
const ended = endUnstoppable(next);
|
||||
if (changed) {
|
||||
try {
|
||||
require("./omp-share").syncClients();
|
||||
} catch {
|
||||
/* sharing off or not available */
|
||||
}
|
||||
}
|
||||
return { blocked: [...next], ended };
|
||||
}
|
||||
|
||||
/** A flag left by an earlier server (it may still say blocked). */
|
||||
function hadFlag(userId) {
|
||||
try {
|
||||
return fs.existsSync(hostFlag(userId));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: forget what the sweep remembered. */
|
||||
function __reset() {
|
||||
blocked = new Set();
|
||||
written.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PERIODS,
|
||||
getDefault,
|
||||
getOverride,
|
||||
effective,
|
||||
setDefault,
|
||||
setUserQuota,
|
||||
validateUserQuota,
|
||||
describe,
|
||||
windowOf,
|
||||
slidingReset,
|
||||
statusAll,
|
||||
statusFor,
|
||||
message,
|
||||
assertSpawnAllowed,
|
||||
flagEnv,
|
||||
isRunBlocked,
|
||||
hookCanStop,
|
||||
sweep,
|
||||
__reset,
|
||||
};
|
||||
@@ -228,6 +228,39 @@ async function listBranches(sourceRepo) {
|
||||
return { branches, current };
|
||||
}
|
||||
|
||||
/**
|
||||
* A repo's remote-tracking branches that have NO local branch of the same
|
||||
* name yet — what a fetch brought in that `listBranches` cannot show.
|
||||
* `[{name: "origin/feature", remote: "origin", branch: "feature"}]`, the
|
||||
* remotes' symbolic `HEAD`s left out. The remote's name is matched against
|
||||
* `git remote` (longest first), so a remote whose name holds a `/` splits right.
|
||||
*/
|
||||
async function listRemoteBranches(dir) {
|
||||
const remotes = (await git(dir, ["remote"])).stdout
|
||||
.split("\n")
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
if (!remotes.length) return [];
|
||||
const refs = (
|
||||
await git(dir, ["for-each-ref", "--format=%(refname:short)%00%(symref)", "refs/remotes"])
|
||||
).stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => l.split("\0"));
|
||||
const local = new Set((await listBranches(dir)).branches);
|
||||
const out = [];
|
||||
for (const [name, symref] of refs) {
|
||||
if (symref) continue; // origin/HEAD → origin/main
|
||||
const remote = remotes.find((r) => name.startsWith(`${r}/`));
|
||||
if (!remote) continue;
|
||||
const branch = name.slice(remote.length + 1);
|
||||
if (!branch || branch === "HEAD" || local.has(branch)) continue;
|
||||
out.push({ name, remote, branch });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the base branch: try origin/<wanted>, then <wanted>, then HEAD.
|
||||
*/
|
||||
@@ -978,6 +1011,7 @@ module.exports = {
|
||||
isGitRepo,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
listRemoteBranches,
|
||||
resolveBase,
|
||||
slugify,
|
||||
listWorktrees,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file OpenAPI fragment for the Artifacts hub (`/api/artifacts/*`, server/routes/artifacts.js)
|
||||
* and the Administration page's Server tab (`GET /api/host`, server/routes/host.js).
|
||||
* and the Dashboard System Health tab's machine cards (`GET /api/host` and `/api/host/history`, server/routes/host.js).
|
||||
* Hand-written from the routes; docs/API.md § Artifacts hub is the long form.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -14,7 +14,7 @@ const tags = [
|
||||
{
|
||||
name: "Host",
|
||||
description:
|
||||
"Administration → Server: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, staff only in server mode.",
|
||||
"Dashboard → System Health: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, every signed-in user.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -385,13 +385,36 @@ const paths = {
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.",
|
||||
"`{at, hostname, os, uptime, bootTime, cpu (+physicalCores, perCore), memory, disks, homesMount, gpus, usage, network, diskIo, temperatures, pressure, topProcesses, process}` — see docs/API.md.",
|
||||
content: { "application/json": { schema: { type: "object" } } },
|
||||
},
|
||||
500: { description: "EHOST" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/host/history": {
|
||||
get: {
|
||||
tags: ["Host"],
|
||||
summary: "The machine's CPU/memory history since the server started",
|
||||
operationId: "getHostHistory",
|
||||
description:
|
||||
"In memory only: one point every 30 s for 24 h, kept by a server-mode server on Linux (empty otherwise, `running: false`). Averaged down to at most 360 points. Server mode: admin/analyst only.",
|
||||
parameters: [
|
||||
{
|
||||
name: "range",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1h", "6h", "24h"], default: "1h" },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<userId>: {cpu, memory}}}]}`.",
|
||||
content: { "application/json": { schema: { type: "object" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
|
||||
* - GET/PUT /api/me/pool-accounts (Auth) — the account's own pick of a pool account per tool
|
||||
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
|
||||
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too)
|
||||
* - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user
|
||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
||||
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
||||
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
|
||||
@@ -36,6 +38,48 @@ const tags = [
|
||||
];
|
||||
|
||||
const schemas = {
|
||||
UserQuota: {
|
||||
type: "object",
|
||||
description:
|
||||
"A spend quota: `limit_usd` per `period` — `day` from the local midnight of `tz_offset` (getTimezoneOffset() minutes), or `5h`, the current UTC hour and the four before it.",
|
||||
required: ["limit_usd", "period"],
|
||||
properties: {
|
||||
limit_usd: { type: "number", minimum: 0, exclusiveMinimum: true },
|
||||
period: { type: "string", enum: ["day", "5h"] },
|
||||
tz_offset: { type: "integer", minimum: -840, maximum: 840 },
|
||||
source: { type: "string", enum: ["default", "user"], readOnly: true },
|
||||
},
|
||||
},
|
||||
UserQuotaSetting: {
|
||||
type: "object",
|
||||
description:
|
||||
"A user's own quota setting: `default` (the default applies), `unlimited`, or `custom` with its own limit.",
|
||||
required: ["mode"],
|
||||
properties: {
|
||||
mode: { type: "string", enum: ["default", "unlimited", "custom"] },
|
||||
limit_usd: { type: "number" },
|
||||
period: { type: "string", enum: ["day", "5h"] },
|
||||
tz_offset: { type: "integer" },
|
||||
},
|
||||
},
|
||||
UserQuotaStatus: {
|
||||
allOf: [
|
||||
{ $ref: "#/components/schemas/UserQuota" },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
spend: { type: "number", description: "USD spent in the current window" },
|
||||
pct: { type: "number" },
|
||||
exceeded: {
|
||||
type: "boolean",
|
||||
description: "Used up: new panes are refused (EQUOTA) and prompts refused by the hooks",
|
||||
},
|
||||
resetsAt: { type: "string", nullable: true, description: "When it opens again" },
|
||||
since: { type: "string", description: "Start of the current window" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
AccountPin: {
|
||||
type: "object",
|
||||
required: ["userId", "username", "account"],
|
||||
@@ -213,6 +257,32 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/quota": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "getMyQuota",
|
||||
summary: "The account's own spend quota now (null without one, and in local mode)",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Quota",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
quota: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuotaStatus" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: err("EAUTH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/instructions": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
@@ -243,6 +313,103 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/pool-accounts": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "The account pool as this account may use it (server mode)",
|
||||
description:
|
||||
"Per tool with at least one account the account's pool group allows: those accounts (never a token), an admin's pin and the account's own choice. Local mode: {tools: []}.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tools",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tools"],
|
||||
properties: {
|
||||
tools: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["tool", "pin", "choice", "accounts"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
pin: { type: "string", nullable: true },
|
||||
choice: { type: "string", nullable: true },
|
||||
accounts: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
kind: { type: "string" },
|
||||
email: { type: "string", nullable: true },
|
||||
expired: { type: "boolean" },
|
||||
quotaPercent: { type: "number", nullable: true },
|
||||
full: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: err("EAUTH"),
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Auth"],
|
||||
summary: "Run this account's new sessions of a tool on one pool account (null = automatic)",
|
||||
description:
|
||||
"Below an admin's pin, above the balancer. A Claude login goes into the account's home at once (`switched`); Codex and Cursor take it at the next session. Login cookie only, audited.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tool"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
account: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Chosen",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tool: { type: "string" },
|
||||
choice: { type: "string", nullable: true },
|
||||
switched: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
enum: ["switched", "kept", "own-login", "expired", "next-session"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: err("EBADTOOL"),
|
||||
401: err("EAUTH"),
|
||||
403: err("ELOCALMODE"),
|
||||
404: err("ENOACCOUNT — not in the pool, or not one the account's group may use"),
|
||||
409: err("ELOCKED — an admin pinned this account's sessions of the tool"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/onboarded": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
@@ -406,11 +573,20 @@ const paths = {
|
||||
type: "object",
|
||||
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
||||
},
|
||||
quotaSetting: { $ref: "#/components/schemas/UserQuotaSetting" },
|
||||
quota: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuotaStatus" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
quotaDefault: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -565,6 +741,28 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/details": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "getServerUserDetails",
|
||||
summary:
|
||||
"One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account's quota beside this user's own 5 h / 7 d share. Never a token",
|
||||
parameters: [
|
||||
idParam,
|
||||
{
|
||||
name: "tz_offset",
|
||||
in: "query",
|
||||
schema: { type: "integer" },
|
||||
description: "The browser's getTimezoneOffset(), for the daily buckets",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: { description: "Account details" },
|
||||
403: err("EADMIN — a developer"),
|
||||
404: err("ENOUSER"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
@@ -592,10 +790,11 @@ const paths = {
|
||||
addWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
signOut: { type: "boolean", enum: [true] },
|
||||
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
|
||||
}),
|
||||
responses: {
|
||||
...changeResponses,
|
||||
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"),
|
||||
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA"),
|
||||
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
|
||||
},
|
||||
},
|
||||
@@ -614,6 +813,44 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/quota-default": {
|
||||
put: {
|
||||
tags: ["Auth"],
|
||||
operationId: "setDefaultUserQuota",
|
||||
summary:
|
||||
"Admin: the default spend quota for every user without one of their own (`quota: null` removes it). A user over their quota can start no pane (429 EQUOTA) and their Claude Code/Codex prompts are refused by the hooks",
|
||||
requestBody: changeBody(
|
||||
{
|
||||
quota: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
["quota"]
|
||||
),
|
||||
responses: {
|
||||
...changeResponses,
|
||||
200: {
|
||||
description: "Saved",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user: { $ref: "#/components/schemas/AuthUser" },
|
||||
quotaDefault: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: err("EBADQUOTA"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/password": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -854,13 +854,13 @@ const paths = {
|
||||
"/api/lanes/{id}/git/branches": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The lane repo's own local branches",
|
||||
summary: "The lane repo's own local branches, and the remote ones not checked out yet",
|
||||
description:
|
||||
"Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
|
||||
"Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneOwnBranches",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "{available, branches, current, worktrees}." },
|
||||
200: { description: "{available, branches, remotes, current, worktrees}." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user