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 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
+ }