fix(usage): persist dashboard refresh interval (#5057)

This commit is contained in:
makoMakoGo
2026-07-09 10:02:23 +08:00
committed by GitHub
parent 95c917b337
commit 98ccde0050
6 changed files with 209 additions and 7 deletions
+3
View File
@@ -366,6 +366,8 @@ pub struct AppSettings {
/// User has confirmed the usage query first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_confirmed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_dashboard_refresh_interval_ms: Option<u32>,
/// User has confirmed the stream check first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream_check_confirmed: Option<bool>,
@@ -501,6 +503,7 @@ impl Default for AppSettings {
enable_local_proxy: false,
proxy_confirmed: None,
usage_confirmed: None,
usage_dashboard_refresh_interval_ms: None,
stream_check_confirmed: None,
enable_failover_toggle: false,
preserve_codex_official_auth_on_switch: false,
+6 -1
View File
@@ -507,7 +507,12 @@ export function SettingsPage({
</TabsContent>
<TabsContent value="usage" className="mt-0">
<UsageDashboard />
<UsageDashboard
refreshIntervalMs={settings?.usageDashboardRefreshIntervalMs}
onRefreshIntervalChange={(usageDashboardRefreshIntervalMs) =>
handleAutoSave({ usageDashboardRefreshIntervalMs })
}
/>
</TabsContent>
</div>
+43 -6
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { UsageHero } from "./UsageHero";
import { UsageTrendChart } from "./UsageTrendChart";
@@ -46,8 +46,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES];
// 0 表示关闭自动刷新(refetchInterval=false
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
const REFRESH_INTERVAL_OPTIONS_MS = [0, 5000, 10000, 30000, 60000] as const;
type RefreshIntervalOption = (typeof REFRESH_INTERVAL_OPTIONS_MS)[number];
const isRefreshIntervalOption = (
value: number | undefined,
): value is RefreshIntervalOption =>
REFRESH_INTERVAL_OPTIONS_MS.includes(value as RefreshIntervalOption);
const normalizeRefreshInterval = (value: number | undefined) =>
isRefreshIntervalOption(value) ? value : DEFAULT_REFRESH_INTERVAL_MS;
// 与 AppSwitcher 的 appIconName 保持一致(codex 复用 openai 图标)
const APP_FILTER_ICON: Record<AppType, string> = {
@@ -64,7 +73,15 @@ const encodeOptionValue = (name: string) => `${DYNAMIC_OPTION_PREFIX}${name}`;
const decodeOptionValue = (value: string) =>
value === "all" ? undefined : value.slice(DYNAMIC_OPTION_PREFIX.length);
export function UsageDashboard() {
interface UsageDashboardProps {
refreshIntervalMs?: number;
onRefreshIntervalChange?: (next: number) => Promise<boolean> | boolean | void;
}
export function UsageDashboard({
refreshIntervalMs: savedRefreshIntervalMs,
onRefreshIntervalChange,
}: UsageDashboardProps = {}) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
@@ -73,7 +90,13 @@ export function UsageDashboard() {
undefined,
);
const [model, setModel] = useState<string | undefined>(undefined);
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
const [refreshIntervalMs, setRefreshIntervalMs] = useState(() =>
normalizeRefreshInterval(savedRefreshIntervalMs),
);
useEffect(() => {
setRefreshIntervalMs(normalizeRefreshInterval(savedRefreshIntervalMs));
}, [savedRefreshIntervalMs]);
// 切应用时清掉下游筛选,避免留下一个在新范围内查无数据的"幽灵"组合;
// 切 Provider 同理清掉模型(模型选项随 Provider 级联)。
@@ -95,9 +118,23 @@ export function UsageDashboard() {
// usage 查询,实现实时刷新(仅在 Dashboard 挂载时生效,离开页面自动取消监听)
useUsageEventBridge();
const changeRefreshInterval = (next: number) => {
setRefreshIntervalMs(next);
const changeRefreshInterval = async (next: number) => {
const normalized = normalizeRefreshInterval(next);
const previous = refreshIntervalMs;
setRefreshIntervalMs(normalized);
queryClient.invalidateQueries({ queryKey: usageKeys.all });
try {
const saved = await onRefreshIntervalChange?.(normalized);
if (saved === false) {
setRefreshIntervalMs(previous);
}
} catch (error) {
console.error(
"[UsageDashboard] Failed to persist refresh interval",
error,
);
setRefreshIntervalMs(previous);
}
};
const language = i18n.resolvedLanguage || i18n.language || "en";
+1
View File
@@ -15,6 +15,7 @@ export const settingsSchema = z.object({
skipClaudeOnboarding: z.boolean().optional(),
launchOnStartup: z.boolean().optional(),
enableLocalProxy: z.boolean().optional(),
usageDashboardRefreshIntervalMs: z.number().optional(),
preserveCodexOfficialAuthOnSwitch: z.boolean().optional(),
unifyCodexSessionHistory: z.boolean().optional(),
language: z.enum(["en", "zh", "zh-TW", "ja"]).optional(),
+1
View File
@@ -361,6 +361,7 @@ export interface Settings {
proxyConfirmed?: boolean;
// User has confirmed the usage query first-run notice
usageConfirmed?: boolean;
usageDashboardRefreshIntervalMs?: number;
// User has confirmed the stream check first-run notice
streamCheckConfirmed?: boolean;
// Whether to show the failover toggle independently on the main page
+155
View File
@@ -0,0 +1,155 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
const useProviderStatsMock = vi.hoisted(() => vi.fn());
const useModelStatsMock = vi.hoisted(() => vi.fn());
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
i18n: {
resolvedLanguage: "en",
language: "en",
},
}),
}));
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
}));
vi.mock("@/hooks/useUsageEventBridge", () => ({
useUsageEventBridge: () => {},
}));
vi.mock("@/lib/query/usage", async () => {
const actual =
await vi.importActual<typeof import("@/lib/query/usage")>(
"@/lib/query/usage",
);
return {
...actual,
useProviderStats: (...args: unknown[]) => useProviderStatsMock(...args),
useModelStats: (...args: unknown[]) => useModelStatsMock(...args),
};
});
vi.mock("@/components/usage/UsageHero", () => ({
UsageHero: () => <div data-testid="usage-hero" />,
}));
vi.mock("@/components/usage/UsageTrendChart", () => ({
UsageTrendChart: () => <div data-testid="usage-trend" />,
}));
vi.mock("@/components/usage/RequestLogTable", () => ({
RequestLogTable: () => <div data-testid="request-log-table" />,
}));
vi.mock("@/components/usage/ProviderStatsTable", () => ({
ProviderStatsTable: () => <div data-testid="provider-stats-table" />,
}));
vi.mock("@/components/usage/ModelStatsTable", () => ({
ModelStatsTable: () => <div data-testid="model-stats-table" />,
}));
vi.mock("@/components/usage/PricingConfigPanel", () => ({
PricingConfigPanel: () => <div data-testid="pricing-config-panel" />,
}));
vi.mock("@/components/usage/UsageDateRangePicker", () => ({
UsageDateRangePicker: () => <button type="button">date-range</button>,
}));
vi.mock("@/components/ui/select", () => ({
Select: ({ value, onValueChange, children }: any) => (
<div data-testid={`select-${value}`}>
{children}
<button type="button" onClick={() => onValueChange?.("5000")}>
choose-5000
</button>
</div>
),
SelectTrigger: ({ children, ...props }: any) => (
<button type="button" {...props}>
{children}
</button>
),
SelectValue: () => null,
SelectContent: ({ children }: any) => <div>{children}</div>,
SelectItem: ({ children, ...props }: any) => <div {...props}>{children}</div>,
}));
const renderDashboard = (props: ComponentProps<typeof UsageDashboard> = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<UsageDashboard {...props} />
</QueryClientProvider>,
);
};
describe("UsageDashboard", () => {
beforeEach(() => {
useProviderStatsMock.mockReset();
useModelStatsMock.mockReset();
useProviderStatsMock.mockReturnValue({ data: [] });
useModelStatsMock.mockReturnValue({ data: [] });
});
it("uses the saved refresh interval when mounted", () => {
renderDashboard({ refreshIntervalMs: 5000 });
expect(screen.getByTestId("select-5000")).toBeInTheDocument();
});
it("persists refresh interval changes", async () => {
const onRefreshIntervalChange = vi.fn().mockResolvedValue(true);
renderDashboard({ onRefreshIntervalChange });
fireEvent.click(
within(screen.getByTestId("select-30000")).getByRole("button", {
name: "choose-5000",
}),
);
await waitFor(() =>
expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000),
);
expect(screen.getByTestId("select-5000")).toBeInTheDocument();
});
it("rolls back optimistic interval changes when persistence fails", async () => {
const onRefreshIntervalChange = vi.fn().mockResolvedValue(false);
renderDashboard({ onRefreshIntervalChange });
fireEvent.click(
within(screen.getByTestId("select-30000")).getByRole("button", {
name: "choose-5000",
}),
);
await waitFor(() =>
expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000),
);
await waitFor(() =>
expect(screen.getByTestId("select-30000")).toBeInTheDocument(),
);
});
});