fix(quota): 配额表单整体替换,留空即不限、0 即不给、负数和越界拒收,超配额的服务器仍可调小
This commit is contained in:
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": "找不到迁移目标账户。",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user