From 136f114fd93e22873ba6d40ee3fac1b58f84674b Mon Sep 17 00:00:00 2001 From: Sam5440 <47051833+Sam5440@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:25:17 +0800 Subject: [PATCH] feat: add online stats range setting (#208) Add online history retention settings with validation, legacy-backend defaults and regression coverage. Secure pull request formatting checks. Co-authored-by: naiba/CloudCode --- src/locales/en/translation.json | 2 + src/locales/zh-CN/translation.json | 2 + src/locales/zh-TW/translation.json | 2 + src/routes/settings.tsx | 31 ++++- src/test/settings-online-stats-days.test.tsx | 118 +++++++++++++++++++ src/test/settings-submit-error.test.tsx | 1 + src/types/api.ts | 4 + 7 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/test/settings-online-stats-days.test.tsx diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 63aa114..e228046 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -159,6 +159,8 @@ "IPChangeNotification": "IP Change Notification", "FullIPNotification": "Show Full IP Address in Notification Messages", "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "OnlineStatsDays": "Online stats range (days)", + "OnlineStatsDaysHint": "Retention and query window for per-server online periods; sessions outside the range are removed by the daily cleanup job.", "EditService": "Edit Service", "CreateService": "Create Service", "EditTask": "Edit Task", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 4fbec49..18c28a6 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -166,6 +166,8 @@ "IPChangeNotification": "IP变更通知", "FullIPNotification": "在通知消息中显示完整的 IP 地址", "EnableMCP": "启用 MCP 接入(默认关闭;启用前请确认已审阅 API 令牌 scope 与服务器白名单)", + "OnlineStatsDays": "在线统计范围(天)", + "OnlineStatsDaysHint": "设备在线时间段的统计与保留窗口,超出范围的记录会在每日清理时删除。", "LoginFailed": "登录失败", "BruteForceAttackingToken": "暴力攻击令牌", "BruteForceAttackingAgentSecret": "暴力攻击代理秘密", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index af41699..ce4d859 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -224,6 +224,8 @@ "StatusCancelled": "已取消" }, "EnableMCP": "啟用 MCP 端點(預設關閉;啟用前請先檢查 API token 範圍與伺服器允許清單)", + "OnlineStatsDays": "線上統計範圍(天)", + "OnlineStatsDaysHint": "裝置線上時間段的統計與保留視窗,超出範圍的紀錄會在每日清理時刪除。", "ApiTokens": "API Token", "CreateApiToken": "建立 API token", "CreateApiTokenDescription": "Token 會代表你對 MCP 與外部用戶端進行驗證,其權限不會超過你本身的權限。", diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index c53ac46..d616ee2 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -41,6 +41,7 @@ const settingFormSchema = z.object({ ignored_ip_notification: asOptionalField(z.string()), ip_change_notification_group_id: z.coerce.number().int().min(0), cover: z.coerce.number().int().min(1), + online_stats_days: z.coerce.number().int().min(7).max(3650), site_name: z.string().min(1), language: z.string().min(2), user_template: z.string().min(1), @@ -77,6 +78,9 @@ export default function SettingsPage() { defaultValues: config ? { ...config.config, + // 兼容尚未包含 online_stats_days 的旧后端:字段缺省时回退 + // 默认值,避免必填校验拦截其他设置的提交 + online_stats_days: config.config?.online_stats_days ?? 7, user_template: config.config?.user_template || Object.keys(config.frontend_templates?.filter((t) => !t.is_admin) || {})[0] || @@ -85,6 +89,7 @@ export default function SettingsPage() { : { ip_change_notification_group_id: 0, cover: 1, + online_stats_days: 7, site_name: "", language: "", user_template: "user-dist", @@ -96,7 +101,11 @@ export default function SettingsPage() { useEffect(() => { if (config?.config) { - form.reset(config?.config) + // 与 defaultValues 同一兼容口径:旧后端缺字段时回退默认值 + form.reset({ + ...config.config, + online_stats_days: config.config?.online_stats_days ?? 7, + }) } }, [config?.config, form]) @@ -563,6 +572,26 @@ export default function SettingsPage() { )} /> + ( + + {t("OnlineStatsDays")} + + + + {t("OnlineStatsDaysHint")} + + + )} + /> diff --git a/src/test/settings-online-stats-days.test.tsx b/src/test/settings-online-stats-days.test.tsx new file mode 100644 index 0000000..e866f10 --- /dev/null +++ b/src/test/settings-online-stats-days.test.tsx @@ -0,0 +1,118 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +vi.mock("sonner", () => ({ + toast: vi.fn(), +})) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string) => k, + i18n: { language: "en", changeLanguage: vi.fn() }, + }), + initReactI18next: { type: "3rdParty", init: () => undefined }, + Trans: ({ children }: { children?: React.ReactNode }) => children ?? null, +})) + +vi.mock("@/hooks/useAuth", () => ({ + useAuth: () => ({ profile: { id: 1, role: 0 }, loading: false }), +})) + +vi.mock("@/hooks/useNotfication", () => ({ + useNotification: () => ({ notifierGroup: [] }), +})) + +// h.data 可在每个用例中替换,覆盖"配置带/不带 online_stats_days"两种后端形态 +const h = vi.hoisted(() => ({ + data: { + config: { + site_name: "Nezha", + language: "zh-CN", + user_template: "user-dist", + cover: 1, + ip_change_notification_group_id: 0, + online_stats_days: 30, + }, + frontend_templates: [] as unknown[], + }, + updateSettings: vi.fn(), +})) + +vi.mock("@/hooks/useSetting", () => ({ + default: () => ({ data: h.data, mutate: vi.fn() }), +})) + +vi.mock("@/api/settings", () => ({ + updateSettings: (...args: unknown[]) => h.updateSettings(...args), +})) + +beforeEach(() => { + h.updateSettings.mockReset() + h.updateSettings.mockResolvedValue(undefined) + h.data.config.online_stats_days = 30 +}) + +afterEach(() => { + document.body.innerHTML = "" +}) + +async function renderSettings() { + const { default: SettingsPage } = await import("@/routes/settings") + await act(async () => { + render( + + + , + ) + }) +} + +async function submitWithDays(value: string) { + const input = screen.getByPlaceholderText("7") + await act(async () => { + fireEvent.change(input, { target: { value } }) + }) + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /Confirm/i })) + }) +} + +test.each(["6", "3651"])("rejects out-of-range online_stats_days (%s)", async (value) => { + await renderSettings() + await submitWithDays(value) + expect(h.updateSettings).not.toHaveBeenCalled() +}) + +test("rejects non-integer online_stats_days", async () => { + await renderSettings() + await submitWithDays("7.5") + expect(h.updateSettings).not.toHaveBeenCalled() +}) + +test("legacy backend without online_stats_days falls back to 7 and still submits", async () => { + // 模拟旧后端:配置里没有该字段(delete 掉而非置 undefined 字面量) + const legacy = h.data.config as Record + delete legacy.online_stats_days + + await renderSettings() + + // 初始化与 reset 后表单都应回退默认值,而不是把 undefined 交给必填校验 + const input = screen.getByPlaceholderText("7") as HTMLInputElement + expect(input.value).toBe("7") + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /Confirm/i })) + }) + await waitFor(() => expect(h.updateSettings).toHaveBeenCalled()) + + const payload = h.updateSettings.mock.calls[0][0] as Record + expect(payload.online_stats_days).toBe(7) +}) + +test("valid submit carries the field in the payload", async () => { + await renderSettings() + await submitWithDays("90") + await waitFor(() => expect(h.updateSettings).toHaveBeenCalled()) + expect(h.updateSettings.mock.calls[0][0]).toMatchObject({ online_stats_days: 90 }) +}) diff --git a/src/test/settings-submit-error.test.tsx b/src/test/settings-submit-error.test.tsx index 45f8704..d27cb46 100644 --- a/src/test/settings-submit-error.test.tsx +++ b/src/test/settings-submit-error.test.tsx @@ -35,6 +35,7 @@ const validConfig = { user_template: "user-dist", cover: 1, ip_change_notification_group_id: 0, + online_stats_days: 7, }, frontend_templates: [], } diff --git a/src/types/api.ts b/src/types/api.ts index 88460e8..0dd9859 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -726,6 +726,8 @@ export interface ModelSetting { oauth2_providers: string[] /** 反代部署下声明对外域名(逗号分隔),防止成员注册冲突 NAT 域名抢占路由 */ reserved_hosts: string + /** 设备在线时间段统计范围(天),最小 7,最大 3650 */ + online_stats_days: number site_name: string /** 用于前端判断生成的安装命令是否启用 TLS */ tls: boolean @@ -752,6 +754,8 @@ export interface ModelSettingForm { ip_change_notification_group_id: number /** @minLength 2 */ language: string + /** 设备在线时间段统计范围(天),最小 7,最大 3650 */ + online_stats_days?: number /** @minLength 1 */ site_name: string tls?: boolean