diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json
index 8070467..68e90fa 100644
--- a/web/src/i18n/en.json
+++ b/web/src/i18n/en.json
@@ -200,7 +200,7 @@
"active_users": "{{count}} active users",
"active_users_label": "Active users: {{count}}",
"image_suffix": "PIC",
- "image_response_time": "Image response: {{time}}",
+ "image_response_time": "Image: {{time}}",
"time_range": "Time Range:",
"range_today": "Today",
"range_7d": "7 Days",
@@ -697,6 +697,7 @@
"cache_ratio": "Cache Ratio",
"cache_cumulative_ratio": "Cumulative Cache Ratio",
"auto_update": "Auto update",
+ "auto_update_off": "Auto update off",
"granularity_hour": "Hourly",
"granularity_day": "Daily"
},
diff --git a/web/src/i18n/zh.json b/web/src/i18n/zh.json
index 9a12dec..5b80906 100644
--- a/web/src/i18n/zh.json
+++ b/web/src/i18n/zh.json
@@ -200,7 +200,7 @@
"active_users": "{{count}} 活跃用户",
"active_users_label": "活跃用户: {{count}}",
"image_suffix": "PIC",
- "image_response_time": "图片响应时间: {{time}}",
+ "image_response_time": "图片: {{time}}",
"time_range": "时间范围:",
"range_today": "今天",
"range_7d": "近 7 天",
@@ -700,6 +700,7 @@
"cache_ratio": "缓存比例",
"cache_cumulative_ratio": "缓存累计比例",
"auto_update": "自动更新",
+ "auto_update_off": "关闭自动更新",
"granularity_hour": "按小时",
"granularity_day": "按天"
},
diff --git a/web/src/pages/admin/AccountsPageContent.tsx b/web/src/pages/admin/AccountsPageContent.tsx
index d5f8009..3624643 100644
--- a/web/src/pages/admin/AccountsPageContent.tsx
+++ b/web/src/pages/admin/AccountsPageContent.tsx
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { keepPreviousData, useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
-import { AlertDialog, Button, Dropdown, EmptyState, Input, Label, ListBox, Select, Spinner, TextField as HeroTextField } from '@heroui/react';
+import { AlertDialog, Button, EmptyState, Input, Label, ListBox, Select, Spinner, TextField as HeroTextField } from '@heroui/react';
import {
Plus,
- RefreshCw,
- ChevronDown,
Search,
Download,
Upload,
@@ -24,11 +22,13 @@ import {
import { useCrudMutation } from '../../shared/hooks/useCrudMutation';
import { useDebouncedValue } from '../../shared/hooks/useDebouncedValue';
import { usePagination } from '../../shared/hooks/usePagination';
+import { ADMIN_AUTO_REFRESH_OPTIONS, usePersistentAutoRefresh } from '../../shared/hooks/usePersistentAutoRefresh';
import { queryKeys } from '../../shared/queryKeys';
import { PAGE_SIZE_OPTIONS, FETCH_ALL_PARAMS } from '../../shared/constants';
import { getTotalPages } from '../../shared/utils/pagination';
import { TablePaginationFooter } from '../../shared/components/TablePaginationFooter';
import { DialogTriggerShim } from '../../shared/components/DialogTriggerShim';
+import { AutoRefreshControl } from '../../shared/components/AutoRefreshControl';
import { CreateAccountModal } from './accounts/CreateAccountModal';
import { EditAccountModal } from './accounts/EditAccountModal';
import { AccountTypeFilterSelect } from './accounts/AccountTypeFilterSelect';
@@ -52,7 +52,6 @@ import {
AccountSelectionStore,
AccountTableRow,
AccountsTableLoadingRow,
- AutoRefreshCountdownLabel,
TableSelectionCheckbox,
UNGROUPED_GROUP_FILTER,
columnAlignClass,
@@ -66,6 +65,8 @@ import {
type AccountUsageWindowCache,
} from './accounts/AccountPageSupport';
+const ACCOUNT_AUTO_REFRESH_STORAGE_KEY = 'airgate.admin.accounts.auto_refresh';
+
export default function AccountsPageContent() {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -131,10 +132,9 @@ export default function AccountsPageContent() {
const [proxyFilter, setProxyFilter] = useState('');
// 自动刷新
- const AUTO_REFRESH_OPTIONS = [0, 5, 10, 15, 30];
- const [autoRefresh, setAutoRefresh] = useState(0); // 秒,0=关闭
+ const [autoRefresh, setAutoRefresh] = usePersistentAutoRefresh(ACCOUNT_AUTO_REFRESH_STORAGE_KEY, 0, ADMIN_AUTO_REFRESH_OPTIONS); // 秒,0=关闭
const refreshAccounts = useCallback(() => {
- queryClient.invalidateQueries({ queryKey: queryKeys.accounts() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.accounts() }, { cancelRefetch: false });
}, [queryClient]);
const autoRefreshLabel = t('accounts.auto_refresh');
const autoRefreshOffLabel = t('accounts.auto_refresh_off');
@@ -175,7 +175,7 @@ export default function AccountsPageContent() {
}, [groupFilter, keyword, page, pageSize, platformFilter, proxyFilter, selectionStore, stateFilter, typeFilter]);
// 查询账号列表
- const { data, isLoading } = useQuery({
+ const { data, isLoading, isFetching: isAccountsFetching } = useQuery({
queryKey: queryKeys.accounts(page, pageSize, debouncedKeyword, platformFilter, stateFilter, typeFilter, groupFilter, proxyFilter),
queryFn: () =>
accountsApi.list({
@@ -189,6 +189,7 @@ export default function AccountsPageContent() {
ungrouped: groupFilter === UNGROUPED_GROUP_FILTER ? true : undefined,
proxy_id: proxyFilter ? Number(proxyFilter) : undefined,
}),
+ meta: { globalLoading: false },
placeholderData: keepPreviousData,
});
const rows = data?.list ?? [];
@@ -750,49 +751,17 @@ export default function AccountsPageContent() {
-
-
-
-
-
-
-
- {
- const action = String(key);
- setAutoRefresh(Number(action.replace('auto_', '')));
- }}
- >
- {AUTO_REFRESH_OPTIONS.map((sec) => (
-
-
- {sec === 0 ? t('accounts.auto_refresh_off') : `${t('accounts.auto_refresh')}${sec}s`}
- {autoRefresh === sec ? ✓ : null}
-
-
- ))}
-
-
-
+
-
- {t('usage.auto_update')}}
- onChange={handleAutoRefreshChange}
+ refreshAriaLabel={t('common.refresh', 'Refresh')}
+ onChange={setAutoRefresh}
+ onAutoRefresh={handleAutoRefresh}
+ onRefresh={handleManualRefresh}
+ isAutoRefreshing={isUsageTableRefreshing}
+ isRefreshing={isRefreshing}
+ isDisabled={!pageActive}
/>
@@ -940,7 +934,7 @@ export default function UsagePage() {
dataVersion={pageActive ? dataUpdatedAt : undefined}
emptyDescription={t('usage.empty_description', '调整筛选条件后重试')}
emptyTitle={t('common.no_data')}
- highlightNewRows={pageActive && autoRefresh && page === 1}
+ highlightNewRows={pageActive && autoRefreshEnabled && page === 1}
highlightResetKey={JSON.stringify({ ...filters, page, pageSize })}
isLoading={!pageActive || isLoading}
page={page}
diff --git a/web/src/pages/admin/accounts/AccountPageSupport.tsx b/web/src/pages/admin/accounts/AccountPageSupport.tsx
index a2a9d0d..0207056 100644
--- a/web/src/pages/admin/accounts/AccountPageSupport.tsx
+++ b/web/src/pages/admin/accounts/AccountPageSupport.tsx
@@ -602,56 +602,6 @@ export function AccountsTableLoadingRow({ colSpan, minHeight = 220 }: { colSpan:
);
}
-export const AutoRefreshCountdownLabel = memo(function AutoRefreshCountdownLabel({
- autoRefresh,
- label,
- offLabel,
- onRefresh,
-}: {
- autoRefresh: number;
- label: string;
- offLabel: string;
- onRefresh: () => void;
-}) {
- const labelRef = useRef(null);
-
- useEffect(() => {
- const renderCountdown = (seconds: number) => {
- if (labelRef.current) {
- labelRef.current.textContent = autoRefresh ? `${label}${seconds}s` : offLabel;
- }
- };
-
- if (!autoRefresh) {
- renderCountdown(0);
- return undefined;
- }
-
- let remaining = autoRefresh;
- renderCountdown(remaining);
- const timer = window.setInterval(() => {
- remaining -= 1;
- if (remaining <= 0) {
- onRefresh();
- remaining = autoRefresh;
- }
- renderCountdown(remaining);
- }, 1000);
- return () => window.clearInterval(timer);
- }, [autoRefresh, label, offLabel, onRefresh]);
-
- return (
-
- {autoRefresh ? `${label}${autoRefresh}s` : offLabel}
-
- );
-}, (prev, next) => (
- prev.autoRefresh === next.autoRefresh
- && prev.label === next.label
- && prev.offLabel === next.offLabel
- && prev.onRefresh === next.onRefresh
-));
-
// formatCountdown 把剩余毫秒格式化成 "Xd Yh"/"Xh Ym"/"Ym" 样式,
// 与 sub2api 的"限流中 10h 16m 自动恢复"徽标一致。
function formatCountdown(ms: number): string {
diff --git a/web/src/pages/user/UserUsageContent.tsx b/web/src/pages/user/UserUsageContent.tsx
index d509fe9..a8177e7 100644
--- a/web/src/pages/user/UserUsageContent.tsx
+++ b/web/src/pages/user/UserUsageContent.tsx
@@ -6,11 +6,10 @@ import { usageApi } from '../../shared/api/usage';
import { apikeysApi } from '../../shared/api/apikeys';
import { queryKeys } from '../../shared/queryKeys';
import { usePagination } from '../../shared/hooks/usePagination';
-import { usePersistentBoolean } from '../../shared/hooks/usePersistentBoolean';
import { usePlatforms } from '../../shared/hooks/usePlatforms';
import { useAuth } from '../../app/providers/AuthProvider';
import { useToast } from '../../shared/ui';
-import { Activity, Hash, DollarSign, Coins, Clock, Gauge, Percent, Upload, RefreshCw } from 'lucide-react';
+import { Activity, Hash, DollarSign, Coins, Clock, Gauge, Percent, Upload } from 'lucide-react';
import type { UsageQuery } from '../../shared/types';
import { useUsageColumns, fmtNum, type UsageColumnConfig, type UsageRow } from '../../shared/columns/usageColumns';
import { getSessionAPIKey } from '../../shared/api/client';
@@ -19,10 +18,10 @@ import { UsageRecordsTable } from '../../shared/components/UsageRecordsTable';
import { UsageDateRangeFilter } from '../../shared/components/UsageDateRangeFilter';
import { UsageModelFilterInput } from '../../shared/components/UsageModelFilterInput';
import { CostValue } from '../../shared/components/CostValue';
-import { NativeSwitch } from '../../shared/components/NativeSwitch';
+import { AutoRefreshControl } from '../../shared/components/AutoRefreshControl';
import { FETCH_ALL_PARAMS } from '../../shared/constants';
+import { USER_AUTO_REFRESH_OPTIONS, usePersistentAutoRefresh } from '../../shared/hooks/usePersistentAutoRefresh';
-const USAGE_AUTO_UPDATE_INTERVAL_MS = 3_000;
const USER_USAGE_AUTO_UPDATE_STORAGE_KEY = 'airgate.user.usage.auto_update';
function StatCard({
@@ -188,8 +187,10 @@ export default function UserUsageContent() {
const customerScope = !!user?.api_key_id;
const { page, setPage, pageSize, setPageSize } = usePagination(20, 'user.usage');
const [filters, setFilters] = useState>({});
- const [autoRefresh, setAutoRefresh] = usePersistentBoolean(USER_USAGE_AUTO_UPDATE_STORAGE_KEY, false);
- const autoRefreshInterval = autoRefresh ? USAGE_AUTO_UPDATE_INTERVAL_MS : false;
+ const [autoRefresh, setAutoRefresh] = usePersistentAutoRefresh(USER_USAGE_AUTO_UPDATE_STORAGE_KEY, 0, USER_AUTO_REFRESH_OPTIONS);
+ const autoRefreshEnabled = autoRefresh > 0;
+ const autoRefreshLabel = `${t('usage.auto_update')} `;
+ const autoRefreshOffLabel = t('usage.auto_update_off', '关闭自动更新');
const handleModelChange = useCallback((model: string) => {
const nextModel = model || undefined;
@@ -231,10 +232,9 @@ export default function UserUsageContent() {
} = useQuery({
queryKey: queryKeys.userUsage(queryParams),
queryFn: ({ signal }) => usageApi.list(queryParams, { signal }),
- refetchInterval: autoRefreshInterval,
- refetchIntervalInBackground: false,
- refetchOnReconnect: autoRefresh,
- refetchOnWindowFocus: autoRefresh,
+ meta: { globalLoading: false },
+ refetchOnReconnect: autoRefreshEnabled,
+ refetchOnWindowFocus: autoRefreshEnabled,
placeholderData: keepPreviousData,
});
@@ -242,25 +242,22 @@ export default function UserUsageContent() {
const { data: stats, isFetching: isStatsFetching, refetch: refetchStats } = useQuery({
queryKey: queryKeys.userUsageStats(filters),
queryFn: ({ signal }) => usageApi.userStats(filters, { signal }),
- refetchInterval: autoRefreshInterval,
- refetchIntervalInBackground: false,
- refetchOnReconnect: autoRefresh,
- refetchOnWindowFocus: autoRefresh,
+ meta: { globalLoading: false },
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
});
const isRefreshing = isUsageFetching || isStatsFetching;
+ const isUsageTableRefreshing = isUsageFetching;
- function handleManualRefresh() {
- void refetchUsage();
- void refetchStats();
- }
+ const handleManualRefresh = useCallback(() => {
+ void refetchUsage({ cancelRefetch: false });
+ void refetchStats({ cancelRefetch: false });
+ }, [refetchStats, refetchUsage]);
- function handleAutoRefreshChange(enabled: boolean) {
- setAutoRefresh(enabled);
- if (enabled) {
- handleManualRefresh();
- }
- }
+ const handleAutoRefresh = useCallback(() => {
+ void refetchUsage({ cancelRefetch: false });
+ }, [refetchUsage]);
function updateFilter(key: string, value: string) {
const nextValue = key === 'api_key_id' && value ? Number(value) : value || undefined;
@@ -439,22 +436,18 @@ export default function UserUsageContent() {
onModelChange={handleModelChange}
/>
-
- {t('usage.auto_update')}}
- onChange={handleAutoRefreshChange}
+ refreshAriaLabel={t('common.refresh', 'Refresh')}
+ onChange={setAutoRefresh}
+ onAutoRefresh={handleAutoRefresh}
+ onRefresh={handleManualRefresh}
+ isAutoRefreshing={isUsageTableRefreshing}
+ isRefreshing={isRefreshing}
/>
@@ -465,7 +458,7 @@ export default function UserUsageContent() {
dataVersion={dataUpdatedAt}
emptyDescription={t('usage.empty_description', '调整筛选条件后重试')}
emptyTitle={t('common.no_data')}
- highlightNewRows={autoRefresh && page === 1}
+ highlightNewRows={autoRefreshEnabled && page === 1}
highlightResetKey={JSON.stringify({ ...filters, page, pageSize })}
isLoading={isLoading}
page={page}
diff --git a/web/src/shared/components/AutoRefreshControl.tsx b/web/src/shared/components/AutoRefreshControl.tsx
new file mode 100644
index 0000000..c40a2b9
--- /dev/null
+++ b/web/src/shared/components/AutoRefreshControl.tsx
@@ -0,0 +1,199 @@
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Button, Dropdown } from '@heroui/react';
+import { Check, ChevronDown, RefreshCw } from 'lucide-react';
+import { normalizeAutoRefresh, type AutoRefreshOptions } from '../hooks/usePersistentAutoRefresh';
+
+interface AutoRefreshControlProps {
+ value: number;
+ options: AutoRefreshOptions;
+ label: string;
+ offLabel: string;
+ ariaLabel: string;
+ refreshAriaLabel: string;
+ onChange: (value: number) => void;
+ onAutoRefresh?: () => void | Promise;
+ onRefresh: () => void | Promise;
+ isRefreshing?: boolean;
+ isAutoRefreshing?: boolean;
+ isDisabled?: boolean;
+}
+
+function useAutoRefreshCountdown({
+ active,
+ isRefreshing,
+ onRefresh,
+ resetKey,
+ seconds,
+}: {
+ active: boolean;
+ isRefreshing: boolean;
+ onRefresh: () => void | Promise;
+ resetKey: number;
+ seconds: number;
+}) {
+ const [remainingSeconds, setRemainingSeconds] = useState(seconds);
+ const onRefreshRef = useRef(onRefresh);
+ const isRefreshingRef = useRef(isRefreshing);
+
+ useEffect(() => {
+ onRefreshRef.current = onRefresh;
+ }, [onRefresh]);
+
+ useEffect(() => {
+ isRefreshingRef.current = isRefreshing;
+ }, [isRefreshing]);
+
+ useEffect(() => {
+ if (!active || seconds <= 0 || typeof window === 'undefined') {
+ setRemainingSeconds(seconds);
+ return undefined;
+ }
+
+ const intervalMs = seconds * 1000;
+ let disposed = false;
+ let timeoutId: number | undefined;
+ let nextRefreshAt = Date.now() + intervalMs;
+
+ const clearTimer = () => {
+ if (timeoutId !== undefined) {
+ window.clearTimeout(timeoutId);
+ timeoutId = undefined;
+ }
+ };
+
+ const documentHidden = () => typeof document !== 'undefined' && document.visibilityState === 'hidden';
+
+ const scheduleNextTick = () => {
+ if (disposed) return;
+ clearTimer();
+
+ if (documentHidden()) {
+ setRemainingSeconds(seconds);
+ return;
+ }
+
+ const msLeft = Math.max(0, nextRefreshAt - Date.now());
+ setRemainingSeconds(Math.max(1, Math.ceil(msLeft / 1000)));
+ timeoutId = window.setTimeout(runTick, Math.min(1000, msLeft));
+ };
+
+ const runTick = () => {
+ if (disposed) return;
+
+ const now = Date.now();
+ if (now >= nextRefreshAt) {
+ if (isRefreshingRef.current) {
+ nextRefreshAt = now + 1000;
+ } else {
+ void onRefreshRef.current();
+ nextRefreshAt = Date.now() + intervalMs;
+ }
+ }
+
+ scheduleNextTick();
+ };
+
+ const handleVisibilityChange = () => {
+ if (documentHidden()) {
+ clearTimer();
+ setRemainingSeconds(seconds);
+ return;
+ }
+ nextRefreshAt = Date.now() + intervalMs;
+ scheduleNextTick();
+ };
+
+ setRemainingSeconds(seconds);
+ scheduleNextTick();
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
+ return () => {
+ disposed = true;
+ clearTimer();
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ };
+ }, [active, resetKey, seconds]);
+
+ return remainingSeconds;
+}
+
+export const AutoRefreshControl = memo(function AutoRefreshControl({
+ value,
+ options,
+ label,
+ offLabel,
+ ariaLabel,
+ refreshAriaLabel,
+ onChange,
+ onAutoRefresh,
+ onRefresh,
+ isAutoRefreshing,
+ isRefreshing = false,
+ isDisabled = false,
+}: AutoRefreshControlProps) {
+ const enabled = value > 0;
+ const [manualRefreshVersion, setManualRefreshVersion] = useState(0);
+ const autoRefreshHandler = onAutoRefresh ?? onRefresh;
+ const remainingSeconds = useAutoRefreshCountdown({
+ active: enabled && !isDisabled,
+ isRefreshing: isAutoRefreshing ?? isRefreshing,
+ onRefresh: autoRefreshHandler,
+ resetKey: manualRefreshVersion,
+ seconds: value,
+ });
+ const selectedKeys = useMemo(() => new Set([`auto_${value}`]), [value]);
+ const currentLabel = enabled ? `${label}${remainingSeconds}s` : offLabel;
+ const optionLabel = (seconds: number) => (seconds === 0 ? offLabel : `${label}${seconds}s`);
+ const handleRefresh = useCallback(() => {
+ void onRefresh();
+ if (enabled) {
+ setManualRefreshVersion((version) => version + 1);
+ }
+ }, [enabled, onRefresh]);
+
+ return (
+ <>
+
+
+
+ {currentLabel}
+
+
+
+ {
+ onChange(normalizeAutoRefresh(String(key).replace('auto_', ''), options));
+ }}
+ >
+ {options.map((seconds) => {
+ const itemLabel = optionLabel(seconds);
+ return (
+
+
+ {itemLabel}
+ {value === seconds ? : null}
+
+
+ );
+ })}
+
+
+
+ >
+ );
+});
diff --git a/web/src/shared/components/UsageRecordsTable.tsx b/web/src/shared/components/UsageRecordsTable.tsx
index e812352..a5bfd8f 100644
--- a/web/src/shared/components/UsageRecordsTable.tsx
+++ b/web/src/shared/components/UsageRecordsTable.tsx
@@ -1,4 +1,4 @@
-import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState, type AnimationEvent, type CSSProperties, type ReactNode } from 'react';
import { EmptyState } from '@heroui/react';
import { Inbox } from 'lucide-react';
import type { UsageColumnConfig, UsageRow } from '../columns/usageColumns';
@@ -8,7 +8,7 @@ import { TablePaginationFooter } from './TablePaginationFooter';
const FULL_CELL_CONTENT_COLUMNS = new Set(['cost', 'tokens']);
const LEFT_ALIGNED_CONTENT_COLUMNS = new Set(['model']);
-const NEW_ROW_MARK_DURATION_MS = 5000;
+const NEW_ROW_ANIMATION_NAME = 'ag-usage-row-new-enter';
function cx(...classes: Array) {
return classes.filter(Boolean).join(' ');
@@ -47,21 +47,21 @@ function useNewRowMarkers({
const rowIds = useMemo(() => rows.map((row) => String(row.id)), [rows]);
const previousRowIdsRef = useRef | null>(null);
const previousResetKeyRef = useRef(undefined);
- const batchClearTimerRef = useRef(null);
const [markedRowIds, setMarkedRowIds] = useState>(() => new Set());
- useEffect(() => () => {
- if (batchClearTimerRef.current != null) {
- window.clearTimeout(batchClearTimerRef.current);
- }
+ const clearMarkedRowId = useCallback((rowId: string) => {
+ setMarkedRowIds((current) => {
+ if (!current.has(rowId)) {
+ return current;
+ }
+ const next = new Set(current);
+ next.delete(rowId);
+ return next;
+ });
}, []);
useEffect(() => {
const clearActiveBatch = () => {
- if (batchClearTimerRef.current != null) {
- window.clearTimeout(batchClearTimerRef.current);
- batchClearTimerRef.current = null;
- }
setMarkedRowIds((current) => (current.size === 0 ? current : new Set()));
};
@@ -93,33 +93,36 @@ function useNewRowMarkers({
return;
}
- if (batchClearTimerRef.current != null) {
- window.clearTimeout(batchClearTimerRef.current);
- }
setMarkedRowIds(new Set(addedIds));
- batchClearTimerRef.current = window.setTimeout(() => {
- batchClearTimerRef.current = null;
- setMarkedRowIds(new Set());
- }, NEW_ROW_MARK_DURATION_MS);
}, [dataVersion, enabled, paused, resetKey, rowIds]);
- return markedRowIds;
+ return { clearMarkedRowId, markedRowIds };
}
const UsageTableRow = memo(function UsageTableRow({
columns,
isNew,
+ onNewAnimationEnd,
row,
}: {
columns: UsageColumnConfig[];
isNew: boolean;
+ onNewAnimationEnd: (rowId: string) => void;
row: UsageRow;
}) {
+ const rowId = String(row.id);
+ const handleAnimationEnd = (event: AnimationEvent) => {
+ if (event.animationName === NEW_ROW_ANIMATION_NAME) {
+ onNewAnimationEnd(rowId);
+ }
+ };
+
return (
{columns.map((column) => {
const fullCellContent = FULL_CELL_CONTENT_COLUMNS.has(column.key);
@@ -200,7 +203,7 @@ export function UsageRecordsTable({
}) as CSSProperties,
[tableMinWidth, tableMobileWidthDelta],
);
- const markedRowIds = useNewRowMarkers({
+ const { clearMarkedRowId, markedRowIds } = useNewRowMarkers({
dataVersion,
enabled: highlightNewRows,
paused: isLoading || suppressHighlight,
@@ -267,6 +270,7 @@ export function UsageRecordsTable({
key={row.id}
columns={columns as UsageColumnConfig[]}
isNew={markedRowIds.has(String(row.id))}
+ onNewAnimationEnd={clearMarkedRowId}
row={row}
/>
))}
diff --git a/web/src/shared/hooks/usePersistentAutoRefresh.ts b/web/src/shared/hooks/usePersistentAutoRefresh.ts
new file mode 100644
index 0000000..e53113e
--- /dev/null
+++ b/web/src/shared/hooks/usePersistentAutoRefresh.ts
@@ -0,0 +1,59 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export const ADMIN_AUTO_REFRESH_OPTIONS = [0, 1, 3, 5, 15, 30] as const;
+export const USER_AUTO_REFRESH_OPTIONS = [0, 5, 10, 15, 30] as const;
+
+export type AutoRefreshOptions = readonly number[];
+type AutoRefreshValueInput = string | number | boolean | null | undefined | ((previous: number) => unknown);
+export type SetAutoRefreshValue = (value: AutoRefreshValueInput) => void;
+
+function defaultEnabledOption(options: AutoRefreshOptions): number {
+ return options.find((option) => option > 0) ?? 0;
+}
+
+export function normalizeAutoRefresh(value: unknown, options: AutoRefreshOptions = ADMIN_AUTO_REFRESH_OPTIONS): number {
+ if (value === true || value === 'true') {
+ return defaultEnabledOption(options);
+ }
+ if (value === false || value === 'false' || value == null) {
+ return 0;
+ }
+
+ const parsed = typeof value === 'number' ? value : Number(value);
+ return options.includes(parsed) ? parsed : 0;
+}
+
+export function usePersistentAutoRefresh(key: string, defaultValue = 0, options: AutoRefreshOptions = ADMIN_AUTO_REFRESH_OPTIONS) {
+ const [value, setValue] = useState(() => {
+ const normalizedDefault = normalizeAutoRefresh(defaultValue, options);
+ if (typeof window === 'undefined') return normalizedDefault;
+ try {
+ const stored = window.localStorage.getItem(key);
+ if (stored == null) return normalizedDefault;
+ return normalizeAutoRefresh(stored, options);
+ } catch {
+ return normalizedDefault;
+ }
+ });
+ const setNormalizedValue = useCallback((nextValue) => {
+ setValue((previous) => {
+ const resolvedValue = typeof nextValue === 'function' ? nextValue(previous) : nextValue;
+ return normalizeAutoRefresh(resolvedValue, options);
+ });
+ }, [options]);
+
+ useEffect(() => {
+ setValue((previous) => normalizeAutoRefresh(previous, options));
+ }, [options]);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.setItem(key, String(value));
+ } catch {
+ // localStorage can be unavailable in restricted browser modes.
+ }
+ }, [key, value]);
+
+ return [value, setNormalizedValue] as const;
+}
diff --git a/web/src/styles/heroui.css b/web/src/styles/heroui.css
index 3b63fbe..9fec95d 100644
--- a/web/src/styles/heroui.css
+++ b/web/src/styles/heroui.css
@@ -372,9 +372,9 @@
.ag-usage-date-range .date-input-group {
display: grid !important;
- grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) 1.625rem;
+ grid-template-columns: max-content auto max-content minmax(0, 1fr);
align-items: center;
- column-gap: 0.125rem;
+ column-gap: 0.375rem;
overflow: hidden;
}
@@ -396,6 +396,7 @@
flex: 0 0 1.625rem;
align-items: center;
justify-content: center;
+ justify-self: end;
width: 1.625rem;
min-width: 1.625rem;
gap: 0;