fa27b299e7
An admin sets a default spend quota on the Users page and overrides it per
user (Edit user → Spend quota: default / no limit / their own), in dollars
per local day or per sliding 5 hours. Spend is the users page's own
hourlyTokenRows + calculateCost over the sessions a user owns.
Over the quota, until it opens again:
- spawnRun refuses every start/resume/home prompt with EQUOTA (429);
- a minute sweep writes each pane's AGENTHUB_QUOTA_FILE (host: data dir,
member: handoff dir via write-as) and hook-handler.js exits 2 on
UserPromptSubmit from that file alone (Claude Code, Codex with trusted
hooks) — fail-open, never asks the dashboard, never for Antigravity;
- a member's omp on the shared login loses its gateway key (clientsFor);
- panes no hook can stop (Antigravity, Cursor, Codex without trusted
hooks, omp on its own login) are ended.
GET /api/me/quota feeds a "Quota N%" chip in the status bar.
New: server/lib/user-quota.js, user_quotas table, PUT /api/users/quota-default,
PATCH /api/users/:id {quota}, docs in SERVER-MODE.md § Spend quotas.
270 lines
8.6 KiB
TypeScript
270 lines
8.6 KiB
TypeScript
/**
|
|
* @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>
|
|
);
|
|
}
|