Spaces:
Paused
Paused
icebear icebear0828 commited on
Commit ·
07041c6
1
Parent(s): 6c928c9
feat: add quota settings UI to dashboard (#101)
Browse files* feat: add quota settings UI to dashboard (#92)
Allow users to configure quota refresh interval, warning thresholds,
and skip-exhausted toggle directly from the web dashboard instead of
editing YAML manually.
* docs: update CHANGELOG for quota settings UI
---------
Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
- CHANGELOG.md +4 -0
- shared/hooks/use-quota-settings.ts +63 -0
- shared/i18n/translations.ts +16 -0
- src/routes/__tests__/quota-settings.test.ts +201 -0
- src/routes/web.ts +80 -0
- web/src/App.tsx +2 -0
- web/src/components/QuotaSettings.tsx +183 -0
CHANGELOG.md
CHANGED
|
@@ -6,6 +6,10 @@
|
|
| 6 |
|
| 7 |
## [Unreleased]
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
### Fixed
|
| 10 |
|
| 11 |
- macOS Electron 桌面版登录报 `spawn Unknown system error -86`:CI 在 arm64 runner 上同时构建 arm64/x64 DMG,但只下载 arm64 的 curl-impersonate,导致 Intel Mac 用户 spawn 失败(EBADARCH);拆分为 per-arch 构建 + `setup-curl.ts` 支持 `--arch` 交叉下载;错误提示改为明确的架构不匹配诊断 (#96)
|
|
|
|
| 6 |
|
| 7 |
## [Unreleased]
|
| 8 |
|
| 9 |
+
### Added
|
| 10 |
+
|
| 11 |
+
- Dashboard 额度设置面板:可在 Web UI 直接调整额度刷新间隔、主/次预警阈值、自动跳过耗尽账号开关,无需手动编辑 YAML;API `GET/POST /admin/quota-settings` 支持鉴权 (#92)
|
| 12 |
+
|
| 13 |
### Fixed
|
| 14 |
|
| 15 |
- macOS Electron 桌面版登录报 `spawn Unknown system error -86`:CI 在 arm64 runner 上同时构建 arm64/x64 DMG,但只下载 arm64 的 curl-impersonate,导致 Intel Mac 用户 spawn 失败(EBADARCH);拆分为 per-arch 构建 + `setup-curl.ts` 支持 `--arch` 交叉下载;错误提示改为明确的架构不匹配诊断 (#96)
|
shared/hooks/use-quota-settings.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from "preact/hooks";
|
| 2 |
+
|
| 3 |
+
export interface QuotaSettingsData {
|
| 4 |
+
refresh_interval_minutes: number;
|
| 5 |
+
warning_thresholds: { primary: number[]; secondary: number[] };
|
| 6 |
+
skip_exhausted: boolean;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function useQuotaSettings(apiKey: string | null) {
|
| 10 |
+
const [data, setData] = useState<QuotaSettingsData | null>(null);
|
| 11 |
+
const [saving, setSaving] = useState(false);
|
| 12 |
+
const [error, setError] = useState<string | null>(null);
|
| 13 |
+
const [saved, setSaved] = useState(false);
|
| 14 |
+
|
| 15 |
+
const load = useCallback(async () => {
|
| 16 |
+
try {
|
| 17 |
+
const resp = await fetch("/admin/quota-settings");
|
| 18 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 19 |
+
const result: QuotaSettingsData = await resp.json();
|
| 20 |
+
setData(result);
|
| 21 |
+
setError(null);
|
| 22 |
+
} catch (err) {
|
| 23 |
+
setError(err instanceof Error ? err.message : String(err));
|
| 24 |
+
}
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
const save = useCallback(async (patch: Partial<QuotaSettingsData>) => {
|
| 28 |
+
setSaving(true);
|
| 29 |
+
setSaved(false);
|
| 30 |
+
setError(null);
|
| 31 |
+
try {
|
| 32 |
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
| 33 |
+
if (apiKey) {
|
| 34 |
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
| 35 |
+
}
|
| 36 |
+
const resp = await fetch("/admin/quota-settings", {
|
| 37 |
+
method: "POST",
|
| 38 |
+
headers,
|
| 39 |
+
body: JSON.stringify(patch),
|
| 40 |
+
});
|
| 41 |
+
if (!resp.ok) {
|
| 42 |
+
const body = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
|
| 43 |
+
throw new Error((body as { error?: string }).error ?? `HTTP ${resp.status}`);
|
| 44 |
+
}
|
| 45 |
+
const result = await resp.json() as { success: boolean } & QuotaSettingsData;
|
| 46 |
+
setData({
|
| 47 |
+
refresh_interval_minutes: result.refresh_interval_minutes,
|
| 48 |
+
warning_thresholds: result.warning_thresholds,
|
| 49 |
+
skip_exhausted: result.skip_exhausted,
|
| 50 |
+
});
|
| 51 |
+
setSaved(true);
|
| 52 |
+
setTimeout(() => setSaved(false), 3000);
|
| 53 |
+
} catch (err) {
|
| 54 |
+
setError(err instanceof Error ? err.message : String(err));
|
| 55 |
+
} finally {
|
| 56 |
+
setSaving(false);
|
| 57 |
+
}
|
| 58 |
+
}, [apiKey]);
|
| 59 |
+
|
| 60 |
+
useEffect(() => { load(); }, [load]);
|
| 61 |
+
|
| 62 |
+
return { data, saving, saved, error, save, load };
|
| 63 |
+
}
|
shared/i18n/translations.ts
CHANGED
|
@@ -181,6 +181,14 @@ export const translations = {
|
|
| 181 |
statusSkip: "Skip",
|
| 182 |
quotaCriticalWarning: "{count} account(s) approaching quota limit",
|
| 183 |
quotaWarning: "{count} account(s) quota usage elevated",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
},
|
| 185 |
zh: {
|
| 186 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
@@ -367,6 +375,14 @@ export const translations = {
|
|
| 367 |
statusSkip: "\u8df3\u8fc7",
|
| 368 |
quotaCriticalWarning: "{count} \u4e2a\u8d26\u53f7\u989d\u5ea6\u5373\u5c06\u8017\u5c3d",
|
| 369 |
quotaWarning: "{count} \u4e2a\u8d26\u53f7\u989d\u5ea6\u7528\u91cf\u8f83\u9ad8",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
},
|
| 371 |
} as const;
|
| 372 |
|
|
|
|
| 181 |
statusSkip: "Skip",
|
| 182 |
quotaCriticalWarning: "{count} account(s) approaching quota limit",
|
| 183 |
quotaWarning: "{count} account(s) quota usage elevated",
|
| 184 |
+
quotaSettings: "Quota Settings",
|
| 185 |
+
quotaRefreshInterval: "Refresh Interval",
|
| 186 |
+
quotaRefreshIntervalHint: "How often to check account quota in the background.",
|
| 187 |
+
quotaPrimaryThresholds: "Primary Warning Thresholds (%)",
|
| 188 |
+
quotaSecondaryThresholds: "Secondary Warning Thresholds (%)",
|
| 189 |
+
quotaThresholdsHint: "Comma-separated percentages (1–100). Highest = critical, rest = warning.",
|
| 190 |
+
quotaSkipExhausted: "Auto-skip exhausted accounts",
|
| 191 |
+
quotaSaved: "Saved",
|
| 192 |
},
|
| 193 |
zh: {
|
| 194 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
|
|
| 375 |
statusSkip: "\u8df3\u8fc7",
|
| 376 |
quotaCriticalWarning: "{count} \u4e2a\u8d26\u53f7\u989d\u5ea6\u5373\u5c06\u8017\u5c3d",
|
| 377 |
quotaWarning: "{count} \u4e2a\u8d26\u53f7\u989d\u5ea6\u7528\u91cf\u8f83\u9ad8",
|
| 378 |
+
quotaSettings: "\u989d\u5ea6\u8bbe\u7f6e",
|
| 379 |
+
quotaRefreshInterval: "\u5237\u65b0\u95f4\u9694",
|
| 380 |
+
quotaRefreshIntervalHint: "\u540e\u53f0\u68c0\u67e5\u8d26\u53f7\u989d\u5ea6\u7684\u9891\u7387\u3002",
|
| 381 |
+
quotaPrimaryThresholds: "\u4e3b\u989d\u5ea6\u9884\u8b66\u9608\u503c (%)",
|
| 382 |
+
quotaSecondaryThresholds: "\u6b21\u989d\u5ea6\u9884\u8b66\u9608\u503c (%)",
|
| 383 |
+
quotaThresholdsHint: "\u9017\u53f7\u5206\u9694\u7684\u767e\u5206\u6bd4 (1\u2013100)\uff0c\u6700\u9ad8\u503c=\u4e25\u91cd\uff0c\u5176\u4f59=\u8b66\u544a\u3002",
|
| 384 |
+
quotaSkipExhausted: "\u81ea\u52a8\u8df3\u8fc7\u989d\u5ea6\u8017\u5c3d\u8d26\u53f7",
|
| 385 |
+
quotaSaved: "\u5df2\u4fdd\u5b58",
|
| 386 |
},
|
| 387 |
} as const;
|
| 388 |
|
src/routes/__tests__/quota-settings.test.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tests for quota settings endpoints.
|
| 3 |
+
* GET /admin/quota-settings — read current quota config
|
| 4 |
+
* POST /admin/quota-settings — update quota config
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
| 8 |
+
|
| 9 |
+
// --- Mocks (before any imports) ---
|
| 10 |
+
|
| 11 |
+
const mockConfig = {
|
| 12 |
+
server: { proxy_api_key: null as string | null },
|
| 13 |
+
quota: {
|
| 14 |
+
refresh_interval_minutes: 5,
|
| 15 |
+
warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
|
| 16 |
+
skip_exhausted: true,
|
| 17 |
+
},
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
vi.mock("../../config.js", () => ({
|
| 21 |
+
getConfig: vi.fn(() => mockConfig),
|
| 22 |
+
reloadAllConfigs: vi.fn(),
|
| 23 |
+
}));
|
| 24 |
+
|
| 25 |
+
vi.mock("../../paths.js", () => ({
|
| 26 |
+
getConfigDir: vi.fn(() => "/tmp/test-config"),
|
| 27 |
+
getPublicDir: vi.fn(() => "/tmp/test-public"),
|
| 28 |
+
getDesktopPublicDir: vi.fn(() => "/tmp/test-desktop"),
|
| 29 |
+
getDataDir: vi.fn(() => "/tmp/test-data"),
|
| 30 |
+
getBinDir: vi.fn(() => "/tmp/test-bin"),
|
| 31 |
+
isEmbedded: vi.fn(() => false),
|
| 32 |
+
}));
|
| 33 |
+
|
| 34 |
+
vi.mock("../../utils/yaml-mutate.js", () => ({
|
| 35 |
+
mutateYaml: vi.fn(),
|
| 36 |
+
}));
|
| 37 |
+
|
| 38 |
+
vi.mock("../../tls/transport.js", () => ({
|
| 39 |
+
getTransport: vi.fn(),
|
| 40 |
+
getTransportInfo: vi.fn(() => ({})),
|
| 41 |
+
}));
|
| 42 |
+
|
| 43 |
+
vi.mock("../../tls/curl-binary.js", () => ({
|
| 44 |
+
getCurlDiagnostics: vi.fn(() => ({})),
|
| 45 |
+
}));
|
| 46 |
+
|
| 47 |
+
vi.mock("../../fingerprint/manager.js", () => ({
|
| 48 |
+
buildHeaders: vi.fn(() => ({})),
|
| 49 |
+
}));
|
| 50 |
+
|
| 51 |
+
vi.mock("../../update-checker.js", () => ({
|
| 52 |
+
getUpdateState: vi.fn(() => ({})),
|
| 53 |
+
checkForUpdate: vi.fn(),
|
| 54 |
+
isUpdateInProgress: vi.fn(() => false),
|
| 55 |
+
}));
|
| 56 |
+
|
| 57 |
+
vi.mock("../../self-update.js", () => ({
|
| 58 |
+
getProxyInfo: vi.fn(() => ({})),
|
| 59 |
+
canSelfUpdate: vi.fn(() => false),
|
| 60 |
+
checkProxySelfUpdate: vi.fn(),
|
| 61 |
+
applyProxySelfUpdate: vi.fn(),
|
| 62 |
+
isProxyUpdateInProgress: vi.fn(() => false),
|
| 63 |
+
getCachedProxyUpdateResult: vi.fn(() => null),
|
| 64 |
+
getDeployMode: vi.fn(() => "git"),
|
| 65 |
+
}));
|
| 66 |
+
|
| 67 |
+
vi.mock("@hono/node-server/serve-static", () => ({
|
| 68 |
+
serveStatic: vi.fn(() => vi.fn()),
|
| 69 |
+
}));
|
| 70 |
+
|
| 71 |
+
vi.mock("@hono/node-server/conninfo", () => ({
|
| 72 |
+
getConnInfo: vi.fn(() => ({ remote: { address: "127.0.0.1" } })),
|
| 73 |
+
}));
|
| 74 |
+
|
| 75 |
+
import { createWebRoutes } from "../web.js";
|
| 76 |
+
import { mutateYaml } from "../../utils/yaml-mutate.js";
|
| 77 |
+
|
| 78 |
+
const mockPool = {
|
| 79 |
+
getAll: vi.fn(() => []),
|
| 80 |
+
acquire: vi.fn(),
|
| 81 |
+
release: vi.fn(),
|
| 82 |
+
} as unknown as Parameters<typeof createWebRoutes>[0];
|
| 83 |
+
|
| 84 |
+
describe("GET /admin/quota-settings", () => {
|
| 85 |
+
beforeEach(() => {
|
| 86 |
+
vi.clearAllMocks();
|
| 87 |
+
mockConfig.quota = {
|
| 88 |
+
refresh_interval_minutes: 5,
|
| 89 |
+
warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
|
| 90 |
+
skip_exhausted: true,
|
| 91 |
+
};
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
it("returns current quota config", async () => {
|
| 95 |
+
const app = createWebRoutes(mockPool);
|
| 96 |
+
const res = await app.request("/admin/quota-settings");
|
| 97 |
+
expect(res.status).toBe(200);
|
| 98 |
+
const data = await res.json();
|
| 99 |
+
expect(data).toEqual({
|
| 100 |
+
refresh_interval_minutes: 5,
|
| 101 |
+
warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
|
| 102 |
+
skip_exhausted: true,
|
| 103 |
+
});
|
| 104 |
+
});
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
describe("POST /admin/quota-settings", () => {
|
| 108 |
+
beforeEach(() => {
|
| 109 |
+
vi.clearAllMocks();
|
| 110 |
+
mockConfig.server.proxy_api_key = null;
|
| 111 |
+
mockConfig.quota = {
|
| 112 |
+
refresh_interval_minutes: 5,
|
| 113 |
+
warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
|
| 114 |
+
skip_exhausted: true,
|
| 115 |
+
};
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
it("updates refresh interval", async () => {
|
| 119 |
+
const app = createWebRoutes(mockPool);
|
| 120 |
+
const res = await app.request("/admin/quota-settings", {
|
| 121 |
+
method: "POST",
|
| 122 |
+
headers: { "Content-Type": "application/json" },
|
| 123 |
+
body: JSON.stringify({ refresh_interval_minutes: 10 }),
|
| 124 |
+
});
|
| 125 |
+
expect(res.status).toBe(200);
|
| 126 |
+
const data = await res.json();
|
| 127 |
+
expect(data.success).toBe(true);
|
| 128 |
+
expect(mutateYaml).toHaveBeenCalledOnce();
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
it("updates warning thresholds", async () => {
|
| 132 |
+
const app = createWebRoutes(mockPool);
|
| 133 |
+
const res = await app.request("/admin/quota-settings", {
|
| 134 |
+
method: "POST",
|
| 135 |
+
headers: { "Content-Type": "application/json" },
|
| 136 |
+
body: JSON.stringify({
|
| 137 |
+
warning_thresholds: { primary: [70, 85, 95], secondary: [60] },
|
| 138 |
+
}),
|
| 139 |
+
});
|
| 140 |
+
expect(res.status).toBe(200);
|
| 141 |
+
const data = await res.json();
|
| 142 |
+
expect(data.success).toBe(true);
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
it("validates refresh_interval_minutes >= 1", async () => {
|
| 146 |
+
const app = createWebRoutes(mockPool);
|
| 147 |
+
const res = await app.request("/admin/quota-settings", {
|
| 148 |
+
method: "POST",
|
| 149 |
+
headers: { "Content-Type": "application/json" },
|
| 150 |
+
body: JSON.stringify({ refresh_interval_minutes: 0 }),
|
| 151 |
+
});
|
| 152 |
+
expect(res.status).toBe(400);
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
it("validates thresholds 1-100", async () => {
|
| 156 |
+
const app = createWebRoutes(mockPool);
|
| 157 |
+
const res = await app.request("/admin/quota-settings", {
|
| 158 |
+
method: "POST",
|
| 159 |
+
headers: { "Content-Type": "application/json" },
|
| 160 |
+
body: JSON.stringify({
|
| 161 |
+
warning_thresholds: { primary: [101] },
|
| 162 |
+
}),
|
| 163 |
+
});
|
| 164 |
+
expect(res.status).toBe(400);
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
it("requires auth when proxy_api_key is set", async () => {
|
| 168 |
+
mockConfig.server.proxy_api_key = "my-secret";
|
| 169 |
+
const app = createWebRoutes(mockPool);
|
| 170 |
+
|
| 171 |
+
// No auth → 401
|
| 172 |
+
const res1 = await app.request("/admin/quota-settings", {
|
| 173 |
+
method: "POST",
|
| 174 |
+
headers: { "Content-Type": "application/json" },
|
| 175 |
+
body: JSON.stringify({ refresh_interval_minutes: 10 }),
|
| 176 |
+
});
|
| 177 |
+
expect(res1.status).toBe(401);
|
| 178 |
+
|
| 179 |
+
// With auth → 200
|
| 180 |
+
const res2 = await app.request("/admin/quota-settings", {
|
| 181 |
+
method: "POST",
|
| 182 |
+
headers: {
|
| 183 |
+
"Content-Type": "application/json",
|
| 184 |
+
Authorization: "Bearer my-secret",
|
| 185 |
+
},
|
| 186 |
+
body: JSON.stringify({ refresh_interval_minutes: 10 }),
|
| 187 |
+
});
|
| 188 |
+
expect(res2.status).toBe(200);
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
it("updates skip_exhausted", async () => {
|
| 192 |
+
const app = createWebRoutes(mockPool);
|
| 193 |
+
const res = await app.request("/admin/quota-settings", {
|
| 194 |
+
method: "POST",
|
| 195 |
+
headers: { "Content-Type": "application/json" },
|
| 196 |
+
body: JSON.stringify({ skip_exhausted: false }),
|
| 197 |
+
});
|
| 198 |
+
expect(res.status).toBe(200);
|
| 199 |
+
expect((await res.json()).success).toBe(true);
|
| 200 |
+
});
|
| 201 |
+
});
|
src/routes/web.ts
CHANGED
|
@@ -451,6 +451,86 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
|
|
| 451 |
return c.json({ proxy_api_key: config.server.proxy_api_key });
|
| 452 |
});
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
app.post("/admin/settings", async (c) => {
|
| 455 |
const config = getConfig();
|
| 456 |
const currentKey = config.server.proxy_api_key;
|
|
|
|
| 451 |
return c.json({ proxy_api_key: config.server.proxy_api_key });
|
| 452 |
});
|
| 453 |
|
| 454 |
+
// --- Quota settings endpoints ---
|
| 455 |
+
|
| 456 |
+
app.get("/admin/quota-settings", (c) => {
|
| 457 |
+
const config = getConfig();
|
| 458 |
+
return c.json({
|
| 459 |
+
refresh_interval_minutes: config.quota.refresh_interval_minutes,
|
| 460 |
+
warning_thresholds: config.quota.warning_thresholds,
|
| 461 |
+
skip_exhausted: config.quota.skip_exhausted,
|
| 462 |
+
});
|
| 463 |
+
});
|
| 464 |
+
|
| 465 |
+
app.post("/admin/quota-settings", async (c) => {
|
| 466 |
+
const config = getConfig();
|
| 467 |
+
const currentKey = config.server.proxy_api_key;
|
| 468 |
+
|
| 469 |
+
// Auth: if a key is currently set, require Bearer token matching it
|
| 470 |
+
if (currentKey) {
|
| 471 |
+
const authHeader = c.req.header("Authorization") ?? "";
|
| 472 |
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
| 473 |
+
if (token !== currentKey) {
|
| 474 |
+
c.status(401);
|
| 475 |
+
return c.json({ error: "Invalid current API key" });
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
const body = await c.req.json() as {
|
| 480 |
+
refresh_interval_minutes?: number;
|
| 481 |
+
warning_thresholds?: { primary?: number[]; secondary?: number[] };
|
| 482 |
+
skip_exhausted?: boolean;
|
| 483 |
+
};
|
| 484 |
+
|
| 485 |
+
// Validate refresh_interval_minutes
|
| 486 |
+
if (body.refresh_interval_minutes !== undefined) {
|
| 487 |
+
if (!Number.isInteger(body.refresh_interval_minutes) || body.refresh_interval_minutes < 1) {
|
| 488 |
+
c.status(400);
|
| 489 |
+
return c.json({ error: "refresh_interval_minutes must be an integer >= 1" });
|
| 490 |
+
}
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
// Validate thresholds (1-100)
|
| 494 |
+
const validateThresholds = (arr?: number[]): boolean => {
|
| 495 |
+
if (!arr) return true;
|
| 496 |
+
return arr.every((v) => Number.isInteger(v) && v >= 1 && v <= 100);
|
| 497 |
+
};
|
| 498 |
+
if (body.warning_thresholds) {
|
| 499 |
+
if (!validateThresholds(body.warning_thresholds.primary) ||
|
| 500 |
+
!validateThresholds(body.warning_thresholds.secondary)) {
|
| 501 |
+
c.status(400);
|
| 502 |
+
return c.json({ error: "Thresholds must be integers between 1 and 100" });
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
const configPath = resolve(getConfigDir(), "default.yaml");
|
| 507 |
+
mutateYaml(configPath, (data) => {
|
| 508 |
+
if (!data.quota) data.quota = {};
|
| 509 |
+
const quota = data.quota as Record<string, unknown>;
|
| 510 |
+
if (body.refresh_interval_minutes !== undefined) {
|
| 511 |
+
quota.refresh_interval_minutes = body.refresh_interval_minutes;
|
| 512 |
+
}
|
| 513 |
+
if (body.warning_thresholds) {
|
| 514 |
+
const existing = (quota.warning_thresholds ?? {}) as Record<string, unknown>;
|
| 515 |
+
if (body.warning_thresholds.primary) existing.primary = body.warning_thresholds.primary;
|
| 516 |
+
if (body.warning_thresholds.secondary) existing.secondary = body.warning_thresholds.secondary;
|
| 517 |
+
quota.warning_thresholds = existing;
|
| 518 |
+
}
|
| 519 |
+
if (body.skip_exhausted !== undefined) {
|
| 520 |
+
quota.skip_exhausted = body.skip_exhausted;
|
| 521 |
+
}
|
| 522 |
+
});
|
| 523 |
+
reloadAllConfigs();
|
| 524 |
+
|
| 525 |
+
const updated = getConfig();
|
| 526 |
+
return c.json({
|
| 527 |
+
success: true,
|
| 528 |
+
refresh_interval_minutes: updated.quota.refresh_interval_minutes,
|
| 529 |
+
warning_thresholds: updated.quota.warning_thresholds,
|
| 530 |
+
skip_exhausted: updated.quota.skip_exhausted,
|
| 531 |
+
});
|
| 532 |
+
});
|
| 533 |
+
|
| 534 |
app.post("/admin/settings", async (c) => {
|
| 535 |
const config = getConfig();
|
| 536 |
const currentKey = config.server.proxy_api_key;
|
web/src/App.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { ApiConfig } from "./components/ApiConfig";
|
|
| 10 |
import { AnthropicSetup } from "./components/AnthropicSetup";
|
| 11 |
import { CodeExamples } from "./components/CodeExamples";
|
| 12 |
import { SettingsPanel } from "./components/SettingsPanel";
|
|
|
|
| 13 |
import { TestConnection } from "./components/TestConnection";
|
| 14 |
import { Footer } from "./components/Footer";
|
| 15 |
import { ProxySettings } from "./pages/ProxySettings";
|
|
@@ -148,6 +149,7 @@ function Dashboard() {
|
|
| 148 |
serviceTier={status.selectedSpeed}
|
| 149 |
/>
|
| 150 |
<SettingsPanel />
|
|
|
|
| 151 |
<TestConnection />
|
| 152 |
</div>
|
| 153 |
</main>
|
|
|
|
| 10 |
import { AnthropicSetup } from "./components/AnthropicSetup";
|
| 11 |
import { CodeExamples } from "./components/CodeExamples";
|
| 12 |
import { SettingsPanel } from "./components/SettingsPanel";
|
| 13 |
+
import { QuotaSettings } from "./components/QuotaSettings";
|
| 14 |
import { TestConnection } from "./components/TestConnection";
|
| 15 |
import { Footer } from "./components/Footer";
|
| 16 |
import { ProxySettings } from "./pages/ProxySettings";
|
|
|
|
| 149 |
serviceTier={status.selectedSpeed}
|
| 150 |
/>
|
| 151 |
<SettingsPanel />
|
| 152 |
+
<QuotaSettings />
|
| 153 |
<TestConnection />
|
| 154 |
</div>
|
| 155 |
</main>
|
web/src/components/QuotaSettings.tsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "../../../shared/i18n/context";
|
| 3 |
+
import { useQuotaSettings } from "../../../shared/hooks/use-quota-settings";
|
| 4 |
+
import { useSettings } from "../../../shared/hooks/use-settings";
|
| 5 |
+
|
| 6 |
+
export function QuotaSettings() {
|
| 7 |
+
const t = useT();
|
| 8 |
+
const settings = useSettings();
|
| 9 |
+
const qs = useQuotaSettings(settings.apiKey);
|
| 10 |
+
|
| 11 |
+
const [draftInterval, setDraftInterval] = useState<string | null>(null);
|
| 12 |
+
const [draftPrimary, setDraftPrimary] = useState<string | null>(null);
|
| 13 |
+
const [draftSecondary, setDraftSecondary] = useState<string | null>(null);
|
| 14 |
+
const [draftSkip, setDraftSkip] = useState<boolean | null>(null);
|
| 15 |
+
const [collapsed, setCollapsed] = useState(true);
|
| 16 |
+
|
| 17 |
+
const currentInterval = qs.data?.refresh_interval_minutes ?? 5;
|
| 18 |
+
const currentPrimary = qs.data?.warning_thresholds.primary ?? [80, 90];
|
| 19 |
+
const currentSecondary = qs.data?.warning_thresholds.secondary ?? [80, 90];
|
| 20 |
+
const currentSkip = qs.data?.skip_exhausted ?? true;
|
| 21 |
+
|
| 22 |
+
const displayInterval = draftInterval ?? String(currentInterval);
|
| 23 |
+
const displayPrimary = draftPrimary ?? currentPrimary.join(", ");
|
| 24 |
+
const displaySecondary = draftSecondary ?? currentSecondary.join(", ");
|
| 25 |
+
const displaySkip = draftSkip ?? currentSkip;
|
| 26 |
+
|
| 27 |
+
const isDirty =
|
| 28 |
+
draftInterval !== null ||
|
| 29 |
+
draftPrimary !== null ||
|
| 30 |
+
draftSecondary !== null ||
|
| 31 |
+
draftSkip !== null;
|
| 32 |
+
|
| 33 |
+
const parseThresholds = (str: string): number[] | null => {
|
| 34 |
+
if (!str.trim()) return [];
|
| 35 |
+
const parts = str.split(",").map((s) => s.trim()).filter(Boolean);
|
| 36 |
+
const nums = parts.map(Number);
|
| 37 |
+
if (nums.some((n) => isNaN(n) || !Number.isInteger(n) || n < 1 || n > 100)) return null;
|
| 38 |
+
return nums.sort((a, b) => a - b);
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const handleSave = useCallback(async () => {
|
| 42 |
+
const patch: Record<string, unknown> = {};
|
| 43 |
+
|
| 44 |
+
if (draftInterval !== null) {
|
| 45 |
+
const val = parseInt(draftInterval, 10);
|
| 46 |
+
if (isNaN(val) || val < 1) return;
|
| 47 |
+
patch.refresh_interval_minutes = val;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (draftPrimary !== null || draftSecondary !== null) {
|
| 51 |
+
const thresholds: Record<string, number[]> = {};
|
| 52 |
+
if (draftPrimary !== null) {
|
| 53 |
+
const parsed = parseThresholds(draftPrimary);
|
| 54 |
+
if (!parsed) return;
|
| 55 |
+
thresholds.primary = parsed;
|
| 56 |
+
}
|
| 57 |
+
if (draftSecondary !== null) {
|
| 58 |
+
const parsed = parseThresholds(draftSecondary);
|
| 59 |
+
if (!parsed) return;
|
| 60 |
+
thresholds.secondary = parsed;
|
| 61 |
+
}
|
| 62 |
+
patch.warning_thresholds = thresholds;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
if (draftSkip !== null) {
|
| 66 |
+
patch.skip_exhausted = draftSkip;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
await qs.save(patch);
|
| 70 |
+
setDraftInterval(null);
|
| 71 |
+
setDraftPrimary(null);
|
| 72 |
+
setDraftSecondary(null);
|
| 73 |
+
setDraftSkip(null);
|
| 74 |
+
}, [draftInterval, draftPrimary, draftSecondary, draftSkip, qs]);
|
| 75 |
+
|
| 76 |
+
const inputCls =
|
| 77 |
+
"w-full px-3 py-2 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-700 dark:text-text-main outline-none focus:ring-1 focus:ring-primary";
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl shadow-sm transition-colors">
|
| 81 |
+
<button
|
| 82 |
+
onClick={() => setCollapsed(!collapsed)}
|
| 83 |
+
class="w-full flex items-center justify-between p-5 cursor-pointer select-none"
|
| 84 |
+
>
|
| 85 |
+
<div class="flex items-center gap-2">
|
| 86 |
+
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 87 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
| 88 |
+
</svg>
|
| 89 |
+
<h2 class="text-[0.95rem] font-bold">{t("quotaSettings")}</h2>
|
| 90 |
+
</div>
|
| 91 |
+
<svg class={`size-5 text-slate-400 dark:text-text-dim transition-transform ${collapsed ? "" : "rotate-180"}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 92 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
| 93 |
+
</svg>
|
| 94 |
+
</button>
|
| 95 |
+
|
| 96 |
+
{!collapsed && (
|
| 97 |
+
<div class="px-5 pb-5 border-t border-slate-100 dark:border-border-dark pt-4 space-y-4">
|
| 98 |
+
{/* Refresh interval */}
|
| 99 |
+
<div class="space-y-1.5">
|
| 100 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">
|
| 101 |
+
{t("quotaRefreshInterval")}
|
| 102 |
+
</label>
|
| 103 |
+
<p class="text-xs text-slate-400 dark:text-text-dim">{t("quotaRefreshIntervalHint")}</p>
|
| 104 |
+
<div class="flex items-center gap-2">
|
| 105 |
+
<input
|
| 106 |
+
type="number"
|
| 107 |
+
min="1"
|
| 108 |
+
class={`${inputCls} max-w-[120px]`}
|
| 109 |
+
value={displayInterval}
|
| 110 |
+
onInput={(e) => setDraftInterval((e.target as HTMLInputElement).value)}
|
| 111 |
+
/>
|
| 112 |
+
<span class="text-xs text-slate-500 dark:text-text-dim">{t("minutes")}</span>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{/* Primary thresholds */}
|
| 117 |
+
<div class="space-y-1.5">
|
| 118 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">
|
| 119 |
+
{t("quotaPrimaryThresholds")}
|
| 120 |
+
</label>
|
| 121 |
+
<p class="text-xs text-slate-400 dark:text-text-dim">{t("quotaThresholdsHint")}</p>
|
| 122 |
+
<input
|
| 123 |
+
type="text"
|
| 124 |
+
class={inputCls}
|
| 125 |
+
value={displayPrimary}
|
| 126 |
+
onInput={(e) => setDraftPrimary((e.target as HTMLInputElement).value)}
|
| 127 |
+
placeholder="80, 90"
|
| 128 |
+
/>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
{/* Secondary thresholds */}
|
| 132 |
+
<div class="space-y-1.5">
|
| 133 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">
|
| 134 |
+
{t("quotaSecondaryThresholds")}
|
| 135 |
+
</label>
|
| 136 |
+
<input
|
| 137 |
+
type="text"
|
| 138 |
+
class={inputCls}
|
| 139 |
+
value={displaySecondary}
|
| 140 |
+
onInput={(e) => setDraftSecondary((e.target as HTMLInputElement).value)}
|
| 141 |
+
placeholder="80, 90"
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
{/* Skip exhausted */}
|
| 146 |
+
<div class="flex items-center gap-2">
|
| 147 |
+
<input
|
| 148 |
+
type="checkbox"
|
| 149 |
+
id="skip-exhausted"
|
| 150 |
+
checked={displaySkip}
|
| 151 |
+
onChange={(e) => setDraftSkip((e.target as HTMLInputElement).checked)}
|
| 152 |
+
class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer"
|
| 153 |
+
/>
|
| 154 |
+
<label for="skip-exhausted" class="text-xs font-semibold text-slate-700 dark:text-text-main cursor-pointer">
|
| 155 |
+
{t("quotaSkipExhausted")}
|
| 156 |
+
</label>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
{/* Save button + status */}
|
| 160 |
+
<div class="flex items-center gap-3">
|
| 161 |
+
<button
|
| 162 |
+
onClick={handleSave}
|
| 163 |
+
disabled={qs.saving || !isDirty}
|
| 164 |
+
class={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
|
| 165 |
+
isDirty && !qs.saving
|
| 166 |
+
? "bg-primary text-white hover:bg-primary/90 cursor-pointer"
|
| 167 |
+
: "bg-slate-100 dark:bg-[#21262d] text-slate-400 dark:text-text-dim cursor-not-allowed"
|
| 168 |
+
}`}
|
| 169 |
+
>
|
| 170 |
+
{qs.saving ? "..." : t("submit")}
|
| 171 |
+
</button>
|
| 172 |
+
{qs.saved && (
|
| 173 |
+
<span class="text-xs font-medium text-green-600 dark:text-green-400">{t("quotaSaved")}</span>
|
| 174 |
+
)}
|
| 175 |
+
{qs.error && (
|
| 176 |
+
<span class="text-xs font-medium text-red-500">{qs.error}</span>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
)}
|
| 181 |
+
</section>
|
| 182 |
+
);
|
| 183 |
+
}
|