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 <hi+cloudcode@nai.ba>
This commit is contained in:
Sam5440andnaiba/CloudCode authored and GitHub committed 2026-10-16 19:25:17 +08:00
1 parent 4620aa91e6
commit 136f114fd9
7 files changed
+159 -1

No files matched your search

+2
View File
@@ -159,6 +159,8 @@
"IPChangeNotification": "IP Change Notification", "IPChangeNotification": "IP Change Notification",
"FullIPNotification": "Show Full IP Address in Notification Messages", "FullIPNotification": "Show Full IP Address in Notification Messages",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", "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", "EditService": "Edit Service",
"CreateService": "Create Service", "CreateService": "Create Service",
"EditTask": "Edit Task", "EditTask": "Edit Task",
+2
View File
@@ -166,6 +166,8 @@
"IPChangeNotification": "IP变更通知", "IPChangeNotification": "IP变更通知",
"FullIPNotification": "在通知消息中显示完整的 IP 地址", "FullIPNotification": "在通知消息中显示完整的 IP 地址",
"EnableMCP": "启用 MCP 接入(默认关闭;启用前请确认已审阅 API 令牌 scope 与服务器白名单)", "EnableMCP": "启用 MCP 接入(默认关闭;启用前请确认已审阅 API 令牌 scope 与服务器白名单)",
"OnlineStatsDays": "在线统计范围(天)",
"OnlineStatsDaysHint": "设备在线时间段的统计与保留窗口,超出范围的记录会在每日清理时删除。",
"LoginFailed": "登录失败", "LoginFailed": "登录失败",
"BruteForceAttackingToken": "暴力攻击令牌", "BruteForceAttackingToken": "暴力攻击令牌",
"BruteForceAttackingAgentSecret": "暴力攻击代理秘密", "BruteForceAttackingAgentSecret": "暴力攻击代理秘密",
+2
View File
@@ -224,6 +224,8 @@
"StatusCancelled": "已取消" "StatusCancelled": "已取消"
}, },
"EnableMCP": "啟用 MCP 端點(預設關閉;啟用前請先檢查 API token 範圍與伺服器允許清單)", "EnableMCP": "啟用 MCP 端點(預設關閉;啟用前請先檢查 API token 範圍與伺服器允許清單)",
"OnlineStatsDays": "線上統計範圍(天)",
"OnlineStatsDaysHint": "裝置線上時間段的統計與保留視窗,超出範圍的紀錄會在每日清理時刪除。",
"ApiTokens": "API Token", "ApiTokens": "API Token",
"CreateApiToken": "建立 API token", "CreateApiToken": "建立 API token",
"CreateApiTokenDescription": "Token 會代表你對 MCP 與外部用戶端進行驗證,其權限不會超過你本身的權限。", "CreateApiTokenDescription": "Token 會代表你對 MCP 與外部用戶端進行驗證,其權限不會超過你本身的權限。",
+30 -1
View File
@@ -41,6 +41,7 @@ const settingFormSchema = z.object({
ignored_ip_notification: asOptionalField(z.string()), ignored_ip_notification: asOptionalField(z.string()),
ip_change_notification_group_id: z.coerce.number().int().min(0), ip_change_notification_group_id: z.coerce.number().int().min(0),
cover: z.coerce.number().int().min(1), cover: z.coerce.number().int().min(1),
online_stats_days: z.coerce.number().int().min(7).max(3650),
site_name: z.string().min(1), site_name: z.string().min(1),
language: z.string().min(2), language: z.string().min(2),
user_template: z.string().min(1), user_template: z.string().min(1),
@@ -77,6 +78,9 @@ export default function SettingsPage() {
defaultValues: config defaultValues: config
? { ? {
...config.config, ...config.config,
// 兼容尚未包含 online_stats_days 的旧后端:字段缺省时回退
// 默认值,避免必填校验拦截其他设置的提交
online_stats_days: config.config?.online_stats_days ?? 7,
user_template: user_template:
config.config?.user_template || config.config?.user_template ||
Object.keys(config.frontend_templates?.filter((t) => !t.is_admin) || {})[0] || 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, ip_change_notification_group_id: 0,
cover: 1, cover: 1,
online_stats_days: 7,
site_name: "", site_name: "",
language: "", language: "",
user_template: "user-dist", user_template: "user-dist",
@@ -96,7 +101,11 @@ export default function SettingsPage() {
useEffect(() => { useEffect(() => {
if (config?.config) { if (config?.config) {
form.reset(config?.config) // 与 defaultValues 同一兼容口径:旧后端缺字段时回退默认值
form.reset({
...config.config,
online_stats_days: config.config?.online_stats_days ?? 7,
})
} }
}, [config?.config, form]) }, [config?.config, form])
@@ -563,6 +572,26 @@ export default function SettingsPage() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="online_stats_days"
render={({ field }) => (
<FormItem>
<FormLabel>{t("OnlineStatsDays")}</FormLabel>
<FormControl>
<Input
type="number"
min={7}
max={3650}
placeholder="7"
{...field}
/>
</FormControl>
<FormDescription>{t("OnlineStatsDaysHint")}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">{t("Confirm")}</Button> <Button type="submit">{t("Confirm")}</Button>
</form> </form>
</Form> </Form>
@@ -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(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>,
)
})
}
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<string, unknown>
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<string, unknown>
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 })
})
+1
View File
@@ -35,6 +35,7 @@ const validConfig = {
user_template: "user-dist", user_template: "user-dist",
cover: 1, cover: 1,
ip_change_notification_group_id: 0, ip_change_notification_group_id: 0,
online_stats_days: 7,
}, },
frontend_templates: [], frontend_templates: [],
} }
+4
View File
@@ -726,6 +726,8 @@ export interface ModelSetting {
oauth2_providers: string[] oauth2_providers: string[]
/** 反代部署下声明对外域名(逗号分隔),防止成员注册冲突 NAT 域名抢占路由 */ /** 反代部署下声明对外域名(逗号分隔),防止成员注册冲突 NAT 域名抢占路由 */
reserved_hosts: string reserved_hosts: string
/** 设备在线时间段统计范围(天),最小 7,最大 3650 */
online_stats_days: number
site_name: string site_name: string
/** 用于前端判断生成的安装命令是否启用 TLS */ /** 用于前端判断生成的安装命令是否启用 TLS */
tls: boolean tls: boolean
@@ -752,6 +754,8 @@ export interface ModelSettingForm {
ip_change_notification_group_id: number ip_change_notification_group_id: number
/** @minLength 2 */ /** @minLength 2 */
language: string language: string
/** 设备在线时间段统计范围(天),最小 7,最大 3650 */
online_stats_days?: number
/** @minLength 1 */ /** @minLength 1 */
site_name: string site_name: string
tls?: boolean tls?: boolean