Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38ea78b489 | |||
| 7b5abc6ad9 | |||
| ca55ff8ec5 | |||
| 0841d3e5c8 | |||
| 33d30f5e1f | |||
| 14e979837e | |||
| fa27b299e7 | |||
| 9e3d6f4ca1 | |||
| 26aabd6660 |
@@ -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/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/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` |
|
| `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 |
|
| `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()` |
|
| `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 |
|
| `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 |
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @file HtmlFrame.tsx
|
||||||
|
* @description An HTML file rendered as a page, the one way every file view
|
||||||
|
* shows one (a Chat file preview, the Changes panel's Preview, the File
|
||||||
|
* Explorer's viewer): its relative CSS, scripts and images pulled in through
|
||||||
|
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
|
||||||
|
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
|
||||||
|
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
|
||||||
|
* file itself through the same `fetchFile`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FileText, Loader2 } from "lucide-react";
|
||||||
|
import { artifactDocument, frameSandbox } from "../lib/artifactView";
|
||||||
|
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
|
||||||
|
|
||||||
|
/** A page read by `fetchFile` is shown up to this size. */
|
||||||
|
const HTML_MAX = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function HtmlFrame({
|
||||||
|
path,
|
||||||
|
html,
|
||||||
|
fetchFile,
|
||||||
|
title,
|
||||||
|
className = "",
|
||||||
|
testId = "html-frame",
|
||||||
|
}: {
|
||||||
|
/** The file's path relative to the place `fetchFile` reads from. */
|
||||||
|
path: string;
|
||||||
|
/** The page's text; read through `fetchFile` when absent. */
|
||||||
|
html?: string;
|
||||||
|
/** Reads the page's relative files; none = the page as it is. */
|
||||||
|
fetchFile?: FetchFile | null;
|
||||||
|
title: string;
|
||||||
|
className?: string;
|
||||||
|
testId?: string;
|
||||||
|
}) {
|
||||||
|
const [doc, setDoc] = useState<string | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
// The page shown stays until its successor is ready (a version step, a
|
||||||
|
// re-read): no flash of the spinner between two renders of one file.
|
||||||
|
setFailed(false);
|
||||||
|
(async () => {
|
||||||
|
let text = html;
|
||||||
|
if (text === undefined) {
|
||||||
|
if (!fetchFile) throw new Error("nothing to read the page with");
|
||||||
|
const b = await fetchFile(path);
|
||||||
|
if (b.size > HTML_MAX) throw new Error("too big");
|
||||||
|
text = await blobText(b);
|
||||||
|
}
|
||||||
|
const page = text;
|
||||||
|
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
|
||||||
|
})().then(
|
||||||
|
(d) => live && setDoc(d),
|
||||||
|
() => live && setFailed(true)
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [html, path, fetchFile]);
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||||
|
<FileText className="h-8 w-8" />
|
||||||
|
<p className="break-all font-mono text-xs">{path}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (doc === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
data-testid={testId}
|
||||||
|
title={title}
|
||||||
|
srcDoc={artifactDocument("html", doc, false) ?? ""}
|
||||||
|
sandbox={frameSandbox("html")}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
|
||||||
|
export function fetchVia(url: (relPath: string) => string): FetchFile {
|
||||||
|
return async (rel) => {
|
||||||
|
const r = await fetch(url(rel), { credentials: "same-origin" });
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
return r.blob();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
* its fullest window, live account only — and, on hover or focus, a panel
|
* its fullest window, live account only — and, on hover or focus, a panel
|
||||||
* above grouping every account per tool (saved Claude profiles too): name,
|
* 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.
|
* 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>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
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 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 { AiToolIcon } from "./AiToolIcon";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
import { useSpin } from "../hooks/useSpin";
|
import { useSpin } from "../hooks/useSpin";
|
||||||
@@ -369,6 +372,8 @@ export function StatusBar({
|
|||||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||||
// The budget furthest past one of its thresholds, if any.
|
// The budget furthest past one of its thresholds, if any.
|
||||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
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 location = useLocation();
|
||||||
const shown = selectedFrom(location.pathname, location.search);
|
const shown = selectedFrom(location.pathname, location.search);
|
||||||
const lane = shown.lane;
|
const lane = shown.lane;
|
||||||
@@ -414,6 +419,10 @@ export function StatusBar({
|
|||||||
if (live()) setBudget(hit[0] ?? null);
|
if (live()) setBudget(hit[0] ?? null);
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
api.me
|
||||||
|
.quota()
|
||||||
|
.then((r) => live() && setMyQuota(r?.quota ?? null))
|
||||||
|
.catch(() => undefined);
|
||||||
if (lane !== null) {
|
if (lane !== null) {
|
||||||
api.pricing
|
api.pricing
|
||||||
.totalCost({ lane })
|
.totalCost({ lane })
|
||||||
@@ -524,6 +533,26 @@ export function StatusBar({
|
|||||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||||
</Link>
|
</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 />
|
<QuotaChips />
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
|
|||||||
daily_costs: [],
|
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: {
|
budgets: {
|
||||||
status: vi.fn().mockResolvedValue({
|
status: vi.fn().mockResolvedValue({
|
||||||
budgets: [
|
budgets: [
|
||||||
@@ -183,6 +197,16 @@ describe("StatusBar", () => {
|
|||||||
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
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", () => {
|
it("says Disconnected when the socket is down", () => {
|
||||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
* (the clipboard outlives the dialog); upload files or whole folders (picker
|
* (the clipboard outlives the dialog); upload files or whole folders (picker
|
||||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||||
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
* itself, anything else as one zip — except an HTML file, which opens as the
|
||||||
|
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
|
||||||
* Skip. Deleting forever asks first when it empties the Trash or touches
|
* Skip. Deleting forever asks first when it empties the Trash or touches
|
||||||
* another user's. An admin's Trash opens on one folder per user (with its
|
* another user's. An admin's Trash opens on one folder per user (with its
|
||||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||||
@@ -90,6 +91,7 @@ import {
|
|||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
import { HtmlFrame, fetchVia } from "../HtmlFrame";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { formatBytes } from "../../lib/format";
|
import { formatBytes } from "../../lib/format";
|
||||||
@@ -166,6 +168,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
|||||||
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||||
const errCode = (e: unknown) => (e as { code?: string })?.code;
|
const errCode = (e: unknown) => (e as { code?: string })?.code;
|
||||||
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
|
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
|
||||||
|
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
|
||||||
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||||
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
|
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
|
||||||
const CODE = new Set([
|
const CODE = new Set([
|
||||||
@@ -498,8 +501,11 @@ export function FileBrowser({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Actions ─────────────────────────────────────────────────────────────
|
// ── Actions ─────────────────────────────────────────────────────────────
|
||||||
|
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
|
||||||
|
const [viewing, setViewing] = useState<FsEntry | null>(null);
|
||||||
const openEntry = (e: FsEntry) => {
|
const openEntry = (e: FsEntry) => {
|
||||||
if (isFolder(e)) go(e.path);
|
if (isFolder(e)) go(e.path);
|
||||||
|
else if (isHtml(e)) setViewing(e);
|
||||||
else download([e]);
|
else download([e]);
|
||||||
};
|
};
|
||||||
const goUp = () => {
|
const goUp = () => {
|
||||||
@@ -970,6 +976,14 @@ export function FileBrowser({
|
|||||||
onClick={() => setMenu(null)}
|
onClick={() => setMenu(null)}
|
||||||
data-testid="file-explorer"
|
data-testid="file-explorer"
|
||||||
>
|
>
|
||||||
|
{viewing && (
|
||||||
|
<HtmlViewer
|
||||||
|
entry={viewing}
|
||||||
|
place={target}
|
||||||
|
onDownload={() => download([viewing])}
|
||||||
|
onClose={() => setViewing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{cloneParent && (
|
{cloneParent && (
|
||||||
<CloneHereDialog
|
<CloneHereDialog
|
||||||
open={cloning}
|
open={cloning}
|
||||||
@@ -1656,13 +1670,18 @@ export function FileBrowser({
|
|||||||
{menuItem ? (
|
{menuItem ? (
|
||||||
<>
|
<>
|
||||||
<MenuItem onSelect={close(() => openEntry(menuItem))}>
|
<MenuItem onSelect={close(() => openEntry(menuItem))}>
|
||||||
{isFolder(menuItem) ? t("fx.open") : t("fx.download")}
|
{isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{(isFolder(menuItem) || menuTargets.length > 1) && (
|
{(isFolder(menuItem) || menuTargets.length > 1) && (
|
||||||
<MenuItem onSelect={close(() => download(menuTargets))}>
|
<MenuItem onSelect={close(() => download(menuTargets))}>
|
||||||
{t("fx.downloadZip")}
|
{t("fx.downloadZip")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isHtml(menuItem) && menuTargets.length === 1 && (
|
||||||
|
<MenuItem onSelect={close(() => download([menuItem]))}>
|
||||||
|
{t("fx.download")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
{!ro && (
|
{!ro && (
|
||||||
<MenuItem onSelect={close(() => putClip(menuTargets, true))}>
|
<MenuItem onSelect={close(() => putClip(menuTargets, true))}>
|
||||||
@@ -1916,6 +1935,67 @@ export function FileExplorer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An HTML file shown as the page, over the explorer: its relative CSS,
|
||||||
|
* scripts and images read through the same download route (`HtmlFrame`).
|
||||||
|
* Keys and clicks stop here — through the portal they would otherwise reach
|
||||||
|
* the explorer's own shortcuts (Delete would trash the selection). */
|
||||||
|
function HtmlViewer({
|
||||||
|
entry,
|
||||||
|
place,
|
||||||
|
onDownload,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
entry: FsEntry;
|
||||||
|
place: FsPlace;
|
||||||
|
onDownload: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDialog(true, panelRef, onClose);
|
||||||
|
const placeKey = JSON.stringify(place);
|
||||||
|
const fetchFile = useMemo(
|
||||||
|
() => fetchVia((p) => api.files.downloadUrl(place, p)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[placeKey]
|
||||||
|
);
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={entry.name}
|
||||||
|
data-testid="fx-html-viewer"
|
||||||
|
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
|
||||||
|
{entry.path}
|
||||||
|
</span>
|
||||||
|
<HeadButton label={t("fx.download")} onClick={onDownload}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</HeadButton>
|
||||||
|
<HeadButton label={t("fx.close")} onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</HeadButton>
|
||||||
|
</div>
|
||||||
|
<HtmlFrame path={entry.path} fetchFile={fetchFile} title={entry.name} />
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HeadButton({
|
function HeadButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
* side, each block with the diff's Undo / Keep while nothing
|
* side, each block with the diff's Undo / Keep while nothing
|
||||||
* is unsaved): Changes folds the unchanged lines away, Whole file
|
* is unsaved): Changes folds the unchanged lines away, Whole file
|
||||||
* shows them; any other file opens on its read-only diff; a
|
* shows them; any other file opens on its read-only diff; a
|
||||||
* whole markdown file can also be read rendered (Preview), and an
|
* whole markdown file can also be read rendered (Preview), an HTML
|
||||||
|
* file opens as the page itself (`HtmlFrame`, its relative CSS,
|
||||||
|
* scripts and images read like downloads), and an
|
||||||
* image file is shown as the picture (`GET …/git/raw`)
|
* image file is shown as the picture (`GET …/git/raw`)
|
||||||
* history — recent commits; opening one shows that commit's diff
|
* history — recent commits; opening one shows that commit's diff
|
||||||
* browse — quick-open by path, or the repo's tracked file tree (the
|
* browse — quick-open by path, or the repo's tracked file tree (the
|
||||||
@@ -77,7 +79,16 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import {
|
||||||
|
Suspense,
|
||||||
|
lazy,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -127,6 +138,7 @@ import { LaneConflictView } from "./LaneConflictView";
|
|||||||
import { FileExplorer } from "./FileExplorer";
|
import { FileExplorer } from "./FileExplorer";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||||
|
import { HtmlFrame, fetchVia } from "../HtmlFrame";
|
||||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||||
import { dragProjectPath } from "../../lib/promptAttach";
|
import { dragProjectPath } from "../../lib/promptAttach";
|
||||||
@@ -185,6 +197,9 @@ function Delta({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An HTML page, which the viewer can show rendered (`HtmlFrame`). */
|
||||||
|
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
|
||||||
|
|
||||||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||||||
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
||||||
// CodeMirror is loaded only once a file is edited.
|
// CodeMirror is loaded only once a file is edited.
|
||||||
@@ -874,10 +889,11 @@ export function LaneChangesPanel({
|
|||||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||||
const [whole, setWhole] = useState(false);
|
const [whole, setWhole] = useState(false);
|
||||||
// A whole markdown file shown rendered instead of as source; per file, each
|
// A whole markdown or HTML file shown rendered instead of as source; per
|
||||||
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
|
// file, markdown opening as Settings → Appearance says
|
||||||
|
// (`lib/markdownView.ts`), an HTML page always as the page.
|
||||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||||
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
|
useEffect(() => setMdPreview(isHtml(openFile) || readMarkdownPreview()), [openFile]);
|
||||||
const [data, setData] = useState<LaneChanges | null>(null);
|
const [data, setData] = useState<LaneChanges | null>(null);
|
||||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||||
// has no changes, review or history — only its files, which Files reads
|
// has no changes, review or history — only its files, which Files reads
|
||||||
@@ -2042,8 +2058,16 @@ export function LaneChangesPanel({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||||
// Rendered only where the toggle is offered: a whole markdown file.
|
// Rendered only where the toggle is offered: a whole markdown or HTML file.
|
||||||
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
const mdShown = mdPreview && whole && (isMarkdown(openFile) || isHtml(openFile));
|
||||||
|
// An HTML page's relative CSS/scripts/images, read like a download — only
|
||||||
|
// from the working tree: a commit's page would get today's files.
|
||||||
|
const htmlFetch = useMemo(
|
||||||
|
() =>
|
||||||
|
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[range, JSON.stringify(target)]
|
||||||
|
);
|
||||||
dirtyRef.current = dirty;
|
dirtyRef.current = dirty;
|
||||||
useUnsavedGuard(dirty);
|
useUnsavedGuard(dirty);
|
||||||
const dirtyChangeRef = useRef(onDirtyChange);
|
const dirtyChangeRef = useRef(onDirtyChange);
|
||||||
@@ -2969,7 +2993,7 @@ export function LaneChangesPanel({
|
|||||||
{t("changes.annotate")}
|
{t("changes.annotate")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{whole && text && !dirty && isMarkdown(openFile) && (
|
{whole && text && !dirty && (isMarkdown(openFile) || isHtml(openFile)) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="lane-file-preview"
|
data-testid="lane-file-preview"
|
||||||
@@ -3113,7 +3137,17 @@ export function LaneChangesPanel({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
) : whole ? (
|
) : whole ? (
|
||||||
text && mdShown ? (
|
text && mdShown && isHtml(openFile) ? (
|
||||||
|
<HtmlFrame
|
||||||
|
key={openFile}
|
||||||
|
path={text.path}
|
||||||
|
// A cut-off page is read whole through the download.
|
||||||
|
html={text.truncated && htmlFetch ? undefined : text.lines.join("\n")}
|
||||||
|
fetchFile={htmlFetch}
|
||||||
|
title={text.path}
|
||||||
|
className="rounded border border-border-light"
|
||||||
|
/>
|
||||||
|
) : text && mdShown ? (
|
||||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||||
<MarkdownPreview text={text.lines.join("\n")} />
|
<MarkdownPreview text={text.lines.join("\n")} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
* @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 —
|
* 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
|
* 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
|
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||||
* Changes panel's job). Committing is
|
* Changes panel's job). Committing is
|
||||||
@@ -114,6 +116,8 @@ export function LaneGitBar({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [switching, setSwitching] = useState(false);
|
const [switching, setSwitching] = useState(false);
|
||||||
const [branches, setBranches] = useState<string[] | null>(null);
|
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 [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||||
const [picked, setPicked] = useState("");
|
const [picked, setPicked] = useState("");
|
||||||
const [newBranch, setNewBranch] = useState("");
|
const [newBranch, setNewBranch] = useState("");
|
||||||
@@ -138,6 +142,7 @@ export function LaneGitBar({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSwitching(false);
|
setSwitching(false);
|
||||||
setBranches(null);
|
setBranches(null);
|
||||||
|
setRemoteBranches([]);
|
||||||
setWorktrees([]);
|
setWorktrees([]);
|
||||||
setPicked("");
|
setPicked("");
|
||||||
setNewBranch("");
|
setNewBranch("");
|
||||||
@@ -152,6 +157,9 @@ export function LaneGitBar({
|
|||||||
|
|
||||||
if (!git.available) return null;
|
if (!git.available) return null;
|
||||||
const dirty = git.dirty + git.untracked;
|
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 merging = !!git.merging;
|
||||||
|
|
||||||
const run = async (action: Action, body?: RunBody) => {
|
const run = async (action: Action, body?: RunBody) => {
|
||||||
@@ -184,6 +192,9 @@ export function LaneGitBar({
|
|||||||
setPicked("");
|
setPicked("");
|
||||||
setBranches(null);
|
setBranches(null);
|
||||||
await refreshBranches();
|
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();
|
onChanged();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -217,6 +228,7 @@ export function LaneGitBar({
|
|||||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
||||||
if (laneNow.current !== laneId) return;
|
if (laneNow.current !== laneId) return;
|
||||||
setBranches(r.branches);
|
setBranches(r.branches);
|
||||||
|
setRemoteBranches(r.remotes || []);
|
||||||
setWorktrees(r.worktrees || []);
|
setWorktrees(r.worktrees || []);
|
||||||
} catch {
|
} catch {
|
||||||
if (laneNow.current === laneId) setBranches([]);
|
if (laneNow.current === laneId) setBranches([]);
|
||||||
@@ -446,6 +458,15 @@ export function LaneGitBar({
|
|||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{remoteBranches.length > 0 && (
|
||||||
|
<optgroup label={t("gitBar.remoteBranches")}>
|
||||||
|
{remoteBranches.map((b) => (
|
||||||
|
<option key={`remote:${b}`} value={b}>
|
||||||
|
{b}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<Btn
|
<Btn
|
||||||
label={t("gitBar.switchApply")}
|
label={t("gitBar.switchApply")}
|
||||||
@@ -476,7 +497,7 @@ export function LaneGitBar({
|
|||||||
})
|
})
|
||||||
: t("gitBar.newLaneHint")
|
: 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,
|
// The sibling lane is created through the normal Add-lane flow,
|
||||||
// prefilled — one provisioning path, not a second one here.
|
// prefilled — one provisioning path, not a second one here.
|
||||||
onClick={() => onNewLane(picked)}
|
onClick={() => onNewLane(picked)}
|
||||||
@@ -486,7 +507,7 @@ export function LaneGitBar({
|
|||||||
label={t("gitBar.branchDelete")}
|
label={t("gitBar.branchDelete")}
|
||||||
icon={<Trash2 className="h-3 w-3" />}
|
icon={<Trash2 className="h-3 w-3" />}
|
||||||
busy={busy === "branch-delete"}
|
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")}
|
title={t("gitBar.branchDeleteHint")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void confirmRun(
|
void confirmRun(
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
* purge there, sending the item's store) and
|
* purge there, sending the item's store) and
|
||||||
* shows a Trash that could not be read as an error, never as empty; nests a
|
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||||
* user's workspaces under their home in the sidebar (under a fold-only row
|
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||||
* when the viewer may not open that home).
|
* when the viewer may not open that home); opens an HTML file as the page
|
||||||
|
* instead of downloading it.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -136,6 +137,38 @@ describe("FileExplorer", () => {
|
|||||||
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
|
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
|
||||||
|
vi.mocked(api.files.list).mockResolvedValue({
|
||||||
|
...root,
|
||||||
|
entries: [
|
||||||
|
...root.entries,
|
||||||
|
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const body: Record<string, string> = {
|
||||||
|
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
|
||||||
|
"/dl/1/s.css": "p{color:red}",
|
||||||
|
};
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
|
||||||
|
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||||
|
open();
|
||||||
|
await screen.findByText("deck.html");
|
||||||
|
fireEvent.doubleClick(row("deck.html"));
|
||||||
|
const viewer = await screen.findByTestId("fx-html-viewer");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
|
||||||
|
"<style>p{color:red}</style>"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(click).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
|
||||||
|
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
click.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("makes a folder inline", async () => {
|
it("makes a folder inline", async () => {
|
||||||
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
|
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -544,6 +544,34 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a whole HTML file as the page, its source after Raw", async () => {
|
||||||
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
path: "",
|
||||||
|
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
path: "deck.html",
|
||||||
|
rev: "work",
|
||||||
|
binary: false,
|
||||||
|
truncated: false,
|
||||||
|
lines: ["<p>deck</p>"],
|
||||||
|
sha: "c".repeat(40),
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} full />);
|
||||||
|
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||||
|
await user.click(await within(sidebar).findByText("deck.html"));
|
||||||
|
const frame = await screen.findByTestId("html-frame");
|
||||||
|
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
|
||||||
|
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||||
|
await user.click(screen.getByTestId("lane-file-preview"));
|
||||||
|
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||||
|
expect(screen.queryByTestId("html-frame")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows an image file as the picture, with no edit control", async () => {
|
it("shows an image file as the picture, with no edit control", async () => {
|
||||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
* 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
|
* 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
|
* 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.
|
* a generic sentence.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -236,6 +237,43 @@ describe("LaneGitBar", () => {
|
|||||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
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 () => {
|
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onNewLane = vi.fn();
|
const onNewLane = vi.fn();
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { useDialog } from "../../hooks/useDialog";
|
|||||||
import { dragSkill } from "../../lib/promptAttach";
|
import { dragSkill } from "../../lib/promptAttach";
|
||||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||||
import { MarkdownPreview } from "../MarkdownPreview";
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
|
import { HtmlFrame, fetchVia } from "../HtmlFrame";
|
||||||
import { CodeBlock } from "../conversation/CodeBlock";
|
import { CodeBlock } from "../conversation/CodeBlock";
|
||||||
import { useTheme } from "../../hooks/useTheme";
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
@@ -85,6 +86,18 @@ export const extOf = (p: string) => {
|
|||||||
export const displayName = (p: string) =>
|
export const displayName = (p: string) =>
|
||||||
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
|
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
|
||||||
|
|
||||||
|
/** A file inside the pane's folder: the git target that reads it and its path there. */
|
||||||
|
function workspaceFile(path: string, scope: PreviewScope) {
|
||||||
|
const file = slashed(path);
|
||||||
|
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||||
|
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
|
||||||
|
const target =
|
||||||
|
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||||
|
? scope.laneId
|
||||||
|
: gitTarget(scope.laneId, scope.sessionId);
|
||||||
|
return { target, rel: file.slice(base.length + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
type Content =
|
type Content =
|
||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
| { kind: "image"; src: string }
|
| { kind: "image"; src: string }
|
||||||
@@ -121,8 +134,8 @@ function useContent(path: string, scope: PreviewScope): Content {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const file = slashed(path);
|
const file = slashed(path);
|
||||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
const inside = workspaceFile(path, scope);
|
||||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
|
if (!inside) {
|
||||||
// Claude Code's own files (memory, a skill): the config pages' guarded read.
|
// Claude Code's own files (memory, a skill): the config pages' guarded read.
|
||||||
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
|
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
|
||||||
show(
|
show(
|
||||||
@@ -147,11 +160,7 @@ function useContent(path: string, scope: PreviewScope): Content {
|
|||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rel = file.slice(base.length + 1);
|
const { target, rel } = inside;
|
||||||
const target =
|
|
||||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
|
||||||
? scope.laneId
|
|
||||||
: gitTarget(scope.laneId, scope.sessionId);
|
|
||||||
if (IMAGE_RE.test(path)) {
|
if (IMAGE_RE.test(path)) {
|
||||||
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
|
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
|
||||||
return;
|
return;
|
||||||
@@ -235,9 +244,28 @@ function defaultView(path: string): View {
|
|||||||
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
|
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
|
||||||
}
|
}
|
||||||
|
|
||||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
function Body({
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
view,
|
||||||
|
scope,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
content: Content;
|
||||||
|
view: View;
|
||||||
|
scope: PreviewScope;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const kind = artifactKind(path);
|
const kind = artifactKind(path);
|
||||||
|
// A page in the workspace brings its relative CSS, scripts and images along.
|
||||||
|
const inside = kind === "html" ? workspaceFile(path, scope) : null;
|
||||||
|
const insideTarget = inside ? JSON.stringify(inside.target) : null;
|
||||||
|
const page = useMemo(() => {
|
||||||
|
if (!inside) return null;
|
||||||
|
const { target, rel } = inside;
|
||||||
|
return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [insideTarget, inside?.rel]);
|
||||||
// An image the route refused (gone, server mode) shows like no preview.
|
// An image the route refused (gone, server mode) shows like no preview.
|
||||||
const [broken, setBroken] = useState<string | null>(null);
|
const [broken, setBroken] = useState<string | null>(null);
|
||||||
const shown: Content =
|
const shown: Content =
|
||||||
@@ -290,6 +318,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
||||||
|
if (page && !shown.truncated) {
|
||||||
|
return (
|
||||||
|
<HtmlFrame
|
||||||
|
key={path}
|
||||||
|
path={page.rel}
|
||||||
|
html={shown.text}
|
||||||
|
fetchFile={page.fetchFile}
|
||||||
|
title={baseName(path)}
|
||||||
|
testId="artifact-frame"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +543,7 @@ export function FilePreview({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<Body path={path} content={content} view={view} />
|
<Body path={path} content={content} view={view} scope={scope} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
||||||
|
|||||||
@@ -242,7 +242,9 @@ describe("the artifact view", () => {
|
|||||||
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
|
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
|
||||||
).toContain("two");
|
).toContain("two");
|
||||||
fireEvent.click(versions.querySelector("button")!);
|
fireEvent.click(versions.querySelector("button")!);
|
||||||
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
|
await vi.waitFor(() =>
|
||||||
|
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>")
|
||||||
|
);
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -800,7 +800,7 @@ function ProfileUsageLine({
|
|||||||
|
|
||||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
||||||
Boolean
|
Boolean
|
||||||
);
|
);
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @file AccountPoolCard.tsx
|
* @file AccountPoolCard.tsx
|
||||||
* @description The Users page's account pool card (`GET /api/accounts`), one
|
* @description The Users page's account pool card (`GET /api/accounts`).
|
||||||
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
|
* Claude Code, Codex and Cursor accounts live on the POOLS board
|
||||||
* Codex and Cursor have a pool — the accounts every user's new sessions of that
|
* (`PoolGroups`): one column per pool with its accounts of every tool — each
|
||||||
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
|
* a card with its live panes (and, for Claude/Codex, $ and tokens in the last
|
||||||
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
|
* 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
|
||||||
* Oh My Pi has no pool but can share the host's login (the same switch as
|
* tools with no pool get a tab each below it: Oh My Pi can share the host's
|
||||||
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
|
* login (the same switch as Settings → AI tools,
|
||||||
* other installed tool says that its users sign in themselves. A tab shows
|
* `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
|
||||||
* how many accounts its pool holds, and an empty pool says so instead of
|
* says that its users sign in themselves. An admin adds an account from the
|
||||||
* vanishing. An admin adds an account to the open tab's tool — for Claude a
|
* header's Add account, picking its tool — for Claude a
|
||||||
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
||||||
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
||||||
* `claude setup-token` token; for Codex a saved Codex profile
|
* `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;
|
* 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
|
* 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
|
* 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
|
* Claude login goes into their home at once — no restart. A profile's
|
||||||
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
* "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
|
* which opens Add on that profile; the header links back to the profiles.
|
||||||
* to the profiles.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
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 {
|
import {
|
||||||
api,
|
api,
|
||||||
type AccountPin,
|
type AccountPin,
|
||||||
@@ -47,10 +47,10 @@ import { isSubmitEnter } from "../../lib/keys";
|
|||||||
import { useSpin } from "../../hooks/useSpin";
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { Checkbox } from "../Checkbox";
|
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
||||||
import { PoolGroups, groupsOf } from "./PoolGroups";
|
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
|
||||||
|
import { MemberPicker } from "./MemberPicker";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
tool: PoolTool;
|
tool: PoolTool;
|
||||||
@@ -69,6 +69,8 @@ type Row = {
|
|||||||
full: boolean;
|
full: boolean;
|
||||||
/** The 5-hour and weekly windows, as the account last reported them. */
|
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||||
windows: QuotaWindow[];
|
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. */
|
/** One quota window of an account: % used and when it rolls over. */
|
||||||
@@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null {
|
|||||||
|
|
||||||
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
||||||
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
||||||
/** Fixed tabs, always shown; any other installed tool follows them. */
|
/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
|
||||||
const FIXED_TABS: { id: string; label: string }[] = [
|
const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
|
||||||
{ id: "claude", label: "Claude Code" },
|
|
||||||
{ id: "codex", label: "Codex" },
|
|
||||||
{ id: "cursor", label: "Cursor CLI" },
|
|
||||||
{ id: "omp", label: "Oh My Pi" },
|
|
||||||
];
|
|
||||||
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||||
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||||
|
|
||||||
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const profile = params.get("poolProfile");
|
const profile = params.get("poolProfile");
|
||||||
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
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.
|
// What Add opens on: a tool, and a saved profile to share into its pool.
|
||||||
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
||||||
isAdmin && preset ? preset : null
|
isAdmin && preset ? preset : null
|
||||||
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
...FIXED_TABS,
|
...FIXED_TABS,
|
||||||
...aiTools.tools
|
...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 })),
|
.map((x) => ({ id: x.id, label: x.label })),
|
||||||
];
|
];
|
||||||
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
||||||
const badge = (id: string): string | null => {
|
const badge = (id: string): string | null =>
|
||||||
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
|
id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||||
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
const rowOf = (tool: PoolTool, name: string) =>
|
||||||
return null;
|
pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
<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">
|
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
|
||||||
{t("pool.profilesLink")}
|
{t("pool.profilesLink")}
|
||||||
</Link>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={t("pool.title")}
|
aria-label={t("pool.otherTools")}
|
||||||
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
|
||||||
>
|
>
|
||||||
{tabs.map((x) => {
|
{tabs.map((x) => {
|
||||||
const selected = x.id === current.id;
|
const selected = x.id === current.id;
|
||||||
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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}`}>
|
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||||
{isPoolTool(current.id) ? (
|
{current.id === "omp" ? (
|
||||||
<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" ? (
|
|
||||||
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||||
) : (
|
) : (
|
||||||
<NoPoolPanel id={current.id} label={current.label} />
|
<NoPoolPanel id={current.id} label={current.label} />
|
||||||
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{adding && (
|
{adding && (
|
||||||
<AddAccountDialog
|
<AddAccountDialog
|
||||||
tool={adding.tool}
|
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}
|
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||||
onClose={closeAdd}
|
onClose={closeAdd}
|
||||||
onDone={(name) => {
|
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. */
|
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
|
||||||
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. */
|
|
||||||
function AccountCard({
|
function AccountCard({
|
||||||
row: r,
|
row: r,
|
||||||
pool,
|
pool,
|
||||||
grouped,
|
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
row: Row;
|
row: Row;
|
||||||
pool: AccountPool;
|
pool: AccountPool;
|
||||||
grouped: boolean;
|
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
@@ -443,39 +399,52 @@ function AccountCard({
|
|||||||
const users = pinsOf(pool, r.tool)
|
const users = pinsOf(pool, r.tool)
|
||||||
.filter((p) => p.account === r.name)
|
.filter((p) => p.account === r.name)
|
||||||
.map((p) => p.username);
|
.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 (
|
return (
|
||||||
<div
|
<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"
|
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||||
}`}
|
}`}
|
||||||
|
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||||
data-testid="pool-account"
|
data-testid="pool-account"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
<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>
|
</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 && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
title={t("pool.edit", { name: r.name })}
|
title={t("pool.edit", { name: r.name })}
|
||||||
aria-label={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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
title={t("pool.remove", { name: r.name })}
|
title={t("pool.remove", { name: r.name })}
|
||||||
aria-label={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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -488,68 +457,47 @@ function AccountCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{r.refreshError && !r.expired && (
|
{r.refreshError && !r.expired && (
|
||||||
<span
|
<span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
|
||||||
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
|
||||||
title={r.refreshError}
|
|
||||||
>
|
|
||||||
{t("pool.refreshFailed")}
|
{t("pool.refreshFailed")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{r.full && (
|
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
|
||||||
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
|
||||||
{t("pool.full")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
{metered && (
|
||||||
<span className="inline-flex items-center gap-1">
|
<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
|
<span
|
||||||
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
{t("pool.liveCount", { count: r.livePanes })}
|
{t("pool.liveCount", { count: r.livePanes })}
|
||||||
</span>
|
</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>
|
</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 && (
|
{users.length > 0 && (
|
||||||
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||||
<UserRound className="h-3 w-3" aria-hidden />
|
<UserRound className="h-3 w-3" aria-hidden />
|
||||||
{t("pool.pinned", { users: users.join(", ") })}
|
{t("pool.pinned", { users: users.join(", ") })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-auto text-[11px] text-fg-muted">
|
|
||||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -568,7 +516,7 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
|||||||
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
<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="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||||
<span
|
<span
|
||||||
className={`block h-full ${
|
className={`block h-full transition-[width] duration-500 ${
|
||||||
left == null
|
left == null
|
||||||
? ""
|
? ""
|
||||||
: left === 0
|
: left === 0
|
||||||
@@ -596,72 +544,6 @@ export 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
|
* 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
|
* 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 changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||||
const ready = changed && current.length > 0;
|
const ready = changed && current.length > 0;
|
||||||
const toggle = (id: number, on: boolean) =>
|
const groupOf = (userId: number) =>
|
||||||
setChosen((prev) => {
|
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
|
||||||
const next = new Set(prev);
|
|
||||||
if (on) next.add(id);
|
|
||||||
else next.delete(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!ready || busy) return;
|
if (!ready || busy) return;
|
||||||
@@ -822,38 +699,28 @@ function EditAccountDialog({
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
<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">
|
<div className="mt-1">
|
||||||
{users === null && (
|
<MemberPicker
|
||||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
users={users}
|
||||||
)}
|
chosen={chosen}
|
||||||
{users?.length === 0 && (
|
onChange={setChosen}
|
||||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
groupOf={groupOf}
|
||||||
)}
|
grouped={(pool.groups ?? []).length > 0}
|
||||||
{users?.map((u) => {
|
note={(u) => {
|
||||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||||
return (
|
const group = groupOf(u.id);
|
||||||
<div key={u.id} className="px-3 py-2">
|
return (
|
||||||
<Checkbox
|
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||||
checked={chosen.has(u.id)}
|
{[
|
||||||
onChange={(on) => toggle(u.id, on)}
|
group && t("pool.groups.inGroup", { group }),
|
||||||
label={
|
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
|
||||||
<span>
|
]
|
||||||
{u.username}
|
.filter(Boolean)
|
||||||
{u.disabled && (
|
.join(" · ")}
|
||||||
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
|||||||
* account), a new password (typed or generated, copyable — someone else's
|
* 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
|
* 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
|
* 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
|
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||||
* the server validates the whole change before it applies any of it. A
|
* 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 { useTranslation } from "react-i18next";
|
||||||
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
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 type { Lane } from "../../lib/types";
|
||||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
@@ -27,6 +36,7 @@ import { PasswordRules } from "./PasswordRules";
|
|||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
import { Switch } from "../Switch";
|
import { Switch } from "../Switch";
|
||||||
import { Checkbox } from "../Checkbox";
|
import { Checkbox } from "../Checkbox";
|
||||||
|
import { QuotaFields, parseLimit, useQuotaLabel, type QuotaPeriod } from "./UserQuota";
|
||||||
|
|
||||||
type Role = AuthUser["role"];
|
type Role = AuthUser["role"];
|
||||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||||
@@ -55,10 +65,13 @@ export function UserEditDialog({
|
|||||||
user,
|
user,
|
||||||
self,
|
self,
|
||||||
users,
|
users,
|
||||||
|
quotaDefault = null,
|
||||||
onClose,
|
onClose,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
user: ServerUserUsage;
|
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. */
|
/** The account being edited is the signed-in admin's own. */
|
||||||
self: boolean;
|
self: boolean;
|
||||||
users: ServerUserUsage[];
|
users: ServerUserUsage[];
|
||||||
@@ -81,6 +94,18 @@ export function UserEditDialog({
|
|||||||
const [added, setAdded] = useState<number[]>([]);
|
const [added, setAdded] = useState<number[]>([]);
|
||||||
const [pick, setPick] = useState("");
|
const [pick, setPick] = useState("");
|
||||||
const [signOut, setSignOut] = useState(false);
|
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 [current, setCurrent] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -117,12 +142,33 @@ export function UserEditDialog({
|
|||||||
if (added.length) patch.addWorkspaces = added;
|
if (added.length) patch.addWorkspaces = added;
|
||||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||||
if (signOut) patch.signOut = true;
|
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;
|
const changed = Object.keys(patch).length > 0;
|
||||||
// A blank field keeps the password. Someone else's may be anything (they
|
// 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.
|
// choose their own at next sign-in); your own must pass every rule.
|
||||||
const weakOwn =
|
const weakOwn =
|
||||||
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
|
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 () => {
|
const submit = async () => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
@@ -479,6 +525,44 @@ export function UserEditDialog({
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</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")}>
|
<Section title={t("edit.sessions")}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={signOut}
|
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
|
* @file AccountPoolCard.test.tsx
|
||||||
* @description The Users page's account pool card: one tab per AI tool — the
|
* @description The Users page's account pool card: the pools board with every
|
||||||
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
|
* tool's accounts (live panes and quota) and members per pool, then a tab per
|
||||||
* Oh My Pi's login sharing, and a "no pool" note for any other installed
|
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
|
||||||
* tool; says what an empty pool means per tool, an
|
* other installed tool; an
|
||||||
* admin adds an account only with their own password typed (the token goes
|
* 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
|
* 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
|
* 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
|
* 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.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import type { AccountPool } from "../../../lib/api";
|
import type { AccountPool } from "../../../lib/api";
|
||||||
@@ -129,6 +134,28 @@ const OMP = {
|
|||||||
gateway: { running: false },
|
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(() => {
|
beforeEach(() => {
|
||||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||||
aiTools.list.mockResolvedValue({
|
aiTools.list.mockResolvedValue({
|
||||||
@@ -147,8 +174,8 @@ beforeEach(() => {
|
|||||||
accountPool.list.mockResolvedValue(POOL);
|
accountPool.list.mockResolvedValue(POOL);
|
||||||
users.list.mockResolvedValue({
|
users.list.mockResolvedValue({
|
||||||
users: [
|
users: [
|
||||||
{ id: 7, username: "mia", disabled: false },
|
{ id: 7, username: "mia", role: "developer", disabled: false },
|
||||||
{ id: 8, username: "leo", disabled: false },
|
{ id: 8, username: "leo", role: "analyst", disabled: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
accountPool.profiles.mockResolvedValue({
|
accountPool.profiles.mockResolvedValue({
|
||||||
@@ -166,11 +193,15 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AccountPoolCard", () => {
|
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 user = userEvent.setup();
|
||||||
const { unmount } = render(card(false));
|
render(card(false));
|
||||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
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");
|
const [account, second] = screen.getAllByTestId("pool-account");
|
||||||
// Every account of the pool has its card; quota is shown as what is LEFT.
|
// Every account of the pool has its card; quota is shown as what is LEFT.
|
||||||
expect(second).toHaveTextContent("team-2");
|
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("$12.00 · 3.4M tokens in 7 days");
|
||||||
expect(account).toHaveTextContent("2 live");
|
expect(account).toHaveTextContent("2 live");
|
||||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||||
// Without groups nothing is said about them on an account.
|
// An analyst reads: no add, remove or new pool.
|
||||||
expect(screen.queryByText("Shared pool")).toBeNull();
|
expect(screen.queryByTestId("pool-add-account")).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
|
|
||||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||||
const codexTab = screen.getByRole("tab", { name: /Codex/ });
|
expect(screen.queryByRole("button", { name: "New pool" })).toBeNull();
|
||||||
expect(codexTab).toHaveTextContent("1");
|
// Pool tools have no tab any more; an installed tool without a pool does.
|
||||||
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
|
expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull();
|
||||||
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.
|
|
||||||
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
||||||
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
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.add.mockResolvedValue({ account: { name: "cu-1" } });
|
||||||
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(card(true));
|
render(card(true));
|
||||||
await screen.findByText("team-1");
|
await screen.findByText("team-1");
|
||||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor");
|
||||||
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
|
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
expect(screen.queryByLabelText("From")).toBeNull();
|
expect(screen.queryByLabelText("From")).toBeNull();
|
||||||
await user.type(screen.getByLabelText("Name"), "cu-1");
|
await user.type(screen.getByLabelText("Name"), "cu-1");
|
||||||
@@ -238,27 +255,7 @@ describe("AccountPoolCard", () => {
|
|||||||
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shares a saved profile from its card", async () => {
|
it("builds pools on the board: members, accounts and profiles moved in, one password", 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 () => {
|
|
||||||
accountPool.list.mockResolvedValue({
|
accountPool.list.mockResolvedValue({
|
||||||
...POOL,
|
...POOL,
|
||||||
groups: [
|
groups: [
|
||||||
@@ -272,28 +269,180 @@ describe("AccountPoolCard", () => {
|
|||||||
});
|
});
|
||||||
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
||||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||||
|
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(card(true));
|
render(card(true));
|
||||||
const groupCard = await screen.findByTestId("pool-group");
|
const teamA = await screen.findByTestId("pool-group");
|
||||||
expect(groupCard).toHaveTextContent("Team A");
|
expect(teamA).toHaveTextContent("leo");
|
||||||
expect(groupCard).toHaveTextContent("leo");
|
expect(teamA).toHaveTextContent("team-1");
|
||||||
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
|
await screen.findByText("mia");
|
||||||
await user.click(screen.getByRole("button", { name: "New group" }));
|
|
||||||
await screen.findByRole("dialog");
|
await user.click(screen.getByRole("button", { name: "New pool" }));
|
||||||
await user.type(screen.getByLabelText("Group name"), "Team B");
|
const names = screen.getAllByLabelText("Pool name");
|
||||||
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
|
await user.type(names[names.length - 1]!, "Team B");
|
||||||
expect(screen.getByText("now in Team A")).toBeInTheDocument();
|
const teamB = screen.getAllByTestId("pool-group")[1]!;
|
||||||
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
|
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
|
||||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
expect(teamB).toHaveTextContent("mia");
|
||||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
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(() =>
|
await waitFor(() =>
|
||||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||||
2,
|
2,
|
||||||
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
|
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
|
||||||
"boss-pass"
|
"boss-pass"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
expect(accountPool.add).toHaveBeenCalledWith(
|
||||||
|
{ tool: "claude", name: "work", profile: "work" },
|
||||||
|
"boss-pass"
|
||||||
|
);
|
||||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "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 () => {
|
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||||
@@ -312,7 +461,7 @@ describe("AccountPoolCard", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(card(true));
|
render(card(true));
|
||||||
await screen.findByText("team-1");
|
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 dialog = await screen.findByRole("dialog");
|
||||||
const select = await screen.findByLabelText("Profile");
|
const select = await screen.findByLabelText("Profile");
|
||||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||||
@@ -337,7 +486,7 @@ describe("AccountPoolCard", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(card(true));
|
render(card(true));
|
||||||
await screen.findByText("team-1");
|
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 dialog = await screen.findByRole("dialog");
|
||||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||||
@@ -362,7 +511,6 @@ describe("AccountPoolCard", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(card(true));
|
render(card(true));
|
||||||
await screen.findByText("team-1");
|
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.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||||
@@ -425,7 +573,6 @@ describe("AccountPoolCard", () => {
|
|||||||
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
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 waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
||||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -195,6 +195,7 @@
|
|||||||
"gitBar.switch": "Branch",
|
"gitBar.switch": "Branch",
|
||||||
"gitBar.switchApply": "Switch branch",
|
"gitBar.switchApply": "Switch branch",
|
||||||
"gitBar.switchCurrent": "current",
|
"gitBar.switchCurrent": "current",
|
||||||
|
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
||||||
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
||||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||||
"gitBar.switchLabel": "Switch branch…",
|
"gitBar.switchLabel": "Switch branch…",
|
||||||
|
|||||||
@@ -78,6 +78,13 @@
|
|||||||
"quotaWindow": {
|
"quotaWindow": {
|
||||||
"wk": "Week",
|
"wk": "Week",
|
||||||
"mo": "Month"
|
"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",
|
"version": "Version",
|
||||||
|
|||||||
@@ -87,7 +87,8 @@
|
|||||||
"ELOCALMODE": "Accounts are managed in server mode only.",
|
"ELOCALMODE": "Accounts are managed in server mode only.",
|
||||||
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
|
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
|
||||||
"EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).",
|
"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.",
|
"readOnly": "Analysts can view accounts but not change them.",
|
||||||
"auditTitle": "Audit log",
|
"auditTitle": "Audit log",
|
||||||
@@ -133,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
"pool": {
|
"pool": {
|
||||||
"title": "Account 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",
|
"refresh": "Re-read quotas",
|
||||||
"add": "Add account",
|
"add": "Add account",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
@@ -254,20 +255,20 @@
|
|||||||
"shareNamed": "Share {{name}} to the pool"
|
"shareNamed": "Share {{name}} to the pool"
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"title": "Groups",
|
"title": "Pools",
|
||||||
"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.",
|
"description": "Members in no pool, and accounts in no pool, make up the shared pool.",
|
||||||
"new": "New group",
|
"new": "New pool",
|
||||||
"empty": "No groups yet — every user shares every account.",
|
"empty": "No groups yet — every user shares every account.",
|
||||||
"members_one": "{{count}} member",
|
"members_one": "{{count}} member",
|
||||||
"members_other": "{{count}} members",
|
"members_other": "{{count}} members",
|
||||||
"edit": "Edit group {{name}}",
|
"edit": "Edit group {{name}}",
|
||||||
"delete": "Delete group {{name}}",
|
"delete": "Delete pool {{name}}",
|
||||||
"deleteHint": "Its members go back to the shared pool. No account is removed.",
|
"deleteHint": "Its members and accounts go back to the shared pool. No account is removed.",
|
||||||
"noAccounts": "none — members sign in themselves",
|
"noAccounts": "none — members sign in themselves",
|
||||||
"saved": "Group {{name}} saved",
|
"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.",
|
"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",
|
"memberList": "Members",
|
||||||
"inGroup": "now in {{group}}",
|
"inGroup": "now in {{group}}",
|
||||||
"accountList": "Accounts",
|
"accountList": "Accounts",
|
||||||
@@ -278,7 +279,57 @@
|
|||||||
"5h": "5 hours",
|
"5h": "5 hours",
|
||||||
"wk": "Week"
|
"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": {
|
"delete": {
|
||||||
"button": "Delete user",
|
"button": "Delete user",
|
||||||
@@ -362,5 +413,37 @@
|
|||||||
"spendSub": "7 d {{week}} · 30 d {{month}}",
|
"spendSub": "7 d {{week}} · 30 d {{month}}",
|
||||||
"sessionsN": "{{n}} sessions",
|
"sessionsN": "{{n}} sessions",
|
||||||
"workspacesN": "{{n}} workspaces",
|
"workspacesN": "{{n}} workspaces",
|
||||||
"tmuxCol": "tmux"
|
"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}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
"gitBar.switch": "Nhánh",
|
"gitBar.switch": "Nhánh",
|
||||||
"gitBar.switchApply": "Chuyển nhánh",
|
"gitBar.switchApply": "Chuyển nhánh",
|
||||||
"gitBar.switchCurrent": "hiện tại",
|
"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.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.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||||
"gitBar.switchLabel": "Đổi nhánh…",
|
"gitBar.switchLabel": "Đổi nhánh…",
|
||||||
|
|||||||
@@ -78,6 +78,13 @@
|
|||||||
"quotaWindow": {
|
"quotaWindow": {
|
||||||
"wk": "Tuần",
|
"wk": "Tuần",
|
||||||
"mo": "Tháng"
|
"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",
|
"version": "Phiên bản",
|
||||||
|
|||||||
@@ -87,7 +87,8 @@
|
|||||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
|
"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.",
|
"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).",
|
"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.",
|
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||||
"auditTitle": "Nhật ký audit",
|
"auditTitle": "Nhật ký audit",
|
||||||
@@ -133,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
"pool": {
|
"pool": {
|
||||||
"title": "Pool tài khoản",
|
"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",
|
"refresh": "Đọc lại quota",
|
||||||
"add": "Thêm tài khoản",
|
"add": "Thêm tài khoản",
|
||||||
"account": "Tài khoản",
|
"account": "Tài khoản",
|
||||||
@@ -254,20 +255,20 @@
|
|||||||
"shareNamed": "Chia sẻ {{name}} vào pool"
|
"shareNamed": "Chia sẻ {{name}} vào pool"
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"title": "Nhóm",
|
"title": "Pool",
|
||||||
"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.",
|
"description": "Member và tài khoản chưa thuộc pool nào nằm trong pool chung.",
|
||||||
"new": "Tạo nhóm",
|
"new": "Tạo pool",
|
||||||
"empty": "Chưa có nhóm — mọi user dùng chung mọi tài khoản.",
|
"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_one": "{{count}} thành viên",
|
||||||
"members_other": "{{count}} thành viên",
|
"members_other": "{{count}} thành viên",
|
||||||
"edit": "Sửa nhóm {{name}}",
|
"edit": "Sửa nhóm {{name}}",
|
||||||
"delete": "Xoá nhóm {{name}}",
|
"delete": "Xoá pool {{name}}",
|
||||||
"deleteHint": "Các thành viên quay về pool chung. Không tài khoản nào bị xoá.",
|
"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",
|
"noAccounts": "chưa có — thành viên tự đăng nhập",
|
||||||
"saved": "Đã lưu nhóm {{name}}",
|
"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.",
|
"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",
|
"memberList": "Thành viên",
|
||||||
"inGroup": "đang ở {{group}}",
|
"inGroup": "đang ở {{group}}",
|
||||||
"accountList": "Tài khoản",
|
"accountList": "Tài khoản",
|
||||||
@@ -278,7 +279,57 @@
|
|||||||
"5h": "5 giờ",
|
"5h": "5 giờ",
|
||||||
"wk": "Tuần"
|
"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": {
|
"delete": {
|
||||||
"button": "Xoá user",
|
"button": "Xoá user",
|
||||||
@@ -362,5 +413,37 @@
|
|||||||
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
|
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
|
||||||
"sessionsN": "{{n}} session",
|
"sessionsN": "{{n}} session",
|
||||||
"workspacesN": "{{n}} workspace",
|
"workspacesN": "{{n}} workspace",
|
||||||
"tmuxCol": "tmux"
|
"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,53 @@
|
|||||||
|
/**
|
||||||
|
* @file htmlInline.test.ts
|
||||||
|
* @description An HTML preview's relative CSS, scripts and images are inlined
|
||||||
|
* from the place's own files, never from above its root or off a URL.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { inlineHtml, resolveRef } from "../htmlInline";
|
||||||
|
|
||||||
|
const files: Record<string, string> = {
|
||||||
|
"site/css/app.css": "body{background:url(../img/bg.png)}",
|
||||||
|
"site/js/app.js": "window.ok=1",
|
||||||
|
"site/img/logo.png": "PNG",
|
||||||
|
"site/img/bg.png": "BG",
|
||||||
|
};
|
||||||
|
const fetchFile = vi.fn(async (p: string) => {
|
||||||
|
if (!(p in files)) throw new Error("404");
|
||||||
|
return new Blob([files[p]!]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRef", () => {
|
||||||
|
it("resolves against the page's folder, from the root on a leading slash", () => {
|
||||||
|
expect(resolveRef("site", "css/a.css?v=2#x")).toBe("site/css/a.css");
|
||||||
|
expect(resolveRef("site/sub", "../img/a%20b.png")).toBe("site/img/a b.png");
|
||||||
|
expect(resolveRef("site", "/top.css")).toBe("top.css");
|
||||||
|
});
|
||||||
|
it("skips URLs, anchors and anything above the root", () => {
|
||||||
|
for (const r of ["https://x/a.js", "//cdn/a.js", "data:x", "#top", "", "../../etc/passwd"])
|
||||||
|
expect(resolveRef("site", r)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inlineHtml", () => {
|
||||||
|
it("inlines stylesheets (with their url()s), scripts and images", async () => {
|
||||||
|
const html = `<!doctype html><html><head><link rel="stylesheet" href="css/app.css">
|
||||||
|
<script src="js/app.js"></script><script src="https://cdn.example/x.js"></script></head>
|
||||||
|
<body><img src="img/logo.png" srcset="img/logo@2x.png 2x"><img src="missing.png"></body></html>`;
|
||||||
|
const out = await inlineHtml(html, "site/index.html", fetchFile);
|
||||||
|
expect(out.startsWith("<!doctype html>")).toBe(true);
|
||||||
|
expect(out).toContain(`<style>body{background:url("data:image/png;base64,${btoa("BG")}")}</style>`);
|
||||||
|
expect(out).toContain(`src="data:text/javascript;base64,${btoa("window.ok=1")}"`);
|
||||||
|
expect(out).toContain(`src="data:image/png;base64,${btoa("PNG")}"`);
|
||||||
|
expect(out).not.toContain("srcset");
|
||||||
|
expect(out).toContain('src="https://cdn.example/x.js"');
|
||||||
|
expect(out).toContain('src="missing.png"');
|
||||||
|
expect(fetchFile).not.toHaveBeenCalledWith(expect.stringContaining("cdn"));
|
||||||
|
});
|
||||||
|
it("returns the page untouched when nothing is local", async () => {
|
||||||
|
const html = "<p>hi <a href='https://x'>x</a></p>";
|
||||||
|
expect(await inlineHtml(html, "a.html", fetchFile)).toBe(html);
|
||||||
|
});
|
||||||
|
});
|
||||||
+46
-1
@@ -545,6 +545,31 @@ export type ServerUserUsage = AuthUser & {
|
|||||||
/** Active login sessions (cookies not expired). */
|
/** Active login sessions (cookies not expired). */
|
||||||
logins?: number;
|
logins?: number;
|
||||||
lastLoginAt?: string | null;
|
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`). */
|
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
|
||||||
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
|
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
|
||||||
@@ -773,6 +798,8 @@ export type UserPatch = {
|
|||||||
addWorkspaces?: number[];
|
addWorkspaces?: number[];
|
||||||
removeWorkspaces?: number[];
|
removeWorkspaces?: number[];
|
||||||
signOut?: true;
|
signOut?: true;
|
||||||
|
/** Their own spend quota (`default` = the default applies). */
|
||||||
|
quota?: UserQuotaSetting;
|
||||||
};
|
};
|
||||||
/** GET /api/me/account — the signed-in account's own information. */
|
/** GET /api/me/account — the signed-in account's own information. */
|
||||||
export type MyAccount =
|
export type MyAccount =
|
||||||
@@ -810,6 +837,8 @@ export type PoolAccount = {
|
|||||||
} | null;
|
} | null;
|
||||||
quotaPercent: number | null;
|
quotaPercent: number | null;
|
||||||
full: boolean;
|
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. */
|
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */
|
||||||
export type CodexPoolAccount = {
|
export type CodexPoolAccount = {
|
||||||
@@ -828,6 +857,8 @@ export type CodexPoolAccount = {
|
|||||||
} | null;
|
} | null;
|
||||||
quotaPercent: number | null;
|
quotaPercent: number | null;
|
||||||
full: boolean;
|
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. */
|
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
|
||||||
export type AccountPin = {
|
export type AccountPin = {
|
||||||
@@ -1206,6 +1237,8 @@ export const api = {
|
|||||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||||
savePrefs: (prefs: Record<string, string | null>) =>
|
savePrefs: (prefs: Record<string, string | null>) =>
|
||||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
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). */
|
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
|
||||||
instructions: () => request<AgentInstructionsView>("/me/instructions"),
|
instructions: () => request<AgentInstructionsView>("/me/instructions"),
|
||||||
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
|
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
|
||||||
@@ -1267,7 +1300,15 @@ export const api = {
|
|||||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||||
list: () =>
|
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). */
|
/** GET /api/users/:id/details — one account in full (admin/analyst). */
|
||||||
details: (id: number) =>
|
details: (id: number) =>
|
||||||
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
|
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||||
@@ -4279,6 +4320,8 @@ export const api = {
|
|||||||
request<{
|
request<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
branches: string[];
|
branches: string[];
|
||||||
|
/** Fetched remote branches with no local one yet (`origin/feature`); absent from an older server. */
|
||||||
|
remotes?: string[];
|
||||||
current: string | null;
|
current: string | null;
|
||||||
worktrees: LaneWorktree[];
|
worktrees: LaneWorktree[];
|
||||||
}>(gitUrl(id, "/branches")),
|
}>(gitUrl(id, "/branches")),
|
||||||
@@ -4778,6 +4821,8 @@ export interface CcAccountIdentity {
|
|||||||
organization: string | null;
|
organization: string | null;
|
||||||
seatTier: string | null;
|
seatTier: string | null;
|
||||||
subscriptionType: 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
|
/** Access-token expiry, epoch ms; a switch still works past it (the refresh
|
||||||
* token is stored too). */
|
* token is stored too). */
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* @file htmlInline.ts
|
||||||
|
* @description An HTML file made whole for its sandboxed preview. The page
|
||||||
|
* runs in an `<iframe srcdoc>` with no same-origin access, so it can fetch
|
||||||
|
* nothing from the dashboard — and the dashboard's login cookie is
|
||||||
|
* SameSite=Strict, so even a URL it could reach would answer 401. Instead the
|
||||||
|
* DASHBOARD reads the page's relative stylesheets, scripts, images, media and
|
||||||
|
* the `url(...)`s inside its CSS through a read route that already guards
|
||||||
|
* them (the caller's `fetchFile`: a workspace's or a home's download), and
|
||||||
|
* writes them into the page as `<style>` and `data:` URLs before it renders.
|
||||||
|
* Only paths inside the page's own place are followed (`..` above its root is
|
||||||
|
* skipped), and a page can pull at most `MAX_ASSETS` files / `MAX_BYTES` in
|
||||||
|
* all, so a big site never stalls the viewer. A reference that cannot be read
|
||||||
|
* is left as it was. `fetch()`/XHR from the page's own script, ES module
|
||||||
|
* imports and links to other pages still do not work.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Reads one file by its path relative to the place's root; throws when it cannot. */
|
||||||
|
export type FetchFile = (relPath: string) => Promise<Blob>;
|
||||||
|
|
||||||
|
const MAX_ASSETS = 60;
|
||||||
|
const MAX_BYTES = 30 * 1024 * 1024;
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
css: "text/css",
|
||||||
|
js: "text/javascript",
|
||||||
|
mjs: "text/javascript",
|
||||||
|
json: "application/json",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
avif: "image/avif",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
ico: "image/x-icon",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
woff: "font/woff",
|
||||||
|
woff2: "font/woff2",
|
||||||
|
ttf: "font/ttf",
|
||||||
|
otf: "font/otf",
|
||||||
|
mp4: "video/mp4",
|
||||||
|
webm: "video/webm",
|
||||||
|
mp3: "audio/mpeg",
|
||||||
|
wav: "audio/wav",
|
||||||
|
ogg: "audio/ogg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mimeOf = (p: string) =>
|
||||||
|
MIME[(/\.([A-Za-z0-9]+)$/.exec(p)?.[1] || "").toLowerCase()] ?? "application/octet-stream";
|
||||||
|
|
||||||
|
/** The folder part of a relative path ("" at the root). */
|
||||||
|
export const dirOf = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page's reference as a path from the place's root, or null when it is not
|
||||||
|
* a local file (a URL, `//host`, `data:`, an anchor) or climbs above the root.
|
||||||
|
* A leading `/` counts from the root, as a site served from it would.
|
||||||
|
*/
|
||||||
|
export function resolveRef(dir: string, ref: string): string | null {
|
||||||
|
const r = ref.trim();
|
||||||
|
if (!r || r.startsWith("#") || r.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(r)) return null;
|
||||||
|
let p = r.replace(/[?#].*$/, "");
|
||||||
|
try {
|
||||||
|
p = decodeURIComponent(p);
|
||||||
|
} catch {
|
||||||
|
// A stray % is a literal one.
|
||||||
|
}
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const seg of (p.startsWith("/") ? p : `${dir}/${p}`).split("/")) {
|
||||||
|
if (!seg || seg === ".") continue;
|
||||||
|
if (seg === "..") {
|
||||||
|
if (!out.length) return null;
|
||||||
|
out.pop();
|
||||||
|
} else out.push(seg);
|
||||||
|
}
|
||||||
|
return out.length ? out.join("/") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Blob's text / bytes, through FileReader where Blob lacks the methods (jsdom). */
|
||||||
|
export function blobText(b: Blob): Promise<string> {
|
||||||
|
if (typeof b.text === "function") return b.text();
|
||||||
|
return new Promise((ok, fail) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => ok(String(r.result));
|
||||||
|
r.onerror = () => fail(r.error);
|
||||||
|
r.readAsText(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function blobBytes(b: Blob): Promise<ArrayBuffer> {
|
||||||
|
if (typeof b.arrayBuffer === "function") return b.arrayBuffer();
|
||||||
|
return new Promise((ok, fail) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => ok(r.result as ArrayBuffer);
|
||||||
|
r.onerror = () => fail(r.error);
|
||||||
|
r.readAsArrayBuffer(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64(buf: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||||
|
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||||
|
}
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const URL_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
|
||||||
|
|
||||||
|
/** Inline the relative resources of `html` (the file at `htmlPath`). */
|
||||||
|
export async function inlineHtml(
|
||||||
|
html: string,
|
||||||
|
htmlPath: string,
|
||||||
|
fetchFile: FetchFile
|
||||||
|
): Promise<string> {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
const cache = new Map<string, Promise<Blob | null>>();
|
||||||
|
let count = 0;
|
||||||
|
let bytes = 0;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const read = (rel: string): Promise<Blob | null> => {
|
||||||
|
let p = cache.get(rel);
|
||||||
|
if (!p) {
|
||||||
|
if (count >= MAX_ASSETS) return Promise.resolve(null);
|
||||||
|
count++;
|
||||||
|
p = fetchFile(rel)
|
||||||
|
.then((b) => {
|
||||||
|
if (bytes + b.size > MAX_BYTES) return null;
|
||||||
|
bytes += b.size;
|
||||||
|
return b;
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
cache.set(rel, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const dataUrl = async (rel: string): Promise<string | null> => {
|
||||||
|
const b = await read(rel);
|
||||||
|
return b ? `data:${mimeOf(rel)};base64,${base64(await blobBytes(b))}` : null;
|
||||||
|
};
|
||||||
|
/** CSS with its own relative `url(...)`s made data URLs (from the CSS's folder). */
|
||||||
|
const inlineCss = async (css: string, dir: string): Promise<string> => {
|
||||||
|
const refs = new Map<string, string | null>();
|
||||||
|
for (const m of css.matchAll(URL_RE)) {
|
||||||
|
const rel = resolveRef(dir, m[2]!);
|
||||||
|
if (rel && !refs.has(rel)) refs.set(rel, null);
|
||||||
|
}
|
||||||
|
await Promise.all([...refs.keys()].map(async (rel) => refs.set(rel, await dataUrl(rel))));
|
||||||
|
return css.replace(URL_RE, (all, _q, ref: string) => {
|
||||||
|
const rel = resolveRef(dir, ref);
|
||||||
|
const url = rel ? refs.get(rel) : null;
|
||||||
|
return url ? `url("${url}")` : all;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dir = dirOf(htmlPath);
|
||||||
|
const jobs: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const link of doc.querySelectorAll<HTMLLinkElement>("link[rel~='stylesheet' i][href]")) {
|
||||||
|
const rel = resolveRef(dir, link.getAttribute("href")!);
|
||||||
|
if (!rel) continue;
|
||||||
|
jobs.push(
|
||||||
|
(async () => {
|
||||||
|
const b = await read(rel);
|
||||||
|
if (!b) return;
|
||||||
|
const style = doc.createElement("style");
|
||||||
|
const media = link.getAttribute("media");
|
||||||
|
if (media) style.setAttribute("media", media);
|
||||||
|
style.textContent = await inlineCss(await blobText(b), dirOf(rel));
|
||||||
|
link.replaceWith(style);
|
||||||
|
changed = true;
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const style of doc.querySelectorAll("style")) {
|
||||||
|
const css = style.textContent || "";
|
||||||
|
if (!/url\(/i.test(css)) continue;
|
||||||
|
jobs.push(
|
||||||
|
inlineCss(css, dir).then((out) => {
|
||||||
|
if (out !== css) {
|
||||||
|
style.textContent = out;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const el of doc.querySelectorAll<HTMLElement>("[style*='url(' i]")) {
|
||||||
|
const css = el.getAttribute("style")!;
|
||||||
|
jobs.push(
|
||||||
|
inlineCss(css, dir).then((out) => {
|
||||||
|
if (out !== css) {
|
||||||
|
el.setAttribute("style", out);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const attrs: [string, string][] = [
|
||||||
|
["script[src]", "src"],
|
||||||
|
["img[src]", "src"],
|
||||||
|
["source[src]", "src"],
|
||||||
|
["video[src]", "src"],
|
||||||
|
["audio[src]", "src"],
|
||||||
|
["video[poster]", "poster"],
|
||||||
|
["input[type='image' i][src]", "src"],
|
||||||
|
["link[rel~='icon' i][href]", "href"],
|
||||||
|
];
|
||||||
|
for (const [sel, attr] of attrs) {
|
||||||
|
for (const el of doc.querySelectorAll(sel)) {
|
||||||
|
const rel = resolveRef(dir, el.getAttribute(attr)!);
|
||||||
|
if (!rel) continue;
|
||||||
|
jobs.push(
|
||||||
|
dataUrl(rel).then((url) => {
|
||||||
|
if (!url) return;
|
||||||
|
el.setAttribute(attr, url);
|
||||||
|
// A srcset beside it would win over the inlined src and still miss.
|
||||||
|
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
|
||||||
|
changed = true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(jobs);
|
||||||
|
if (!changed) return html;
|
||||||
|
const doctype = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype/i.test(html) ? "<!doctype html>\n" : "";
|
||||||
|
return doctype + doc.documentElement.outerHTML;
|
||||||
|
}
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||||
* on (`AccountPoolCard`).
|
* 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
|
* Every change asks for the admin's OWN password again (the
|
||||||
* server refuses without it); an analyst only reads. A row shows the display
|
* 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
|
* name and email under the username when set, and "New password due" while
|
||||||
@@ -61,6 +63,7 @@ import {
|
|||||||
type AuthUser,
|
type AuthUser,
|
||||||
type MemberTmuxRow,
|
type MemberTmuxRow,
|
||||||
type ServerUserUsage,
|
type ServerUserUsage,
|
||||||
|
type UserQuota,
|
||||||
type UserStorage,
|
type UserStorage,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
|
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
|
||||||
@@ -74,6 +77,7 @@ import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
|||||||
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||||
import { UserDetail } from "../components/users/UserDetail";
|
import { UserDetail } from "../components/users/UserDetail";
|
||||||
import { UserAvatar } from "../components/users/UserAvatar";
|
import { UserAvatar } from "../components/users/UserAvatar";
|
||||||
|
import { QuotaDefaultCard, QuotaSummary } from "../components/users/UserQuota";
|
||||||
import {
|
import {
|
||||||
MemberTmuxBar,
|
MemberTmuxBar,
|
||||||
TmuxCell,
|
TmuxCell,
|
||||||
@@ -104,6 +108,8 @@ export function Users({
|
|||||||
const isAdmin = me?.role === "admin";
|
const isAdmin = me?.role === "admin";
|
||||||
|
|
||||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
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 [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
@@ -151,6 +157,7 @@ export function Users({
|
|||||||
.list()
|
.list()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setUsers(r.users);
|
setUsers(r.users);
|
||||||
|
setQuotaDefault(r.quotaDefault);
|
||||||
setError(null);
|
setError(null);
|
||||||
})
|
})
|
||||||
.catch((e: Error) => setError(e.message)),
|
.catch((e: Error) => setError(e.message)),
|
||||||
@@ -406,6 +413,7 @@ export function Users({
|
|||||||
month: fmtCost(u.last30d.cost),
|
month: fmtCost(u.last30d.cost),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<QuotaSummary quota={u.quota} />
|
||||||
</td>
|
</td>
|
||||||
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
|
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
|
||||||
{showTmux && (
|
{showTmux && (
|
||||||
@@ -487,6 +495,17 @@ export function Users({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{quotaDefault !== undefined && (
|
||||||
|
<QuotaDefaultCard
|
||||||
|
quotaDefault={quotaDefault}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onSaved={(message) => {
|
||||||
|
toast.success(message);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AccountPoolCard isAdmin={isAdmin} />
|
<AccountPoolCard isAdmin={isAdmin} />
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
@@ -559,6 +578,7 @@ export function Users({
|
|||||||
{editing && (
|
{editing && (
|
||||||
<UserEditDialog
|
<UserEditDialog
|
||||||
user={editing}
|
user={editing}
|
||||||
|
quotaDefault={quotaDefault ?? null}
|
||||||
self={editing.id === me.id}
|
self={editing.id === me.id}
|
||||||
users={users ?? []}
|
users={users ?? []}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ describe("Users page", () => {
|
|||||||
expect(confirm.hasAttribute("disabled")).toBe(true);
|
expect(confirm.hasAttribute("disabled")).toBe(true);
|
||||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
expect(confirm.hasAttribute("disabled")).toBe(false);
|
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 ue.click(confirm);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(users.create).toHaveBeenCalledWith(
|
expect(users.create).toHaveBeenCalledWith(
|
||||||
@@ -236,7 +238,7 @@ describe("Users page", () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
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 () => {
|
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
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.29 (rebuild client to match)
|
UI build v2.8.33 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-7
@@ -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/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
| `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, 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). 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.
|
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).
|
`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`):
|
**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,7 @@ 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 |
|
| `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 |
|
| `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 |
|
| `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: []}` |
|
| `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 |
|
| `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) |
|
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||||
@@ -941,8 +942,11 @@ read the disk instead for a folder that is not a git repo (below).
|
|||||||
GET /api/lanes/:id/git/branches
|
GET /api/lanes/:id/git/branches
|
||||||
```
|
```
|
||||||
|
|
||||||
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
|
`{"available": true, "branches": [...], "remotes": ["origin/feat/y"], "current": "feat/x", "worktrees": [...]}`
|
||||||
— the local branches of the workspace's own repo, for the branch switcher. The repo
|
— 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
|
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
|
/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
|
not exist yet; in server mode with `AGENTHUB_USERS_ROOT` set, a developer may name
|
||||||
@@ -1058,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}` |
|
| `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}` |
|
| `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": ["..."]}` |
|
| `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-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
| `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"}` |
|
| `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"}` |
|
||||||
@@ -2584,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]
|
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 as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), 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
|
#### Analytics
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -2900,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.
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -677,8 +677,18 @@ reads the workspace's git surface in five modes:
|
|||||||
line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at
|
line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at
|
||||||
least one line tall (`MIN_ROW`), so an empty filler row keeps the two
|
least one line tall (`MIN_ROW`), so an empty filler row keeps the two
|
||||||
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
|
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
|
||||||
shown as the picture (`/git/raw`), and a conflicted file opens in the
|
shown as the picture (`/git/raw`), an HTML file (`.html`/`.htm`) opens
|
||||||
conflict view. **Cancel** reverts unsaved edits. **Save** (or
|
as the page itself — **Raw** shows its source in the editor, **Preview**
|
||||||
|
brings the page back — and a conflicted file opens in the
|
||||||
|
conflict view. The page (here, in a Chat file preview, and on a
|
||||||
|
double-click in the File Explorer, whose right-click menu keeps
|
||||||
|
**Download**) runs in the chat artifact's sandbox (`HtmlFrame.tsx`:
|
||||||
|
`<iframe srcdoc>`, scripts but never `allow-same-origin`); its relative
|
||||||
|
stylesheets (and their `url()`s), scripts, images, media and icons are
|
||||||
|
read by the dashboard through the same download route and written into
|
||||||
|
it as `<style>`/`data:` URLs (`client/src/lib/htmlInline.ts`: never above
|
||||||
|
the folder's root, at most 60 files / 30 MB a page). The page's own
|
||||||
|
`fetch()`, ES module imports and links to other pages do not resolve. **Cancel** reverts unsaved edits. **Save** (or
|
||||||
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
|
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
|
||||||
exact version that was opened — a file the agent changed meanwhile answers
|
exact version that was opened — a file the agent changed meanwhile answers
|
||||||
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
|
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
|
||||||
|
|||||||
+72
-1
@@ -595,7 +595,7 @@ agenthub accounts pin cursor-team mia --tool cursor
|
|||||||
## Account pool groups
|
## Account pool groups
|
||||||
|
|
||||||
By default every user shares every pool account. **Groups** split the pool
|
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`):
|
`server/lib/pool-groups.js`):
|
||||||
|
|
||||||
- A group has a name, members and accounts of every pool tool (Claude, Codex,
|
- A group has a name, members and accounts of every pool tool (Claude, Codex,
|
||||||
@@ -613,6 +613,28 @@ by team (Users page → **Account pool → Groups**, server mode only,
|
|||||||
an account takes it out of every group; deleting a group sends its members
|
an account takes it out of every group; deleting a group sends its members
|
||||||
back to the shared pool and removes no account.
|
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
|
### A user's own pool account
|
||||||
|
|
||||||
When the pool holds more than one account a user may use, they pick which one
|
When the pool holds more than one account a user may use, they pick which one
|
||||||
@@ -688,6 +710,55 @@ omp login # e.g. anthropic
|
|||||||
> runs; it cannot carry a login away. A member who edits the `agenthub`
|
> 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.
|
> 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
|
## One Linux user per account
|
||||||
|
|
||||||
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
||||||
|
|||||||
+180
-5
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.8.29
|
version: 2.8.30
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -4657,6 +4657,73 @@ components:
|
|||||||
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/DashboardEvent'
|
$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:
|
AccountPin:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -10975,6 +11042,12 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$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':
|
'500':
|
||||||
description: Unexpected failure (EINTERNAL).
|
description: Unexpected failure (EINTERNAL).
|
||||||
content:
|
content:
|
||||||
@@ -12203,8 +12276,8 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- Lanes
|
- 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.
|
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
|
operationId: listLaneOwnBranches
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@@ -12214,7 +12287,7 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: '{available, branches, current, worktrees}.'
|
description: '{available, branches, remotes, current, worktrees}.'
|
||||||
'404':
|
'404':
|
||||||
description: Lane not found.
|
description: Lane not found.
|
||||||
/api/lanes/{id}/git/commit-message:
|
/api/lanes/{id}/git/commit-message:
|
||||||
@@ -14453,6 +14526,30 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$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:
|
/api/me/instructions:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -14966,6 +15063,16 @@ paths:
|
|||||||
type: number
|
type: number
|
||||||
tokens:
|
tokens:
|
||||||
type: integer
|
type: integer
|
||||||
|
quotaSetting:
|
||||||
|
$ref: '#/components/schemas/UserQuotaSetting'
|
||||||
|
quota:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/UserQuotaStatus'
|
||||||
|
nullable: true
|
||||||
|
quotaDefault:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/UserQuota'
|
||||||
|
nullable: true
|
||||||
'403':
|
'403':
|
||||||
description: EADMIN — a developer
|
description: EADMIN — a developer
|
||||||
content:
|
content:
|
||||||
@@ -15303,6 +15410,8 @@ paths:
|
|||||||
type: boolean
|
type: boolean
|
||||||
enum:
|
enum:
|
||||||
- true
|
- true
|
||||||
|
quota:
|
||||||
|
$ref: '#/components/schemas/UserQuotaSetting'
|
||||||
currentPassword:
|
currentPassword:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
@@ -15316,7 +15425,7 @@ paths:
|
|||||||
user:
|
user:
|
||||||
$ref: '#/components/schemas/AuthUser'
|
$ref: '#/components/schemas/AuthUser'
|
||||||
'400':
|
'400':
|
||||||
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL
|
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -15413,6 +15522,72 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$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:
|
/api/users/{id}/password:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.29",
|
"version": "2.8.33",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.29",
|
"version": "2.8.33",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.29",
|
"version": "2.8.33",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,9 +16,18 @@
|
|||||||
* for the response is what made Claude Code sit at "running hooks" for seconds
|
* for the response is what made Claude Code sit at "running hooks" for seconds
|
||||||
* whenever a dashboard was busy, slow, or wedged.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
|
|
||||||
const hookType = process.argv[2] || "unknown";
|
const hookType = process.argv[2] || "unknown";
|
||||||
@@ -44,11 +53,36 @@ function resolvePorts() {
|
|||||||
|
|
||||||
const ports = 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 = "";
|
let input = "";
|
||||||
|
|
||||||
process.stdin.setEncoding("utf8");
|
process.stdin.setEncoding("utf8");
|
||||||
process.stdin.on("data", (chunk) => (input += chunk));
|
process.stdin.on("data", (chunk) => (input += chunk));
|
||||||
process.stdin.on("end", () => {
|
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;
|
let parsedData;
|
||||||
try {
|
try {
|
||||||
parsedData = JSON.parse(input);
|
parsedData = JSON.parse(input);
|
||||||
|
|||||||
@@ -444,3 +444,55 @@ describe("cc-profiles — identity and token disagree", () => {
|
|||||||
assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" });
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the profile endpoint's live plan wins over files written at sign-in", () => {
|
||||||
|
const { planFromProfile, rememberLivePlan, shownPlan } = profiles;
|
||||||
|
assert.strictEqual(
|
||||||
|
planFromProfile({ organization_type: "claude_max", rate_limit_tier: "default_claude_max_5x" }),
|
||||||
|
"Max 5x"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
planFromProfile({
|
||||||
|
organization_type: "claude_team",
|
||||||
|
rate_limit_tier: "default_raven",
|
||||||
|
seat_tier: "team_standard",
|
||||||
|
}),
|
||||||
|
"Team Standard"
|
||||||
|
);
|
||||||
|
assert.strictEqual(planFromProfile({ organization_type: "claude_pro" }), "Pro");
|
||||||
|
assert.strictEqual(planFromProfile(null), null);
|
||||||
|
|
||||||
|
// Signed in on Pro, upgraded to Max 5x since: the files still say Pro.
|
||||||
|
const oauth = { subscriptionType: "pro", rateLimitTier: "default_claude_pro" };
|
||||||
|
const account = { accountUuid: "acct-upgraded" };
|
||||||
|
assert.strictEqual(shownPlan(oauth, account), "Pro");
|
||||||
|
rememberLivePlan("acct-upgraded", "Max 5x");
|
||||||
|
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
|
||||||
|
rememberLivePlan("acct-upgraded", null); // an unknown answer keeps the last one
|
||||||
|
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
|
||||||
|
assert.strictEqual(shownPlan(oauth, { accountUuid: "other" }), "Pro");
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const pty = require("../lib/pty-run");
|
|||||||
|
|
||||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||||
const realFetchUsage = ccUsage.fetchUsage;
|
const realFetchUsage = ccUsage.fetchUsage;
|
||||||
|
const realFetchOwner = ccUsage.fetchOwner;
|
||||||
|
|
||||||
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
|
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -61,10 +62,12 @@ before(() => {
|
|||||||
ccUsage.fetchUsage = async () => {
|
ccUsage.fetchUsage = async () => {
|
||||||
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
|
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
|
||||||
};
|
};
|
||||||
|
ccUsage.fetchOwner = async () => null;
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
ccUsage.fetchUsage = realFetchUsage;
|
ccUsage.fetchUsage = realFetchUsage;
|
||||||
|
ccUsage.fetchOwner = realFetchOwner;
|
||||||
tmux.__setExecImpl(null);
|
tmux.__setExecImpl(null);
|
||||||
fs.rmSync(TMP, { recursive: true, force: true });
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,7 +138,10 @@ describe("collectChanges — work range", () => {
|
|||||||
const out = await collectChanges(LANE, { range: "work" });
|
const out = await collectChanges(LANE, { range: "work" });
|
||||||
// `--untracked-files=normal` folded these into one "docs/" row that
|
// `--untracked-files=normal` folded these into one "docs/" row that
|
||||||
// opened nothing; the panel needs the files, like JetBrains and VS Code.
|
// 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.ok(out.files.every((f) => f.untracked && f.added === 1));
|
||||||
assert.equal((await gitFacts(LANE)).untracked, 2);
|
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 () => {
|
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.
|
// No recorded base (an adopted folder): what origin/HEAD names.
|
||||||
assert.equal(
|
assert.equal((await collectChanges(LANE, { range: "branch", base: null })).base, "development");
|
||||||
(await collectChanges(LANE, { range: "branch", base: null })).base,
|
|
||||||
"development"
|
|
||||||
);
|
|
||||||
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
|
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
|
||||||
code: "ENOBASE",
|
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 () => {
|
it("refuses a branch another worktree holds", async () => {
|
||||||
const wt = path.join(ROOT, "sibling");
|
const wt = path.join(ROOT, "sibling");
|
||||||
g(LANE, "worktree", "add", "-q", wt, "development");
|
g(LANE, "worktree", "add", "-q", wt, "development");
|
||||||
|
|||||||
@@ -1122,6 +1122,7 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
|||||||
const r = await request("GET", `/api/lanes/${laneId}/git/branches`);
|
const r = await request("GET", `/api/lanes/${laneId}/git/branches`);
|
||||||
assert.equal(r.status, 200);
|
assert.equal(r.status, 200);
|
||||||
assert.deepEqual(r.body.branches, ["feat/api-changes"]);
|
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");
|
assert.equal(r.body.current, "feat/api-changes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
|
|||||||
const pins = require("../lib/account-pins");
|
const pins = require("../lib/account-pins");
|
||||||
|
|
||||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||||
|
|
||||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
|
|||||||
const pins = require("../lib/account-pins");
|
const pins = require("../lib/account-pins");
|
||||||
|
|
||||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||||
|
|
||||||
const BOSS_PASS = "admin-pass-1";
|
const BOSS_PASS = "admin-pass-1";
|
||||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||||
|
|||||||
@@ -170,6 +170,65 @@ test("a profile holding another account's token shows EWRONGACCOUNT, not that ac
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the live plan is the profile endpoint's, not the one written at sign-in", async () => {
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { quotas, __resetCache } = require("../lib/quota");
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-plan-"));
|
||||||
|
const home = path.join(root, ".claude");
|
||||||
|
fs.mkdirSync(home, { recursive: true });
|
||||||
|
// Signed in on Pro; upgraded to Max 5x since — the files never learned.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(home, ".credentials.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
claudeAiOauth: {
|
||||||
|
accessToken: "live-upgraded",
|
||||||
|
expiresAt: Date.now() + 3600_000,
|
||||||
|
subscriptionType: "pro",
|
||||||
|
rateLimitTier: "default_claude_pro",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
`${home}.json`,
|
||||||
|
JSON.stringify({ oauthAccount: { accountUuid: "uuid-up", emailAddress: "up@x.y" } })
|
||||||
|
);
|
||||||
|
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
|
||||||
|
process.env.CLAUDE_HOME = home;
|
||||||
|
global.fetch = async (url) => ({
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: async () =>
|
||||||
|
url.endsWith("/profile")
|
||||||
|
? {
|
||||||
|
account: { uuid: "uuid-up", email: "up@x.y" },
|
||||||
|
organization: {
|
||||||
|
organization_type: "claude_max",
|
||||||
|
rate_limit_tier: "default_claude_max_5x",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { five_hour: { utilization: 10, resets_at: null } },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
__resetCache();
|
||||||
|
const live = (await quotas()).tools.find((t) => t.id === "claude" && t.active);
|
||||||
|
assert.strictEqual(live.plan, "Max 5x");
|
||||||
|
// Remembered: a later read from the files alone says the same.
|
||||||
|
const { shownPlan } = require("../lib/cc-profiles");
|
||||||
|
assert.strictEqual(
|
||||||
|
shownPlan({ subscriptionType: "pro" }, { accountUuid: "uuid-up" }),
|
||||||
|
"Max 5x"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
|
||||||
|
else process.env.CLAUDE_HOME = prev.home;
|
||||||
|
global.fetch = prev.fetch;
|
||||||
|
__resetCache();
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
|
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
@@ -222,7 +281,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.label, "Victor");
|
||||||
assert.strictEqual(live.account, "victor@x.y");
|
assert.strictEqual(live.account, "victor@x.y");
|
||||||
assert.strictEqual(live.ownerUuid, undefined);
|
assert.strictEqual(live.ownerUuid, undefined);
|
||||||
assert.strictEqual(live.plan, "max");
|
assert.strictEqual(live.plan, "Max");
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
claude.filter((t) => !t.active).map((t) => t.label),
|
claude.filter((t) => !t.active).map((t) => t.label),
|
||||||
["Thắng"]
|
["Thắng"]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const cursorPool = require("../lib/cursor-pool");
|
|||||||
|
|
||||||
// The quota read would go to the network; the pool's list must not.
|
// The quota read would go to the network; the pool's list must not.
|
||||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||||
|
|
||||||
let server;
|
let server;
|
||||||
let BASE;
|
let BASE;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const auth = require("../lib/auth");
|
|||||||
|
|
||||||
// The quota read would go to the network.
|
// The quota read would go to the network.
|
||||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||||
|
|
||||||
let server;
|
let server;
|
||||||
let BASE;
|
let BASE;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2004,6 +2004,23 @@ db.exec(`
|
|||||||
PRIMARY KEY (group_id, tool, account)
|
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
|
// A database from before the Cursor pool: its `tool` CHECK allowed only
|
||||||
// claude/codex. Rebuilt with every pin kept.
|
// claude/codex. Rebuilt with every pin kept.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -872,6 +872,26 @@ function startBackgroundServices() {
|
|||||||
}, POOL_REFRESH_MS);
|
}, POOL_REFRESH_MS);
|
||||||
if (poolTimer.unref) poolTimer.unref();
|
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)
|
// 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
|
// 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
|
// changed — and only while such a dashboard runs at all: alone on its data
|
||||||
|
|||||||
@@ -179,6 +179,81 @@ function readActiveAuth() {
|
|||||||
return { oauth: (creds && creds.claudeAiOauth) || null, account, userID, keychain };
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan Anthropic's profile endpoint reports for an account RIGHT NOW,
|
||||||
|
* keyed by account uuid (cc-usage.js `fetchOwner` fills it). The stored
|
||||||
|
* `subscriptionType` and rate-limit tiers are written at sign-in, so an
|
||||||
|
* account upgraded since (Pro → Max 5x) keeps reading as its old plan from
|
||||||
|
* the files alone; the live answer wins wherever one was seen.
|
||||||
|
*/
|
||||||
|
const livePlans = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `planLabel` of the profile endpoint's `organization` (`organization_type`
|
||||||
|
* `claude_max`, `rate_limit_tier` `default_claude_max_5x`, `seat_tier`), or null.
|
||||||
|
*/
|
||||||
|
function planFromProfile(org) {
|
||||||
|
if (!org || typeof org !== "object") return null;
|
||||||
|
const type = String(org.organization_type || "");
|
||||||
|
return planLabel(
|
||||||
|
{ subscriptionType: type.replace(/^claude_/, ""), rateLimitTier: org.rate_limit_tier || "" },
|
||||||
|
{ seatTier: org.seat_tier || "", organizationType: type }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remember an account's live plan (null/unknown leaves the last one). */
|
||||||
|
function rememberLivePlan(accountUuid, plan) {
|
||||||
|
if (!accountUuid || !plan) return;
|
||||||
|
if (livePlans.size > 100) livePlans.clear(); // a few accounts per machine
|
||||||
|
livePlans.set(accountUuid, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plan to show: the live one when seen, else what the files say. */
|
||||||
|
function shownPlan(oauth, account) {
|
||||||
|
return livePlans.get(account?.accountUuid) || planLabel(oauth, account);
|
||||||
|
}
|
||||||
|
|
||||||
/** Token-free description of an account, safe to send to the browser. */
|
/** Token-free description of an account, safe to send to the browser. */
|
||||||
function identityOf({ oauth, account }) {
|
function identityOf({ oauth, account }) {
|
||||||
return {
|
return {
|
||||||
@@ -188,6 +263,8 @@ function identityOf({ oauth, account }) {
|
|||||||
organization: (account && account.organizationName) || null,
|
organization: (account && account.organizationName) || null,
|
||||||
seatTier: (account && account.seatTier) || null,
|
seatTier: (account && account.seatTier) || null,
|
||||||
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
||||||
|
/** The readable plan (`shownPlan`: live when seen, else `planLabel`) — what the UI shows. */
|
||||||
|
plan: shownPlan(oauth, account),
|
||||||
/** When the stored access token expires; a switch still works after that
|
/** When the stored access token expires; a switch still works after that
|
||||||
* (the refresh token is stored too), it just refreshes on first use. */
|
* (the refresh token is stored too), it just refreshes on first use. */
|
||||||
expiresAt: (oauth && oauth.expiresAt) || null,
|
expiresAt: (oauth && oauth.expiresAt) || null,
|
||||||
@@ -544,6 +621,10 @@ module.exports = {
|
|||||||
writeProfile,
|
writeProfile,
|
||||||
liveCredentialsFor,
|
liveCredentialsFor,
|
||||||
readActiveAuth,
|
readActiveAuth,
|
||||||
|
planLabel,
|
||||||
|
planFromProfile,
|
||||||
|
rememberLivePlan,
|
||||||
|
shownPlan,
|
||||||
// exported for tests
|
// exported for tests
|
||||||
profilesDir,
|
profilesDir,
|
||||||
configPaths,
|
configPaths,
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ async function fetchUsage(accessToken) {
|
|||||||
const owners = new Map();
|
const owners = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The account a token belongs to — `{uuid, email}` — or null when the profile
|
* The account a token belongs to — `{uuid, email, plan}` (`plan` as the
|
||||||
|
* profile endpoint reports it now, see cc-profiles.js `livePlans`) — or null when the profile
|
||||||
* endpoint does not say. A saved profile's label and its token come from two
|
* endpoint does not say. A saved profile's label and its token come from two
|
||||||
* different files (`oauthAccount` and the credentials store), and a running
|
* different files (`oauthAccount` and the credentials store), and a running
|
||||||
* Claude Code session can leave them naming different accounts, so the token's
|
* Claude Code session can leave them naming different accounts, so the token's
|
||||||
@@ -179,9 +180,14 @@ async function fetchOwner(accessToken) {
|
|||||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const account = (await res.json())?.account;
|
const body = await res.json();
|
||||||
|
const account = body?.account;
|
||||||
if (!account?.uuid) return null;
|
if (!account?.uuid) return null;
|
||||||
const owner = { uuid: account.uuid, email: account.email || null };
|
// The plan as it is NOW (an upgrade since sign-in included): the stored
|
||||||
|
// files keep the plan the account had when it signed in.
|
||||||
|
const plan = profiles.planFromProfile(body.organization);
|
||||||
|
profiles.rememberLivePlan(account.uuid, plan);
|
||||||
|
const owner = { uuid: account.uuid, email: account.email || null, plan };
|
||||||
if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine
|
if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine
|
||||||
owners.set(accessToken, owner);
|
owners.set(accessToken, owner);
|
||||||
return owner;
|
return owner;
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ const inFlight = new Set();
|
|||||||
|
|
||||||
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
|
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
|
||||||
async function refreshQuotas({ force = false } = {}) {
|
async function refreshQuotas({ force = false } = {}) {
|
||||||
const { fetchUsage } = require("./cc-usage");
|
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
allAccounts().map(async (a) => {
|
allAccounts().map(async (a) => {
|
||||||
const cached = quotaCache.get(a.name);
|
const cached = quotaCache.get(a.name);
|
||||||
@@ -388,7 +388,12 @@ async function refreshQuotas({ force = false } = {}) {
|
|||||||
}
|
}
|
||||||
inFlight.add(a.name);
|
inFlight.add(a.name);
|
||||||
try {
|
try {
|
||||||
const u = await fetchUsage(bearerOf(a));
|
// A login's owner too: the profile endpoint's answer is its plan as it
|
||||||
|
// is now (cc-profiles.js livePlans), the stored one is the sign-in's.
|
||||||
|
const [u] = await Promise.all([
|
||||||
|
fetchUsage(bearerOf(a)),
|
||||||
|
a.kind === "login" ? fetchOwner(bearerOf(a)) : null,
|
||||||
|
]);
|
||||||
quotaCache.set(a.name, { at: Date.now(), ...u });
|
quotaCache.set(a.name, { at: Date.now(), ...u });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Keep the last good windows: a hiccup must not unblock a full account.
|
// Keep the last good windows: a hiccup must not unblock a full account.
|
||||||
@@ -435,6 +440,8 @@ function poolStatus() {
|
|||||||
name: a.name,
|
name: a.name,
|
||||||
kind: a.kind,
|
kind: a.kind,
|
||||||
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
|
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").shownPlan(a.oauth, a.account) : null,
|
||||||
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
|
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
|
||||||
addedAt: a.addedAt,
|
addedAt: a.addedAt,
|
||||||
expiresAt: expiresAt(a),
|
expiresAt: expiresAt(a),
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ function poolStatus() {
|
|||||||
return {
|
return {
|
||||||
name: a.name,
|
name: a.name,
|
||||||
kind: a.kind,
|
kind: a.kind,
|
||||||
|
plan: a.kind === "chatgpt" ? planOf(a.auth) : null,
|
||||||
addedAt: a.addedAt,
|
addedAt: a.addedAt,
|
||||||
livePanes: livePanes(a.name),
|
livePanes: livePanes(a.name),
|
||||||
...usageOf(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
|
* 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
|
* placed `a` in that home itself (`placedIn`), its `last_refresh` is no later
|
||||||
|
|||||||
+26
-1
@@ -55,6 +55,7 @@ const {
|
|||||||
git: rawGit,
|
git: rawGit,
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
listBranches,
|
listBranches,
|
||||||
|
listRemoteBranches,
|
||||||
branchCheckedOutAt,
|
branchCheckedOutAt,
|
||||||
statusCounts,
|
statusCounts,
|
||||||
operationInProgress,
|
operationInProgress,
|
||||||
@@ -532,7 +533,31 @@ async function runGitAction(dir, action, body = {}) {
|
|||||||
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
|
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
|
||||||
return done(action, await git(dir, ["switch", "-c", branch]), branch);
|
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);
|
const busyAt = await branchCheckedOutAt(dir, branch);
|
||||||
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
|
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
|
||||||
return done(action, await git(dir, ["switch", branch]), branch);
|
return done(action, await git(dir, ["switch", branch]), branch);
|
||||||
|
|||||||
+17
-3
@@ -108,16 +108,29 @@ function refill(t) {
|
|||||||
while (t.standby.length < STANDBY) t.standby.push(newToken());
|
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 = {};
|
const out = {};
|
||||||
for (const s of t.standby) out[`standby-${sha(s).slice(0, 16)}`] = { tokenSha256: sha(s) };
|
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)) {
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quotaBlocked(runId) {
|
||||||
|
try {
|
||||||
|
return require("./user-quota").isRunBlocked(runId);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let lastClients = null;
|
let lastClients = null;
|
||||||
|
|
||||||
function save(t) {
|
function save(t) {
|
||||||
@@ -519,6 +532,7 @@ module.exports = {
|
|||||||
takeToken,
|
takeToken,
|
||||||
pruneTokens,
|
pruneTokens,
|
||||||
clientsFor,
|
clientsFor,
|
||||||
|
clientsFor,
|
||||||
readTokens,
|
readTokens,
|
||||||
PROVIDER,
|
PROVIDER,
|
||||||
TOKEN_ENV,
|
TOKEN_ENV,
|
||||||
|
|||||||
@@ -471,6 +471,8 @@ function spawnRun(args) {
|
|||||||
// their .mcp.json) or a member in someone else's.
|
// their .mcp.json) or a member in someone else's.
|
||||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
|
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
|
||||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
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
|
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
// 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
|
// 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];
|
for (const k of Object.keys(env)) delete env[k];
|
||||||
Object.assign(env, own);
|
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
|
// `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,
|
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
|
||||||
|
|||||||
+13
-8
@@ -36,7 +36,7 @@
|
|||||||
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
||||||
*
|
*
|
||||||
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
* 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 plan as the profile endpoint reports it now, else `planLabel` of the stored files (tier + seat, not the coarse `subscriptionType`),
|
||||||
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
|
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
|
||||||
* `expiresAt`, when its stored access token runs out — Claude's
|
* `expiresAt`, when its stored access token runs out — Claude's
|
||||||
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
|
* `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 { promisify } = require("node:util");
|
||||||
const aiTools = require("./ai-tools");
|
const aiTools = require("./ai-tools");
|
||||||
const { getCodexHome } = require("./codex-home");
|
const { getCodexHome } = require("./codex-home");
|
||||||
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
const { readActiveAuth, readProfile, listProfiles, shownPlan } = require("./cc-profiles");
|
||||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||||
const { allRolloutFiles } = require("./codex-ingest");
|
const { allRolloutFiles } = require("./codex-ingest");
|
||||||
const { getCursorDir } = require("./cursor-usage");
|
const { getCursorDir } = require("./cursor-usage");
|
||||||
@@ -122,7 +122,8 @@ async function usageFor(token, uuid) {
|
|||||||
code: "EWRONGACCOUNT",
|
code: "EWRONGACCOUNT",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return claudeWindows(usage);
|
// The plan as the profile endpoint says it now (null: it did not answer).
|
||||||
|
return { windows: claudeWindows(usage), plan: owner?.plan || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */
|
/** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */
|
||||||
@@ -142,7 +143,7 @@ async function liveClaude(readAuth = readActiveAuth) {
|
|||||||
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
||||||
who = {
|
who = {
|
||||||
account: account?.emailAddress || account?.displayName || null,
|
account: account?.emailAddress || account?.displayName || null,
|
||||||
plan: oauth.subscriptionType || null,
|
plan: shownPlan(oauth, account),
|
||||||
expiresAt: iso(oauth.expiresAt),
|
expiresAt: iso(oauth.expiresAt),
|
||||||
};
|
};
|
||||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||||
@@ -154,6 +155,8 @@ async function liveClaude(readAuth = readActiveAuth) {
|
|||||||
return {
|
return {
|
||||||
...who,
|
...who,
|
||||||
account: owner?.email || who.account,
|
account: owner?.email || who.account,
|
||||||
|
// Live: the files keep the plan of the sign-in, not of an upgrade since.
|
||||||
|
plan: owner?.plan || who.plan,
|
||||||
ownerUuid: owner?.uuid || account?.accountUuid || null,
|
ownerUuid: owner?.uuid || account?.accountUuid || null,
|
||||||
windows: claudeWindows(usage),
|
windows: claudeWindows(usage),
|
||||||
};
|
};
|
||||||
@@ -172,7 +175,7 @@ async function liveClaude(readAuth = readActiveAuth) {
|
|||||||
async function profileClaude(item) {
|
async function profileClaude(item) {
|
||||||
const who = {
|
const who = {
|
||||||
account: item.email || item.displayName || null,
|
account: item.email || item.displayName || null,
|
||||||
plan: item.subscriptionType || null,
|
plan: item.plan || item.subscriptionType || null,
|
||||||
expiresAt: iso(item.expiresAt),
|
expiresAt: iso(item.expiresAt),
|
||||||
};
|
};
|
||||||
let creds = null;
|
let creds = null;
|
||||||
@@ -183,9 +186,9 @@ async function profileClaude(item) {
|
|||||||
}
|
}
|
||||||
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
||||||
try {
|
try {
|
||||||
const windows = await usageFor(creds.accessToken, item.accountUuid);
|
const { windows, plan } = await usageFor(creds.accessToken, item.accountUuid);
|
||||||
lastGood.set(item.name, { windows, at: Date.now() });
|
lastGood.set(item.name, { windows, at: Date.now() });
|
||||||
return { ...who, windows };
|
return { ...who, plan: plan || who.plan, windows };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
||||||
}
|
}
|
||||||
@@ -289,7 +292,9 @@ function jwtClaims(token) {
|
|||||||
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
|
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
|
||||||
function codexAccount(codexHome = getCodexHome()) {
|
function codexAccount(codexHome = getCodexHome()) {
|
||||||
try {
|
try {
|
||||||
const { tokens } = JSON.parse(readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 }));
|
const { tokens } = JSON.parse(
|
||||||
|
readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 })
|
||||||
|
);
|
||||||
const { exp } = jwtClaims(tokens.access_token);
|
const { exp } = jwtClaims(tokens.access_token);
|
||||||
const id = jwtClaims(tokens.id_token);
|
const id = jwtClaims(tokens.id_token);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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 };
|
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.
|
* Resolve the base branch: try origin/<wanted>, then <wanted>, then HEAD.
|
||||||
*/
|
*/
|
||||||
@@ -978,6 +1011,7 @@ module.exports = {
|
|||||||
isGitRepo,
|
isGitRepo,
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
listBranches,
|
listBranches,
|
||||||
|
listRemoteBranches,
|
||||||
resolveBase,
|
resolveBase,
|
||||||
slugify,
|
slugify,
|
||||||
listWorktrees,
|
listWorktrees,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
* (Auth) — Settings → Account: the account's own profile, password and logins
|
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
* - 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
|
* - 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/{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
|
* - 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)
|
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
|
||||||
@@ -37,6 +38,48 @@ const tags = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const schemas = {
|
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: {
|
AccountPin: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["userId", "username", "account"],
|
required: ["userId", "username", "account"],
|
||||||
@@ -214,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": {
|
"/api/me/instructions": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
@@ -504,11 +573,20 @@ const paths = {
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -712,10 +790,11 @@ const paths = {
|
|||||||
addWorkspaces: { type: "array", items: { type: "integer" } },
|
addWorkspaces: { type: "array", items: { type: "integer" } },
|
||||||
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
||||||
signOut: { type: "boolean", enum: [true] },
|
signOut: { type: "boolean", enum: [true] },
|
||||||
|
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
|
||||||
}),
|
}),
|
||||||
responses: {
|
responses: {
|
||||||
...changeResponses,
|
...changeResponses,
|
||||||
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"),
|
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA"),
|
||||||
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
|
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -734,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": {
|
"/api/users/{id}/password": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
|
|||||||
@@ -854,13 +854,13 @@ const paths = {
|
|||||||
"/api/lanes/{id}/git/branches": {
|
"/api/lanes/{id}/git/branches": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Lanes"],
|
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:
|
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",
|
operationId: "listLaneOwnBranches",
|
||||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "{available, branches, current, worktrees}." },
|
200: { description: "{available, branches, remotes, current, worktrees}." },
|
||||||
404: { description: "Lane not found." },
|
404: { description: "Lane not found." },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -411,6 +411,9 @@ const paths = {
|
|||||||
409: errorJson(
|
409: errorJson(
|
||||||
"Conflict with live state: ERUNLIVE (the pane is busy), ENOTSHARED, EMEMBERGONE, EOWNERFOLDER or ECWDACCOUNT."
|
"Conflict with live state: ERUNLIVE (the pane is busy), ENOTSHARED, EMEMBERGONE, EOWNERFOLDER or ECWDACCOUNT."
|
||||||
),
|
),
|
||||||
|
429: errorJson(
|
||||||
|
"EQUOTA (server mode): the workspace owner's spend quota is used up until it resets."
|
||||||
|
),
|
||||||
503: errorJson("tmux is not installed (ETMUXMISSING)."),
|
503: errorJson("tmux is not installed (ETMUXMISSING)."),
|
||||||
500: errorJson("Unexpected failure (EINTERNAL)."),
|
500: errorJson("Unexpected failure (EINTERNAL)."),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ router.get("/profiles", (req, res) => {
|
|||||||
name: p.name,
|
name: p.name,
|
||||||
label: p.label,
|
label: p.label,
|
||||||
email: p.email,
|
email: p.email,
|
||||||
subscriptionType: p.subscriptionType,
|
subscriptionType: p.plan || p.subscriptionType,
|
||||||
active: p.active,
|
active: p.active,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-4
@@ -48,6 +48,7 @@ const {
|
|||||||
isGitRepo,
|
isGitRepo,
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
listBranches,
|
listBranches,
|
||||||
|
listRemoteBranches,
|
||||||
listWorktrees,
|
listWorktrees,
|
||||||
resetWorktree,
|
resetWorktree,
|
||||||
resolveBase,
|
resolveBase,
|
||||||
@@ -1913,7 +1914,9 @@ router.get("/:id/git/tree", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lane's own local branches, for the branch switcher. Separate from
|
* The lane's own local branches, for the branch switcher, plus `remotes`: the
|
||||||
|
* fetched remote-tracking branches with no local branch yet (`origin/feature`
|
||||||
|
* — switching to one creates its tracking branch). Separate from
|
||||||
* `GET /branches` (which takes a candidate repo PATH from the query, for a
|
* `GET /branches` (which takes a candidate repo PATH from the query, for a
|
||||||
* lane that does not exist yet); this one resolves the repo from the lane row,
|
* lane that does not exist yet); this one resolves the repo from the lane row,
|
||||||
* so no path ever comes from the request.
|
* so no path ever comes from the request.
|
||||||
@@ -1925,9 +1928,10 @@ router.get("/:id/git/branches", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { branches, current } = await listBranches(cwd);
|
const { branches, current } = await listBranches(cwd);
|
||||||
const worktrees = await listWorktrees(cwd).catch(() => []);
|
const worktrees = await listWorktrees(cwd).catch(() => []);
|
||||||
res.json({ available: true, branches, current, worktrees });
|
const remotes = (await listRemoteBranches(cwd).catch(() => [])).map((r) => r.name);
|
||||||
|
res.json({ available: true, branches, remotes, current, worktrees });
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ available: false, branches: [], current: null, worktrees: [] });
|
res.json({ available: false, branches: [], remotes: [], current: null, worktrees: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1987,6 +1991,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
|||||||
"EBADMESSAGE",
|
"EBADMESSAGE",
|
||||||
"EBADBRANCH",
|
"EBADBRANCH",
|
||||||
"ENOBRANCH",
|
"ENOBRANCH",
|
||||||
|
"EAMBIGUOUS",
|
||||||
"EBRANCHEXISTS",
|
"EBRANCHEXISTS",
|
||||||
"EBRANCHBUSY",
|
"EBRANCHBUSY",
|
||||||
"EDIRTY",
|
"EDIRTY",
|
||||||
@@ -3041,11 +3046,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
|||||||
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
|
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
|
||||||
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
|
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
|
||||||
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
|
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
|
||||||
|
// EQUOTA (server mode): the workspace owner is over their spend quota.
|
||||||
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
|
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
|
||||||
? 400
|
? 400
|
||||||
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
||||||
? 409
|
? 409
|
||||||
: 500;
|
: err.code === "EQUOTA"
|
||||||
|
? 429
|
||||||
|
: 500;
|
||||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
|
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
|
||||||
* while an admin pins it (`ELOCKED`) or for an account it may not use
|
* while an admin pins it (`ELOCKED`) or for an account it may not use
|
||||||
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
|
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
|
||||||
|
* `/quota` is the account's own spend quota now (server/lib/user-quota.js —
|
||||||
|
* `null` without one, and in local mode), for the status bar.
|
||||||
*
|
*
|
||||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||||
* the theme in their own Claude Code settings, written as them.
|
* the theme in their own Claude Code settings, written as them.
|
||||||
@@ -170,6 +172,16 @@ router.put("/instructions", sameOriginGuard, (req, res) => {
|
|||||||
res.json(instructionsView(key));
|
res.json(instructionsView(key));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The account's own spend quota now (server/lib/user-quota.js): `{quota}` —
|
||||||
|
// limit, period, spend, whether it is used up and when it opens again — or
|
||||||
|
// `{quota: null}` with no limit, and always in local mode.
|
||||||
|
router.get("/quota", (req, res) => {
|
||||||
|
const key = userKey(req);
|
||||||
|
if (!key) return unauth(res);
|
||||||
|
const id = auth.isServerMode() ? (req.user || auth.userFromRequest(req))?.id : null;
|
||||||
|
res.json({ quota: id == null ? null : require("../lib/user-quota").statusFor(id) });
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/onboarded", sameOriginGuard, (req, res) => {
|
router.post("/onboarded", sameOriginGuard, (req, res) => {
|
||||||
const key = userKey(req);
|
const key = userKey(req);
|
||||||
if (!key) return unauth(res);
|
if (!key) return unauth(res);
|
||||||
|
|||||||
@@ -672,6 +672,10 @@ router.post("/", (req, res) => {
|
|||||||
) {
|
) {
|
||||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||||
}
|
}
|
||||||
|
// Server mode: the workspace owner is over their spend quota (user-quota.js).
|
||||||
|
if (err.code === "EQUOTA") {
|
||||||
|
return res.status(429).json({ error: { code: err.code, message: err.message } });
|
||||||
|
}
|
||||||
// Nor is a host missing tmux: the request was fine, the dependency is not
|
// Nor is a host missing tmux: the request was fine, the dependency is not
|
||||||
// installed. 400 would tell the user to fix their input.
|
// installed. 400 would tell the user to fix their input.
|
||||||
if (err.code === "ETMUXMISSING") {
|
if (err.code === "ETMUXMISSING") {
|
||||||
|
|||||||
+57
-2
@@ -37,6 +37,12 @@
|
|||||||
* non-empty and makes that account pick its own at its next login
|
* non-empty and makes that account pick its own at its next login
|
||||||
* (`must_change_password`); an admin's own new password must pass every
|
* (`must_change_password`); an admin's own new password must pass every
|
||||||
* rule, like Settings → Account's (`validateOwnPassword`, EWEAKPASSWORD).
|
* rule, like Settings → Account's (`validateOwnPassword`, EWEAKPASSWORD).
|
||||||
|
*
|
||||||
|
* Spend quotas (server/lib/user-quota.js): `GET /` also gives each account
|
||||||
|
* its own setting (`quotaSetting`) and the quota binding it now with its spend
|
||||||
|
* (`quota`), plus the `quotaDefault`; `PUT /quota-default` sets or clears the
|
||||||
|
* default and `PATCH /:id` `{quota}` a user's own (`default`, `unlimited` or
|
||||||
|
* `custom`) — account changes like the rest, and each re-sweeps at once.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -130,6 +136,8 @@ router.get("/", (req, res) => {
|
|||||||
rules
|
rules
|
||||||
);
|
);
|
||||||
const none = { cost: 0, tokens: 0 };
|
const none = { cost: 0, tokens: 0 };
|
||||||
|
const quota = require("../lib/user-quota");
|
||||||
|
const quotas = quota.statusAll();
|
||||||
const users = listUsers().map((u) => ({
|
const users = listUsers().map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
|
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
|
||||||
@@ -138,8 +146,12 @@ router.get("/", (req, res) => {
|
|||||||
today: today.get(u.id) || none,
|
today: today.get(u.id) || none,
|
||||||
last7d: last7d.get(u.id) || none,
|
last7d: last7d.get(u.id) || none,
|
||||||
last30d: last30d.get(u.id) || none,
|
last30d: last30d.get(u.id) || none,
|
||||||
|
// Spend quota (user-quota.js): this user's own setting, and the quota that
|
||||||
|
// binds them now with its spend (null: no limit).
|
||||||
|
quotaSetting: quota.getOverride(u.id),
|
||||||
|
quota: quotas.get(u.id) || null,
|
||||||
}));
|
}));
|
||||||
res.json({ users });
|
res.json({ users, quotaDefault: quota.getDefault() });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/storage", (req, res) => {
|
router.get("/storage", (req, res) => {
|
||||||
@@ -164,6 +176,7 @@ const STATUS = {
|
|||||||
EWEAKPASSWORD: 400,
|
EWEAKPASSWORD: 400,
|
||||||
EBADROLE: 400,
|
EBADROLE: 400,
|
||||||
EBADFIELD: 400,
|
EBADFIELD: 400,
|
||||||
|
EBADQUOTA: 400,
|
||||||
EREAUTH: 403,
|
EREAUTH: 403,
|
||||||
ELOCALMODE: 403,
|
ELOCALMODE: 403,
|
||||||
ENOUSER: 404,
|
ENOUSER: 404,
|
||||||
@@ -196,7 +209,9 @@ function accountChange(handler) {
|
|||||||
await auth.confirmPassword(req.user, body.currentPassword);
|
await auth.confirmPassword(req.user, body.currentPassword);
|
||||||
const out = await handler(req, body);
|
const out = await handler(req, body);
|
||||||
recordAccountChange(req.user.id, out.user.id, req.method, out.action);
|
recordAccountChange(req.user.id, out.user.id, req.method, out.action);
|
||||||
res.status(out.status || 200).json({ user: out.user });
|
res
|
||||||
|
.status(out.status || 200)
|
||||||
|
.json({ user: out.user, ...("quotaDefault" in out && { quotaDefault: out.quotaDefault }) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = STATUS[err.code];
|
const status = STATUS[err.code];
|
||||||
if (!status) return res.status(500).json({ error: { message: err.message } });
|
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||||
@@ -507,6 +522,37 @@ router.post(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default spend quota every user without one of their own gets
|
||||||
|
* (server/lib/user-quota.js): `{quota: {limit_usd, period, tz_offset}}`, or
|
||||||
|
* `{quota: null}` for none. The admin's password again, audited.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/quota-default",
|
||||||
|
accountChange(async (req, body) => {
|
||||||
|
const quota = require("../lib/user-quota");
|
||||||
|
if (body.quota === undefined) {
|
||||||
|
throw Object.assign(new Error("quota: an object or null"), { code: "EBADQUOTA" });
|
||||||
|
}
|
||||||
|
const saved = quota.setDefault(body.quota);
|
||||||
|
quotaSweep();
|
||||||
|
return {
|
||||||
|
user: auth.publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.user.id)),
|
||||||
|
action: `default spend quota: ${saved ? `$${saved.limit_usd}/${saved.period}` : "none"}`,
|
||||||
|
quotaDefault: saved,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Apply a quota change now rather than at the next minute's sweep. */
|
||||||
|
function quotaSweep() {
|
||||||
|
try {
|
||||||
|
require("../lib/user-quota").sweep();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("user quota sweep failed:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.patch(
|
router.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
accountChange(async (req, body) => {
|
accountChange(async (req, body) => {
|
||||||
@@ -542,6 +588,8 @@ router.patch(
|
|||||||
if (body.signOut !== undefined && body.signOut !== true) {
|
if (body.signOut !== undefined && body.signOut !== true) {
|
||||||
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
|
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
|
||||||
}
|
}
|
||||||
|
const quotaLib = require("../lib/user-quota");
|
||||||
|
const quota = body.quota === undefined ? null : quotaLib.validateUserQuota(body.quota);
|
||||||
const add = laneIds(body.addWorkspaces, "addWorkspaces");
|
const add = laneIds(body.addWorkspaces, "addWorkspaces");
|
||||||
const remove = laneIds(body.removeWorkspaces, "removeWorkspaces");
|
const remove = laneIds(body.removeWorkspaces, "removeWorkspaces");
|
||||||
const runAs = require("../lib/run-as");
|
const runAs = require("../lib/run-as");
|
||||||
@@ -584,6 +632,11 @@ router.patch(
|
|||||||
auth.endSessions(u.id, keepToken);
|
auth.endSessions(u.id, keepToken);
|
||||||
done.push("signed out everywhere");
|
done.push("signed out everywhere");
|
||||||
}
|
}
|
||||||
|
if (quota) {
|
||||||
|
quotaLib.setUserQuota(u.id, quota);
|
||||||
|
done.push(`spend quota ${quotaLib.describe(quota)}`);
|
||||||
|
quotaSweep();
|
||||||
|
}
|
||||||
if (disabledChange) {
|
if (disabledChange) {
|
||||||
auth.setDisabled(u.username, body.disabled);
|
auth.setDisabled(u.username, body.disabled);
|
||||||
done.push(body.disabled ? "disable" : "enable");
|
done.push(body.disabled ? "disable" : "enable");
|
||||||
@@ -710,3 +763,5 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
// Which account each session belongs to, for the spend quotas (server/lib/user-quota.js).
|
||||||
|
module.exports.OWNED_SESSIONS = OWNED_SESSIONS;
|
||||||
|
|||||||
Reference in New Issue
Block a user