fix(quota): 配额表单整体替换,留空即不限、0 即不给、负数和越界拒收,超配额的服务器仍可调小

This commit is contained in:
Lemon-miaow committed 2026-09-27 02:26:03 +08:00
1 parent ee4065b986
commit 7aa5034c1a
17 files changed
+346 -109

No files matched your search

@@ -218,6 +218,7 @@
"users_quota_memory": "Max Memory (MiB)",
"users_quota_storage": "Max Storage (GiB)",
"users_quota_unlimited": "Unlimited",
"users_quota_hint": "Leave a box empty for unlimited. 0 grants none of it, so every claim that needs it is refused. A cap below current use only stops new claims; servers the user already has stay.",
"users_sessions": "Sessions",
"users_no_sessions": "No active sessions.",
"users_session_expires": "Expires",
@@ -74,6 +74,7 @@
"account_retired": "This account has been retired — sign in with the account it was migrated to.",
"no_migration": "There is no migration in progress.",
"not_confirmed": "Confirm it's you in this browser first. A confirmation lasts 10 minutes.",
"invalid_quota": "A quota must be a whole number from 0 to 2147483647. Leave it empty for unlimited.",
"already_confirmed": "This migration has already been confirmed.",
"invalid_target": "The migration target must be a different account.",
"target_not_found": "No account matches that migration target.",
@@ -217,6 +217,7 @@
"users_quota_memory": "最大内存(MiB)",
"users_quota_storage": "最大存储(GiB)",
"users_quota_unlimited": "无限制",
"users_quota_hint": "留空为不限。填 0 表示这一项不给额度,该用户需要它的认领都会被拒绝。调低到已用量以下只挡新的认领,已有的服务器不受影响。",
"users_sessions": "活跃会话",
"users_no_sessions": "无活跃会话。",
"users_session_expires": "过期时间",
@@ -74,6 +74,7 @@
"account_retired": "该账户已退役——请使用迁移后的账户登录。",
"no_migration": "当前没有进行中的迁移。",
"not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。",
"invalid_quota": "配额要填 0 到 2147483647 之间的整数,不限就留空。",
"already_confirmed": "该迁移已经确认过了。",
"invalid_target": "迁移目标账户不能与来源账户相同。",
"target_not_found": "找不到迁移目标账户。",
+2
View File
@@ -1146,6 +1146,8 @@ export function humanizeError(e: unknown): string {
return t("no_migration");
case "not_confirmed":
return t("not_confirmed");
case "invalid_quota":
return t("invalid_quota");
case "already_confirmed":
return t("already_confirmed");
case "invalid_target":
+14 -3
View File
@@ -1263,7 +1263,10 @@ export interface paths {
};
/** Get a user's quotas (admin only). */
get: operations["getQuotas"];
/** Set a user's quotas (admin only). */
/**
* Set a user's quotas (admin only).
* @description Replaces all four caps at once. An absent or null field is unlimited; 0 grants none of that resource, so every claim that needs it is refused. An empty body lifts every cap. A cap below what the user already owns refuses new claims and leaves the servers they have alone.
*/
put: operations["setQuotas"];
post?: never;
delete?: never;
@@ -2620,7 +2623,7 @@ export interface components {
verified_at: string;
}[];
};
/** @description A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited. */
/** @description A user's quotas row (internal/api/repo.go QuotaView). An absent or null field is unlimited; 0 grants none of that resource. */
QuotaView: {
user_id: string;
max_servers?: number | null;
@@ -6119,7 +6122,15 @@ export interface operations {
"application/json": components["schemas"]["QuotaView"];
};
};
400: components["responses"]["BadRequest"];
/** @description invalid_quota, a cap outside 0..2147483647; or a body that is not JSON or carries a fraction. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown user. */
@@ -10,6 +10,7 @@ import type { UserDetail } from "@/lib/types";
const calls = vi.hoisted(() => ({
getUser: vi.fn(),
getUserQuotas: vi.fn(),
setUserQuotas: vi.fn(),
listUserSessions: vi.fn(),
revokeUserSession: vi.fn(),
revokeUserSessions: vi.fn(),
@@ -240,3 +241,57 @@ describe("UserDetailPage", () => {
});
});
});
describe("UserDetailPage quotas", () => {
function quotaBox(label: string) {
return screen.getByLabelText(i18next.t(`admin:${label}`)) as HTMLInputElement;
}
function quotaCard() {
return quotaBox("users_quota_servers").closest(".space-y-4") as HTMLElement;
}
it("sends every box, an emptied one as unlimited and 0 as none", async () => {
calls.getUser.mockResolvedValue(USER);
calls.getUserQuotas.mockResolvedValue({ user_id: "u-1", max_servers: 3, max_cpu_milli: 2000 });
calls.setUserQuotas.mockResolvedValue({ user_id: "u-1", max_cpu_milli: 2000, max_storage_gb: 0 });
renderPage();
const user = userEvent.setup();
await waitFor(() => expect(quotaBox("users_quota_servers").value).toBe("3"));
expect(within(quotaCard()).getByText(i18next.t("admin:users_quota_hint"))).toBeTruthy();
await user.clear(quotaBox("users_quota_servers"));
await user.type(quotaBox("users_quota_storage"), "0");
await user.click(within(quotaCard()).getByRole("button", { name: i18next.t("admin:users_save_btn") }));
await waitFor(() => expect(calls.setUserQuotas).toHaveBeenCalledTimes(1));
expect(calls.setUserQuotas).toHaveBeenCalledWith("u-1", {
max_servers: null,
max_cpu_milli: 2000,
max_memory_mb: null,
max_storage_gb: 0,
});
});
it("refuses a box that is not a whole number the server can store, and sends nothing", async () => {
calls.getUser.mockResolvedValue(USER);
renderPage();
const user = userEvent.setup();
await waitFor(() => expect(quotaBox("users_quota_servers")).toBeTruthy());
for (const bad of ["-1", "1.5", "2abc", "2147483648"]) {
await user.clear(quotaBox("users_quota_memory"));
await user.type(quotaBox("users_quota_memory"), bad);
await user.click(within(quotaCard()).getByRole("button", { name: i18next.t("admin:users_save_btn") }));
expect(await within(quotaCard()).findByText(i18next.t("errors:invalid_quota"))).toBeTruthy();
expect(quotaBox("users_quota_memory").value).toBe(bad);
}
expect(calls.setUserQuotas).not.toHaveBeenCalled();
await user.clear(quotaBox("users_quota_memory"));
await user.type(quotaBox("users_quota_memory"), " 2147483647 ");
calls.setUserQuotas.mockResolvedValue({ user_id: "u-1", max_memory_mb: 2147483647 });
await user.click(within(quotaCard()).getByRole("button", { name: i18next.t("admin:users_save_btn") }));
await waitFor(() => expect(calls.setUserQuotas).toHaveBeenCalledTimes(1));
expect(calls.setUserQuotas.mock.calls[0][1].max_memory_mb).toBe(2147483647);
});
});
+29 -9
View File
@@ -428,13 +428,27 @@ function QuotasCard({ userId }: { userId: string }) {
setSaving(true);
setErr(null);
setOk(null);
// The form replaces all four caps: an empty box is unlimited, 0 grants none.
// Anything else must be a whole number the server's 32-bit columns hold; the
// boxes are text so a stray letter stays visible and is refused here instead
// of turning into an empty (unlimited) number field.
const parse = (s: string): number | null | undefined => {
const v = s.trim();
if (v === "") return null;
return /^\d+$/.test(v) && Number(v) <= 2147483647 ? Number(v) : undefined;
};
const [servers, cpu, mem, storage] = [maxServers, maxCpu, maxMem, maxStorage].map(parse);
if (servers === undefined || cpu === undefined || mem === undefined || storage === undefined) {
setErr(t("errors:invalid_quota"));
setSaving(false);
return;
}
try {
const toNum = (s: string) => (s === "" ? null : parseInt(s, 10));
await api.setUserQuotas(userId, {
max_servers: toNum(maxServers),
max_cpu_milli: toNum(maxCpu),
max_memory_mb: toNum(maxMem),
max_storage_gb: toNum(maxStorage),
max_servers: servers,
max_cpu_milli: cpu,
max_memory_mb: mem,
max_storage_gb: storage,
});
setOk(t("users_save_ok"));
reload();
@@ -464,7 +478,8 @@ function QuotasCard({ userId }: { userId: string }) {
<Label htmlFor="user-detail-quota-servers" className="text-xs font-semibold text-muted-foreground">{t("users_quota_servers")}</Label>
<Input
id="user-detail-quota-servers"
type="number"
type="text"
inputMode="numeric"
value={maxServers}
onChange={(e) => setMaxServers(e.target.value)}
placeholder={t("users_quota_unlimited")}
@@ -475,7 +490,8 @@ function QuotasCard({ userId }: { userId: string }) {
<Label htmlFor="user-detail-quota-cpu" className="text-xs font-semibold text-muted-foreground">{t("users_quota_cpu")}</Label>
<Input
id="user-detail-quota-cpu"
type="number"
type="text"
inputMode="numeric"
value={maxCpu}
onChange={(e) => setMaxCpu(e.target.value)}
placeholder={t("users_quota_unlimited")}
@@ -486,7 +502,8 @@ function QuotasCard({ userId }: { userId: string }) {
<Label htmlFor="user-detail-quota-memory" className="text-xs font-semibold text-muted-foreground">{t("users_quota_memory")}</Label>
<Input
id="user-detail-quota-memory"
type="number"
type="text"
inputMode="numeric"
value={maxMem}
onChange={(e) => setMaxMem(e.target.value)}
placeholder={t("users_quota_unlimited")}
@@ -497,7 +514,8 @@ function QuotasCard({ userId }: { userId: string }) {
<Label htmlFor="user-detail-quota-storage" className="text-xs font-semibold text-muted-foreground">{t("users_quota_storage")}</Label>
<Input
id="user-detail-quota-storage"
type="number"
type="text"
inputMode="numeric"
value={maxStorage}
onChange={(e) => setMaxStorage(e.target.value)}
placeholder={t("users_quota_unlimited")}
@@ -506,6 +524,8 @@ function QuotasCard({ userId }: { userId: string }) {
</div>
</div>
<p className="text-xs text-muted-foreground">{t("users_quota_hint")}</p>
{err && (
<MessageLine kind="error" message={err} />
)}