From a80557474731df0ba09b78bd5ec485ee14e7ffcd Mon Sep 17 00:00:00 2001 From: Mi Tom <6468993+MDX-Tom@users.noreply.github.com> Date: Wed, 6 May 2026 18:18:13 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E7=BA=BF=E7=A8=8B=E5=88=B7=E6=96=B0=E5=90=8E?= =?UTF-8?q?=20UI=20=E6=9C=AA=E5=90=8C=E6=AD=A5=E6=9B=B4=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/src/hooks/useAccounts.ts | 81 +++++++- apps/src/lib/api/account-client.ts | 4 + .../tests/accounts-usage-auto-refresh.spec.ts | 179 ++++++++++++++++++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 apps/tests/accounts-usage-auto-refresh.spec.ts diff --git a/apps/src/hooks/useAccounts.ts b/apps/src/hooks/useAccounts.ts index c0ce0fff4..a27388ca5 100644 --- a/apps/src/hooks/useAccounts.ts +++ b/apps/src/hooks/useAccounts.ts @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { accountClient } from "@/lib/api/account-client"; @@ -112,6 +112,17 @@ function getAccountsAutoRefreshIntervalMs( return Math.max(1, intervalSecs) * 1000; } +function getUsageRefreshSignalIntervalMs( + enabled: boolean, + intervalSecs: number, +): number | false { + const intervalMs = getAccountsAutoRefreshIntervalMs(enabled, intervalSecs); + if (!intervalMs) { + return false; + } + return Math.min(5_000, intervalMs); +} + /** * 函数 `useAccounts` * @@ -141,6 +152,11 @@ export function useAccounts() { areAccountQueriesEnabled && backgroundTasks.usagePollingEnabled, backgroundTasks.usagePollIntervalSecs, ); + const usageRefreshSignalIntervalMs = getUsageRefreshSignalIntervalMs( + areAccountQueriesEnabled && backgroundTasks.usagePollingEnabled, + backgroundTasks.usagePollIntervalSecs, + ); + const latestUsageCapturedAtRef = useRef(null); const startupSnapshot = queryClient.getQueryData( buildStartupSnapshotQueryKey( serviceStatus.addr, @@ -203,6 +219,69 @@ export function useAccounts() { previousData || (startupUsages.length > 0 ? startupUsages : undefined), }); + const latestUsageSignalQuery = useQuery({ + queryKey: ["usage", "latest-refresh-signal"], + queryFn: () => accountClient.getLatestUsage(), + enabled: areAccountQueriesEnabled && backgroundTasks.usagePollingEnabled, + retry: 1, + refetchInterval: usageRefreshSignalIntervalMs, + refetchIntervalInBackground: false, + }); + + const maxKnownUsageCapturedAt = useMemo(() => { + return (usagesQuery.data || []).reduce((latest, usage) => { + const capturedAt = usage.capturedAt; + if (capturedAt == null) { + return latest; + } + return latest == null ? capturedAt : Math.max(latest, capturedAt); + }, null); + }, [usagesQuery.data]); + + useEffect(() => { + if (!areAccountQueriesEnabled) { + latestUsageCapturedAtRef.current = null; + return; + } + + const capturedAt = latestUsageSignalQuery.data?.capturedAt ?? null; + if (capturedAt == null) { + return; + } + + const previousCapturedAt = latestUsageCapturedAtRef.current; + if ( + previousCapturedAt == null && + maxKnownUsageCapturedAt == null && + !usagesQuery.isFetched + ) { + return; + } + + latestUsageCapturedAtRef.current = capturedAt; + const baselineCapturedAt = + previousCapturedAt == null + ? (maxKnownUsageCapturedAt ?? 0) + : previousCapturedAt; + if (capturedAt <= baselineCapturedAt) { + return; + } + + void Promise.all([ + queryClient.invalidateQueries({ queryKey: ["accounts", "list"] }), + queryClient.invalidateQueries({ queryKey: ["usage", "list"] }), + queryClient.invalidateQueries({ queryKey: ["usage-aggregate"] }), + queryClient.invalidateQueries({ queryKey: ["today-summary"] }), + queryClient.invalidateQueries({ queryKey: ["startup-snapshot"] }), + ]); + }, [ + areAccountQueriesEnabled, + latestUsageSignalQuery.data?.capturedAt, + maxKnownUsageCapturedAt, + queryClient, + usagesQuery.isFetched, + ]); + const accounts = useMemo(() => { return attachUsagesToAccounts( accountsQuery.data?.items || [], diff --git a/apps/src/lib/api/account-client.ts b/apps/src/lib/api/account-client.ts index 4f734bbe8..c6ce22fb6 100644 --- a/apps/src/lib/api/account-client.ts +++ b/apps/src/lib/api/account-client.ts @@ -401,6 +401,10 @@ export const accountClient = { ); return normalizeUsageSnapshot(unwrapUsageSnapshotPayload(result)); }, + async getLatestUsage(): Promise { + const result = await invoke("service_usage_read", withAddr()); + return normalizeUsageSnapshot(unwrapUsageSnapshotPayload(result)); + }, async listUsage(): Promise { const result = await invoke("service_usage_list", withAddr()); return normalizeUsageList(result); diff --git a/apps/tests/accounts-usage-auto-refresh.spec.ts b/apps/tests/accounts-usage-auto-refresh.spec.ts new file mode 100644 index 000000000..357c69481 --- /dev/null +++ b/apps/tests/accounts-usage-auto-refresh.spec.ts @@ -0,0 +1,179 @@ +import { expect, test } from "@playwright/test"; + +const SETTINGS_SNAPSHOT = { + updateAutoCheck: true, + closeToTrayOnClose: false, + closeToTraySupported: false, + lowTransparency: false, + lightweightModeOnCloseToTray: false, + codexCliGuideDismissed: true, + webAccessPasswordConfigured: false, + locale: "zh-CN", + localeOptions: ["zh-CN", "en"], + serviceAddr: "localhost:48760", + serviceListenMode: "loopback", + serviceListenModeOptions: ["loopback", "all_interfaces"], + routeStrategy: "ordered", + routeStrategyOptions: ["ordered", "balanced"], + freeAccountMaxModel: "auto", + freeAccountMaxModelOptions: ["auto", "gpt-5"], + modelForwardRules: "", + accountMaxInflight: 1, + gatewayOriginator: "codex-cli", + gatewayOriginatorDefault: "codex-cli", + gatewayUserAgentVersion: "1.0.0", + gatewayUserAgentVersionDefault: "1.0.0", + gatewayResidencyRequirement: "", + gatewayResidencyRequirementOptions: ["", "us"], + pluginMarketMode: "builtin", + pluginMarketSourceUrl: "", + upstreamProxyUrl: "", + upstreamStreamTimeoutMs: 600000, + upstreamTotalTimeoutMs: 0, + sseKeepaliveIntervalMs: 15000, + backgroundTasks: { + usagePollingEnabled: true, + usagePollIntervalSecs: 30, + gatewayKeepaliveEnabled: true, + gatewayKeepaliveIntervalSecs: 180, + tokenRefreshPollingEnabled: true, + tokenRefreshPollIntervalSecs: 60, + usageRefreshWorkers: 4, + httpWorkerFactor: 4, + httpWorkerMin: 8, + httpStreamWorkerFactor: 1, + httpStreamWorkerMin: 2, + }, + envOverrides: {}, + envOverrideCatalog: [], + envOverrideReservedKeys: [], + envOverrideUnsupportedKeys: [], + theme: "tech", + appearancePreset: "classic", +}; + +const OLD_USAGE = { + accountId: "acct-auto-refresh", + availabilityStatus: "available", + usedPercent: 15, + windowMinutes: 300, + resetsAt: 1900000000, + secondaryUsedPercent: 25, + secondaryWindowMinutes: 10080, + secondaryResetsAt: 1900003600, + creditsJson: null, + capturedAt: 100, +}; + +const NEW_USAGE = { + ...OLD_USAGE, + usedPercent: 40, + capturedAt: 200, +}; + +test("accounts page refreshes usage after backend polling writes a new snapshot", async ({ + page, +}) => { + const startedAt = Date.now(); + let usageListCount = 0; + + await page.route("**/api/runtime", async (route) => { + await route.fulfill({ + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + mode: "web-gateway", + rpcBaseUrl: "/api/rpc", + canManageService: false, + canSelfUpdate: false, + canCloseToTray: false, + canOpenLocalDir: false, + canUseBrowserFileImport: true, + canUseBrowserDownloadExport: true, + }), + }); + }); + + await page.route("**/api/rpc", async (route) => { + const payload = route.request().postDataJSON(); + const method = typeof payload?.method === "string" ? payload.method : ""; + const id = payload?.id ?? 1; + const elapsedMs = Date.now() - startedAt; + + const ok = (result: unknown) => + route.fulfill({ + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + jsonrpc: "2.0", + id, + result, + }), + }); + + if (method === "appSettings/get") { + await ok(SETTINGS_SNAPSHOT); + return; + } + if (method === "initialize") { + await ok({ + userAgent: "codex_cli_rs/0.1.19", + codexHome: "C:/Users/Test/.codex", + platformFamily: "windows", + platformOs: "windows", + }); + return; + } + if (method === "account/list") { + await ok({ + items: [ + { + id: "acct-auto-refresh", + name: "auto-refresh@example.com", + label: "auto-refresh@example.com", + plan_type: "plus", + status: "active", + sort: 0, + }, + ], + total: 1, + page: 1, + pageSize: 20, + }); + return; + } + if (method === "account/usage/read") { + await ok({ snapshot: elapsedMs >= 1200 ? NEW_USAGE : OLD_USAGE }); + return; + } + if (method === "account/usage/list") { + usageListCount += 1; + await ok({ items: [elapsedMs >= 1200 ? NEW_USAGE : OLD_USAGE] }); + return; + } + + await route.fulfill({ + status: 500, + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + jsonrpc: "2.0", + id, + error: { + code: -32000, + message: `Unhandled RPC method in test: ${method}`, + }, + }), + }); + }); + + await page.goto("/accounts/"); + + const row = page + .locator("tbody tr") + .filter({ hasText: "auto-refresh@example.com" }) + .first(); + + await expect(row.getByText("85%", { exact: true }).first()).toBeVisible(); + await expect(row.getByText("60%", { exact: true }).first()).toBeVisible({ + timeout: 8_000, + }); + expect(usageListCount).toBeGreaterThanOrEqual(2); +}); From 4a202f45162ffe9b392caa08a8377d6f4e3a62e2 Mon Sep 17 00:00:00 2001 From: Mi Tom <6468993+MDX-Tom@users.noreply.github.com> Date: Thu, 7 May 2026 14:05:13 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E7=BA=BF=E7=A8=8B=E5=88=B7=E6=96=B0=E5=90=8E?= =?UTF-8?q?=20UI=20=E6=9C=AA=E5=90=8C=E6=AD=A5=E6=9B=B4=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/playwright.config.ts | 19 +++++++++++++++---- .../tests/accounts-usage-auto-refresh.spec.ts | 8 ++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/playwright.config.ts b/apps/playwright.config.ts index 71bcef2b8..dcbd2fc3b 100644 --- a/apps/playwright.config.ts +++ b/apps/playwright.config.ts @@ -1,23 +1,34 @@ import { defineConfig } from "@playwright/test"; const PORT = 3200; +const LOCAL_TEST_HOST = "localhost"; + +const noProxyHosts = ["localhost", "127.0.0.1", "::1"]; +const noProxy = [process.env.NO_PROXY, process.env.no_proxy, ...noProxyHosts] + .filter(Boolean) + .join(","); + +process.env.NO_PROXY = noProxy; +process.env.no_proxy = noProxy; export default defineConfig({ testDir: "./tests", timeout: 30_000, fullyParallel: false, use: { - baseURL: `http://127.0.0.1:${PORT}`, + baseURL: `http://${LOCAL_TEST_HOST}:${PORT}`, trace: "on-first-retry", video: "retain-on-failure", }, webServer: { - command: "node tests/support/static-server.mjs", - url: `http://127.0.0.1:${PORT}`, - reuseExistingServer: true, + command: "pnpm run build:desktop && node tests/support/static-server.mjs", + url: `http://${LOCAL_TEST_HOST}:${PORT}`, + reuseExistingServer: false, timeout: 120_000, env: { + NO_PROXY: noProxy, PORT: String(PORT), + no_proxy: noProxy, }, }, }); diff --git a/apps/tests/accounts-usage-auto-refresh.spec.ts b/apps/tests/accounts-usage-auto-refresh.spec.ts index 357c69481..8e635fca6 100644 --- a/apps/tests/accounts-usage-auto-refresh.spec.ts +++ b/apps/tests/accounts-usage-auto-refresh.spec.ts @@ -74,8 +74,8 @@ const NEW_USAGE = { test("accounts page refreshes usage after backend polling writes a new snapshot", async ({ page, }) => { - const startedAt = Date.now(); let usageListCount = 0; + let newSnapshotAvailable = false; await page.route("**/api/runtime", async (route) => { await route.fulfill({ @@ -97,7 +97,6 @@ test("accounts page refreshes usage after backend polling writes a new snapshot" const payload = route.request().postDataJSON(); const method = typeof payload?.method === "string" ? payload.method : ""; const id = payload?.id ?? 1; - const elapsedMs = Date.now() - startedAt; const ok = (result: unknown) => route.fulfill({ @@ -141,12 +140,12 @@ test("accounts page refreshes usage after backend polling writes a new snapshot" return; } if (method === "account/usage/read") { - await ok({ snapshot: elapsedMs >= 1200 ? NEW_USAGE : OLD_USAGE }); + await ok({ snapshot: newSnapshotAvailable ? NEW_USAGE : OLD_USAGE }); return; } if (method === "account/usage/list") { usageListCount += 1; - await ok({ items: [elapsedMs >= 1200 ? NEW_USAGE : OLD_USAGE] }); + await ok({ items: [newSnapshotAvailable ? NEW_USAGE : OLD_USAGE] }); return; } @@ -172,6 +171,7 @@ test("accounts page refreshes usage after backend polling writes a new snapshot" .first(); await expect(row.getByText("85%", { exact: true }).first()).toBeVisible(); + newSnapshotAvailable = true; await expect(row.getByText("60%", { exact: true }).first()).toBeVisible({ timeout: 8_000, }); From 7007f88ab95a34bf8ad896dccf0837de44e0ddb0 Mon Sep 17 00:00:00 2001 From: Mi Tom <6468993+MDX-Tom@users.noreply.github.com> Date: Thu, 7 May 2026 17:40:58 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E7=BA=BF=E7=A8=8B=E5=88=B7=E6=96=B0=E5=90=8E?= =?UTF-8?q?=20UI=20=E5=90=8C=E6=AD=A5=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/src/hooks/useAccounts.ts | 81 +++++++++---------- .../tests/accounts-usage-auto-refresh.spec.ts | 32 +++++++- 2 files changed, 66 insertions(+), 47 deletions(-) diff --git a/apps/src/hooks/useAccounts.ts b/apps/src/hooks/useAccounts.ts index a27388ca5..26f5fb22f 100644 --- a/apps/src/hooks/useAccounts.ts +++ b/apps/src/hooks/useAccounts.ts @@ -16,7 +16,7 @@ import { useLocalDayRange } from "@/hooks/useLocalDayRange"; import { useRuntimeCapabilities } from "@/hooks/useRuntimeCapabilities"; import { useI18n } from "@/lib/i18n/provider"; import { useAppStore } from "@/lib/store/useAppStore"; -import { AccountListResult, StartupSnapshot } from "@/types"; +import { AccountListResult, AccountUsage, StartupSnapshot } from "@/types"; type ImportByDirectoryResult = Awaited>; type ImportByFileResult = Awaited>; @@ -112,7 +112,7 @@ function getAccountsAutoRefreshIntervalMs( return Math.max(1, intervalSecs) * 1000; } -function getUsageRefreshSignalIntervalMs( +function getUsageListRefreshIntervalMs( enabled: boolean, intervalSecs: number, ): number | false { @@ -123,6 +123,28 @@ function getUsageRefreshSignalIntervalMs( return Math.min(5_000, intervalMs); } +function buildUsageListFingerprint(usages: AccountUsage[]): string { + if (usages.length === 0) { + return ""; + } + + return usages + .map((usage) => + [ + usage.accountId, + usage.capturedAt ?? "", + usage.usedPercent ?? "", + usage.secondaryUsedPercent ?? "", + usage.resetsAt ?? "", + usage.secondaryResetsAt ?? "", + usage.availabilityStatus ?? "", + usage.creditsJson ?? "", + ].join(":"), + ) + .sort() + .join("|"); +} + /** * 函数 `useAccounts` * @@ -152,11 +174,11 @@ export function useAccounts() { areAccountQueriesEnabled && backgroundTasks.usagePollingEnabled, backgroundTasks.usagePollIntervalSecs, ); - const usageRefreshSignalIntervalMs = getUsageRefreshSignalIntervalMs( + const usageListRefreshIntervalMs = getUsageListRefreshIntervalMs( areAccountQueriesEnabled && backgroundTasks.usagePollingEnabled, backgroundTasks.usagePollIntervalSecs, ); - const latestUsageCapturedAtRef = useRef(null); + const usageListFingerprintRef = useRef(null); const startupSnapshot = queryClient.getQueryData( buildStartupSnapshotQueryKey( serviceStatus.addr, @@ -213,72 +235,43 @@ export function useAccounts() { queryFn: () => accountClient.listUsage(), enabled: areAccountQueriesEnabled, retry: 1, - refetchInterval: accountsAutoRefreshIntervalMs, + refetchInterval: usageListRefreshIntervalMs, refetchIntervalInBackground: false, placeholderData: (previousData) => previousData || (startupUsages.length > 0 ? startupUsages : undefined), }); - const latestUsageSignalQuery = useQuery({ - queryKey: ["usage", "latest-refresh-signal"], - queryFn: () => accountClient.getLatestUsage(), - enabled: areAccountQueriesEnabled && backgroundTasks.usagePollingEnabled, - retry: 1, - refetchInterval: usageRefreshSignalIntervalMs, - refetchIntervalInBackground: false, - }); - - const maxKnownUsageCapturedAt = useMemo(() => { - return (usagesQuery.data || []).reduce((latest, usage) => { - const capturedAt = usage.capturedAt; - if (capturedAt == null) { - return latest; - } - return latest == null ? capturedAt : Math.max(latest, capturedAt); - }, null); - }, [usagesQuery.data]); + const usageListFingerprint = useMemo( + () => buildUsageListFingerprint(usagesQuery.data || []), + [usagesQuery.data], + ); useEffect(() => { if (!areAccountQueriesEnabled) { - latestUsageCapturedAtRef.current = null; - return; - } - - const capturedAt = latestUsageSignalQuery.data?.capturedAt ?? null; - if (capturedAt == null) { + usageListFingerprintRef.current = null; return; } - const previousCapturedAt = latestUsageCapturedAtRef.current; - if ( - previousCapturedAt == null && - maxKnownUsageCapturedAt == null && - !usagesQuery.isFetched - ) { + if (!usagesQuery.isFetched) { return; } - latestUsageCapturedAtRef.current = capturedAt; - const baselineCapturedAt = - previousCapturedAt == null - ? (maxKnownUsageCapturedAt ?? 0) - : previousCapturedAt; - if (capturedAt <= baselineCapturedAt) { + const previousFingerprint = usageListFingerprintRef.current; + usageListFingerprintRef.current = usageListFingerprint; + if (previousFingerprint == null || previousFingerprint === usageListFingerprint) { return; } void Promise.all([ queryClient.invalidateQueries({ queryKey: ["accounts", "list"] }), - queryClient.invalidateQueries({ queryKey: ["usage", "list"] }), queryClient.invalidateQueries({ queryKey: ["usage-aggregate"] }), queryClient.invalidateQueries({ queryKey: ["today-summary"] }), queryClient.invalidateQueries({ queryKey: ["startup-snapshot"] }), ]); }, [ areAccountQueriesEnabled, - latestUsageSignalQuery.data?.capturedAt, - maxKnownUsageCapturedAt, queryClient, + usageListFingerprint, usagesQuery.isFetched, ]); diff --git a/apps/tests/accounts-usage-auto-refresh.spec.ts b/apps/tests/accounts-usage-auto-refresh.spec.ts index 8e635fca6..ab7b0252e 100644 --- a/apps/tests/accounts-usage-auto-refresh.spec.ts +++ b/apps/tests/accounts-usage-auto-refresh.spec.ts @@ -71,6 +71,19 @@ const NEW_USAGE = { capturedAt: 200, }; +const UNCHANGED_NEWER_USAGE = { + accountId: "acct-newer-snapshot", + availabilityStatus: "available", + usedPercent: 20, + windowMinutes: 300, + resetsAt: 1900007200, + secondaryUsedPercent: 30, + secondaryWindowMinutes: 10080, + secondaryResetsAt: 1900010800, + creditsJson: null, + capturedAt: 1_000, +}; + test("accounts page refreshes usage after backend polling writes a new snapshot", async ({ page, }) => { @@ -132,20 +145,33 @@ test("accounts page refreshes usage after backend polling writes a new snapshot" status: "active", sort: 0, }, + { + id: "acct-newer-snapshot", + name: "newer-snapshot@example.com", + label: "newer-snapshot@example.com", + plan_type: "plus", + status: "active", + sort: 1, + }, ], - total: 1, + total: 2, page: 1, pageSize: 20, }); return; } if (method === "account/usage/read") { - await ok({ snapshot: newSnapshotAvailable ? NEW_USAGE : OLD_USAGE }); + await ok({ snapshot: UNCHANGED_NEWER_USAGE }); return; } if (method === "account/usage/list") { usageListCount += 1; - await ok({ items: [newSnapshotAvailable ? NEW_USAGE : OLD_USAGE] }); + await ok({ + items: [ + newSnapshotAvailable ? NEW_USAGE : OLD_USAGE, + UNCHANGED_NEWER_USAGE, + ], + }); return; } From a0be8e8361fe442a09440373f518200b0a66192a Mon Sep 17 00:00:00 2001 From: Mi Tom <6468993+MDX-Tom@users.noreply.github.com> Date: Fri, 8 May 2026 11:19:35 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=94=A8=E9=87=8F=E7=BA=BF=E7=A8=8B=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=90=8E=20UI=20=E5=90=8C=E6=AD=A5=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将刷新策略从前端定时探测调整为事件驱动:用量轮询批次一结束,Service 立即通知 Tauri,Tauri 再通知前端账号页刷新,同时保留用量列表短间隔刷新作为兜底 --- apps/src-tauri/src/lib.rs | 27 +++++++++- apps/src/hooks/useAccounts.ts | 34 ++++++++++++ apps/src/lib/api/usage-refresh-events.ts | 53 +++++++++++++++++++ .../tests/accounts-usage-auto-refresh.spec.ts | 9 +++- crates/service/src/lib.rs | 1 + crates/service/src/usage/refresh/batch.rs | 14 +++-- crates/service/src/usage/refresh/mod.rs | 41 +++++++++++++- .../src/usage/tests/usage_refresh_tests.rs | 23 +++++++- crates/service/src/usage/usage_refresh.rs | 1 + 9 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 apps/src/lib/api/usage-refresh-events.ts diff --git a/apps/src-tauri/src/lib.rs b/apps/src-tauri/src/lib.rs index c8d496ff8..87c0f861b 100644 --- a/apps/src-tauri/src/lib.rs +++ b/apps/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ -use tauri::Manager; +use serde::Serialize; +use tauri::{Emitter, Manager}; mod app_shell; mod app_storage; @@ -12,6 +13,16 @@ use app_shell::{ CLOSE_TO_TRAY_ON_CLOSE, TRAY_AVAILABLE, }; +const USAGE_REFRESH_COMPLETED_EVENT: &str = "usage-refresh-completed"; + +#[derive(Clone, Serialize)] +struct UsageRefreshCompletedPayload { + source: &'static str, + processed: usize, + total: usize, + completed_at: i64, +} + /// 函数 `run` /// /// 作者: gaohongshun @@ -49,6 +60,20 @@ pub fn run() { if let Ok(log_dir) = app.path().app_log_dir() { log::info!("log dir: {}", log_dir.display()); } + let usage_refresh_event_app = app.handle().clone(); + codexmanager_service::set_usage_refresh_completed_handler(move |event| { + let payload = UsageRefreshCompletedPayload { + source: event.source, + processed: event.processed, + total: event.total, + completed_at: event.completed_at, + }; + if let Err(err) = usage_refresh_event_app + .emit(USAGE_REFRESH_COMPLETED_EVENT, payload) + { + log::warn!("emit usage refresh completed event failed: {}", err); + } + }); if let Err(err) = setup_tray(app.handle()) { TRAY_AVAILABLE.store(false, std::sync::atomic::Ordering::Relaxed); CLOSE_TO_TRAY_ON_CLOSE.store(false, std::sync::atomic::Ordering::Relaxed); diff --git a/apps/src/hooks/useAccounts.ts b/apps/src/hooks/useAccounts.ts index 26f5fb22f..1b92ca411 100644 --- a/apps/src/hooks/useAccounts.ts +++ b/apps/src/hooks/useAccounts.ts @@ -10,6 +10,7 @@ import { STARTUP_SNAPSHOT_REQUEST_LOG_LIMIT, } from "@/lib/api/startup-snapshot"; import { getAppErrorMessage } from "@/lib/api/transport"; +import { listenUsageRefreshCompleted } from "@/lib/api/usage-refresh-events"; import { useDesktopPageActive } from "@/hooks/useDesktopPageActive"; import { useDeferredDesktopActivation } from "@/hooks/useDeferredDesktopActivation"; import { useLocalDayRange } from "@/hooks/useLocalDayRange"; @@ -246,6 +247,39 @@ export function useAccounts() { [usagesQuery.data], ); + useEffect(() => { + if (!areAccountQueriesEnabled) { + return; + } + + let disposed = false; + let unlisten: (() => void) | null = null; + const refreshVisibleUsageData = () => { + void Promise.all([ + queryClient.refetchQueries({ queryKey: ["usage", "list"], type: "active" }), + queryClient.refetchQueries({ queryKey: ["accounts", "list"], type: "active" }), + queryClient.invalidateQueries({ queryKey: ["usage-aggregate"] }), + queryClient.invalidateQueries({ queryKey: ["today-summary"] }), + queryClient.invalidateQueries({ queryKey: ["startup-snapshot"] }), + ]); + }; + + void listenUsageRefreshCompleted(() => { + refreshVisibleUsageData(); + }).then((cleanup) => { + if (disposed) { + cleanup(); + return; + } + unlisten = cleanup; + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [areAccountQueriesEnabled, queryClient]); + useEffect(() => { if (!areAccountQueriesEnabled) { usageListFingerprintRef.current = null; diff --git a/apps/src/lib/api/usage-refresh-events.ts b/apps/src/lib/api/usage-refresh-events.ts new file mode 100644 index 000000000..d736e2f4f --- /dev/null +++ b/apps/src/lib/api/usage-refresh-events.ts @@ -0,0 +1,53 @@ +import { isTauriRuntime } from "./transport"; + +export const USAGE_REFRESH_COMPLETED_EVENT = "usage-refresh-completed"; + +export interface UsageRefreshCompletedPayload { + source?: string; + processed?: number; + total?: number; + completedAt?: number; + completed_at?: number; +} + +export type UsageRefreshCompletedHandler = ( + payload: UsageRefreshCompletedPayload +) => void; + +type Unlisten = () => void; + +function readUsageRefreshEventPayload(event: Event): UsageRefreshCompletedPayload { + if (event instanceof CustomEvent && typeof event.detail === "object" && event.detail) { + return event.detail as UsageRefreshCompletedPayload; + } + return {}; +} + +export async function listenUsageRefreshCompleted( + handler: UsageRefreshCompletedHandler +): Promise { + if (typeof window === "undefined") { + return () => {}; + } + + const handleWindowEvent = (event: Event) => { + handler(readUsageRefreshEventPayload(event)); + }; + window.addEventListener(USAGE_REFRESH_COMPLETED_EVENT, handleWindowEvent); + + let unlistenTauri: Unlisten | null = null; + if (isTauriRuntime()) { + const { listen } = await import("@tauri-apps/api/event"); + unlistenTauri = await listen( + USAGE_REFRESH_COMPLETED_EVENT, + (event) => { + handler(event.payload || {}); + }, + ); + } + + return () => { + window.removeEventListener(USAGE_REFRESH_COMPLETED_EVENT, handleWindowEvent); + unlistenTauri?.(); + }; +} diff --git a/apps/tests/accounts-usage-auto-refresh.spec.ts b/apps/tests/accounts-usage-auto-refresh.spec.ts index ab7b0252e..505aabc74 100644 --- a/apps/tests/accounts-usage-auto-refresh.spec.ts +++ b/apps/tests/accounts-usage-auto-refresh.spec.ts @@ -198,8 +198,15 @@ test("accounts page refreshes usage after backend polling writes a new snapshot" await expect(row.getByText("85%", { exact: true }).first()).toBeVisible(); newSnapshotAvailable = true; + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("usage-refresh-completed", { + detail: { source: "polling", processed: 1, total: 2 }, + }) + ); + }); await expect(row.getByText("60%", { exact: true }).first()).toBeVisible({ - timeout: 8_000, + timeout: 2_000, }); expect(usageListCount).toBeGreaterThanOrEqual(2); }); diff --git a/crates/service/src/lib.rs b/crates/service/src/lib.rs index c96425fff..20b7a8cb6 100644 --- a/crates/service/src/lib.rs +++ b/crates/service/src/lib.rs @@ -115,6 +115,7 @@ pub use auth::{rpc_auth_token, rpc_auth_token_matches}; pub use lifecycle::bootstrap::{initialize_storage_if_needed, portable}; pub use lifecycle::shutdown::{clear_shutdown_flag, request_shutdown, shutdown_requested}; pub use lifecycle::startup::{start_one_shot_server, start_server, ServerHandle}; +pub use usage_refresh::{set_usage_refresh_completed_handler, UsageRefreshCompletedEvent}; /// 函数 `test_env_guard` /// diff --git a/crates/service/src/usage/refresh/batch.rs b/crates/service/src/usage/refresh/batch.rs index 8e4007e32..58407965b 100644 --- a/crates/service/src/usage/refresh/batch.rs +++ b/crates/service/src/usage/refresh/batch.rs @@ -7,10 +7,11 @@ use std::thread; use std::time::{Duration, Instant}; use super::{ - build_workspace_map_from_accounts, open_storage, record_usage_refresh_failure, - record_usage_refresh_metrics, refresh_usage_for_token, DEFAULT_USAGE_POLL_BATCH_LIMIT, - DEFAULT_USAGE_POLL_CYCLE_BUDGET_SECS, ENV_USAGE_POLL_BATCH_LIMIT, - ENV_USAGE_POLL_CYCLE_BUDGET_SECS, USAGE_POLL_CURSOR, USAGE_REFRESH_WORKERS, + build_workspace_map_from_accounts, notify_usage_refresh_completed, open_storage, + record_usage_refresh_failure, record_usage_refresh_metrics, refresh_usage_for_token, + DEFAULT_USAGE_POLL_BATCH_LIMIT, DEFAULT_USAGE_POLL_CYCLE_BUDGET_SECS, + ENV_USAGE_POLL_BATCH_LIMIT, ENV_USAGE_POLL_CYCLE_BUDGET_SECS, USAGE_POLL_CURSOR, + USAGE_REFRESH_WORKERS, }; /// 函数 `refresh_usage_for_all_accounts` @@ -35,7 +36,9 @@ pub(crate) fn refresh_usage_for_all_accounts() -> Result<(), String> { if tasks.is_empty() { return Ok(()); } - run_usage_refresh_tasks(tasks)?; + let total = tasks.len(); + let processed = run_usage_refresh_tasks(tasks)?; + notify_usage_refresh_completed("manual_all", processed, total); Ok(()) } @@ -100,6 +103,7 @@ pub(crate) fn refresh_usage_for_polling_batch() -> Result<(), String> { cycle_budget.map(|budget| budget.as_secs()).unwrap_or(0) ); } + notify_usage_refresh_completed("polling", processed, total); Ok(()) } diff --git a/crates/service/src/usage/refresh/mod.rs b/crates/service/src/usage/refresh/mod.rs index bc2edb23c..ee2317abf 100644 --- a/crates/service/src/usage/refresh/mod.rs +++ b/crates/service/src/usage/refresh/mod.rs @@ -4,7 +4,7 @@ use codexmanager_core::usage::parse_usage_snapshot; use crossbeam_channel::unbounded; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize}; -use std::sync::OnceLock; +use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -129,6 +129,19 @@ struct UsageRefreshResult { _status: UsageAvailabilityStatus, } +#[derive(Debug, Clone)] +pub struct UsageRefreshCompletedEvent { + pub source: &'static str, + pub processed: usize, + pub total: usize, + pub completed_at: i64, +} + +type UsageRefreshCompletedHandler = Arc; + +static USAGE_REFRESH_COMPLETED_HANDLER: OnceLock>> = + OnceLock::new(); + pub(crate) use self::batch::refresh_usage_for_all_accounts; use self::batch::refresh_usage_for_polling_batch; #[cfg(test)] @@ -146,6 +159,31 @@ pub(crate) use self::settings::{ set_background_tasks_settings, BackgroundTasksSettingsPatch, }; +pub fn set_usage_refresh_completed_handler(handler: F) +where + F: Fn(UsageRefreshCompletedEvent) + Send + Sync + 'static, +{ + let slot = USAGE_REFRESH_COMPLETED_HANDLER.get_or_init(|| Mutex::new(None)); + let mut guard = crate::lock_utils::lock_recover(slot, "usage_refresh_completed_handler"); + *guard = Some(Arc::new(handler)); +} + +pub(crate) fn notify_usage_refresh_completed(source: &'static str, processed: usize, total: usize) { + let handler = USAGE_REFRESH_COMPLETED_HANDLER.get().and_then(|slot| { + let guard = crate::lock_utils::lock_recover(slot, "usage_refresh_completed_handler"); + guard.clone() + }); + + if let Some(handler) = handler { + handler(UsageRefreshCompletedEvent { + source, + processed, + total, + completed_at: now_ts(), + }); + } +} + /// 函数 `ensure_usage_polling` /// /// 作者: gaohongshun @@ -388,6 +426,7 @@ pub(crate) fn refresh_usage_for_account(account_id: &str) -> Result<(), String> } } record_usage_refresh_metrics(true, started_at); + notify_usage_refresh_completed("single", 1, 1); Ok(()) } diff --git a/crates/service/src/usage/tests/usage_refresh_tests.rs b/crates/service/src/usage/tests/usage_refresh_tests.rs index 6356dfcb7..fa9cbe1cb 100644 --- a/crates/service/src/usage/tests/usage_refresh_tests.rs +++ b/crates/service/src/usage/tests/usage_refresh_tests.rs @@ -1,7 +1,8 @@ use super::{ clear_pending_usage_refresh_tasks_for_tests, enqueue_usage_refresh_with_worker, - next_usage_poll_cursor, reset_usage_poll_cursor_for_tests, resolve_token_refresh_issuer, - run_token_refresh_task, should_retry_usage_refresh_with_token, token_refresh_access_exp_cutoff, + next_usage_poll_cursor, notify_usage_refresh_completed, reset_usage_poll_cursor_for_tests, + resolve_token_refresh_issuer, run_token_refresh_task, set_usage_refresh_completed_handler, + should_retry_usage_refresh_with_token, token_refresh_access_exp_cutoff, token_refresh_due_cutoff, token_refresh_schedule, usage_poll_batch_indices, }; use codexmanager_core::storage::{now_ts, Account, Storage, Token}; @@ -9,6 +10,24 @@ use std::collections::HashSet; use std::sync::mpsc; use std::time::Duration; +#[test] +fn usage_refresh_completed_handler_receives_notification() { + let _guard = crate::test_env_guard(); + let (tx, rx) = mpsc::channel(); + set_usage_refresh_completed_handler(move |event| { + let _ = tx.send(event); + }); + + notify_usage_refresh_completed("test-notify", 2, 3); + let event = rx + .recv_timeout(Duration::from_secs(1)) + .expect("usage refresh completed event"); + assert_eq!(event.source, "test-notify"); + assert_eq!(event.processed, 2); + assert_eq!(event.total, 3); + assert!(event.completed_at > 0); +} + /// 函数 `enqueue_usage_refresh_for_same_account_is_deduplicated_until_finish` /// /// 作者: gaohongshun diff --git a/crates/service/src/usage/usage_refresh.rs b/crates/service/src/usage/usage_refresh.rs index c06b12491..164131b2f 100644 --- a/crates/service/src/usage/usage_refresh.rs +++ b/crates/service/src/usage/usage_refresh.rs @@ -7,3 +7,4 @@ pub(crate) use refresh::{ refresh_usage_for_all_accounts, reload_background_tasks_runtime_from_env, set_background_tasks_settings, BackgroundTasksSettingsPatch, }; +pub use refresh::{set_usage_refresh_completed_handler, UsageRefreshCompletedEvent};