Compare commits

...

1 Commits

Author SHA1 Message Date
YoVinchen
5b16650036 feat(usage): enhance dashboard with auto-refresh control and robust formatting
- Add configurable auto-refresh interval toggle (off/5s/10s/30s/60s) to usage dashboard
- Extract shared format utilities (fmtUsd, fmtInt, parseFiniteNumber, getLocaleFromLanguage)
- Refactor request log time filtering to rolling vs fixed mode with validation
- Use stable serializable query keys instead of filter objects
- Handle NaN/Infinity safely in number formatting across all usage components
- Use RFC 3339 date format in backend trend data
2026-02-06 15:46:53 +08:00
9 changed files with 401 additions and 130 deletions

View File

@@ -272,7 +272,7 @@ impl Database {
.single()
.unwrap_or_else(Local::now);
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
let date = bucket_start.to_rfc3339();
if let Some(mut stat) = map.remove(&i) {
stat.date = date;

View File

@@ -8,10 +8,17 @@ import {
TableRow,
} from "@/components/ui/table";
import { useModelStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
export function ModelStatsTable() {
interface ModelStatsTableProps {
refreshIntervalMs: number;
}
export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats();
const { data: stats, isLoading } = useModelStats({
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
@@ -60,10 +67,10 @@ export function ModelStatsTable() {
{stat.totalTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
${parseFloat(stat.totalCost).toFixed(4)}
{fmtUsd(stat.totalCost, 4)}
</TableCell>
<TableCell className="text-right">
${parseFloat(stat.avgCostPerRequest).toFixed(6)}
{fmtUsd(stat.avgCostPerRequest, 6)}
</TableCell>
</TableRow>
))

View File

@@ -8,10 +8,19 @@ import {
TableRow,
} from "@/components/ui/table";
import { useProviderStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
export function ProviderStatsTable() {
interface ProviderStatsTableProps {
refreshIntervalMs: number;
}
export function ProviderStatsTable({
refreshIntervalMs,
}: ProviderStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats();
const { data: stats, isLoading } = useProviderStats({
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
@@ -63,7 +72,7 @@ export function ProviderStatsTable() {
{stat.totalTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
${parseFloat(stat.totalCost).toFixed(4)}
{fmtUsd(stat.totalCost, 4)}
</TableCell>
<TableCell className="text-right">
{stat.successRate.toFixed(1)}%

View File

@@ -21,44 +21,122 @@ import { useRequestLogs, usageKeys } from "@/lib/query/usage";
import { useQueryClient } from "@tanstack/react-query";
import type { LogFilters } from "@/types/usage";
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
import {
fmtInt,
fmtUsd,
getLocaleFromLanguage,
parseFiniteNumber,
} from "./format";
export function RequestLogTable() {
interface RequestLogTableProps {
refreshIntervalMs: number;
}
const ONE_DAY_SECONDS = 24 * 60 * 60;
const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
type TimeMode = "rolling" | "fixed";
export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
// 默认时间范围过去24小时
const getDefaultFilters = (): LogFilters => {
const getRollingRange = () => {
const now = Math.floor(Date.now() / 1000);
const oneDayAgo = now - 24 * 60 * 60;
const oneDayAgo = now - ONE_DAY_SECONDS;
return { startDate: oneDayAgo, endDate: now };
};
const [filters, setFilters] = useState<LogFilters>(getDefaultFilters);
const [tempFilters, setTempFilters] = useState<LogFilters>(getDefaultFilters);
const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>("rolling");
const [draftTimeMode, setDraftTimeMode] = useState<TimeMode>("rolling");
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
const [page, setPage] = useState(0);
const pageSize = 20;
const [validationError, setValidationError] = useState<string | null>(null);
const { data: result, isLoading } = useRequestLogs(filters, page, pageSize);
const { data: result, isLoading } = useRequestLogs({
filters: appliedFilters,
timeMode: appliedTimeMode,
rollingWindowSeconds: ONE_DAY_SECONDS,
page,
pageSize,
options: {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
});
const logs = result?.data ?? [];
const total = result?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
const handleSearch = () => {
setFilters(tempFilters);
setValidationError(null);
if (draftTimeMode === "fixed") {
const start = draftFilters.startDate;
const end = draftFilters.endDate;
if (typeof start !== "number" || typeof end !== "number") {
setValidationError(
t("usage.invalidTimeRange", "请选择完整的开始/结束时间"),
);
return;
}
if (start > end) {
setValidationError(
t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"),
);
return;
}
if (end - start > MAX_FIXED_RANGE_SECONDS) {
setValidationError(
t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"),
);
return;
}
}
setAppliedTimeMode(draftTimeMode);
setAppliedFilters((prev) => {
const next = { ...prev, ...draftFilters };
if (draftTimeMode === "rolling") {
delete next.startDate;
delete next.endDate;
}
return next;
});
setPage(0);
};
const handleReset = () => {
const defaults = getDefaultFilters();
setTempFilters(defaults);
setFilters(defaults);
setValidationError(null);
setAppliedTimeMode("rolling");
setDraftTimeMode("rolling");
setDraftFilters({});
setAppliedFilters({});
setPage(0);
};
const handleRefresh = () => {
const key = {
timeMode: appliedTimeMode,
rollingWindowSeconds:
appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined,
appType: appliedFilters.appType,
providerName: appliedFilters.providerName,
model: appliedFilters.model,
statusCode: appliedFilters.statusCode,
startDate:
appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined,
endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined,
};
queryClient.invalidateQueries({
queryKey: usageKeys.logs(filters, page, pageSize),
queryKey: usageKeys.logs(key, page, pageSize),
});
};
@@ -84,12 +162,11 @@ export function RequestLogTable() {
return Math.floor(timestamp / 1000);
};
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
const rollingRangeForDisplay =
draftTimeMode === "rolling" ? getRollingRange() : null;
return (
<div className="space-y-4">
@@ -97,10 +174,10 @@ export function RequestLogTable() {
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-3">
<Select
value={tempFilters.appType || "all"}
value={draftFilters.appType || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
setDraftFilters({
...draftFilters,
appType: v === "all" ? undefined : v,
})
}
@@ -117,11 +194,16 @@ export function RequestLogTable() {
</Select>
<Select
value={tempFilters.statusCode?.toString() || "all"}
value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
statusCode: v === "all" ? undefined : parseInt(v),
setDraftFilters({
...draftFilters,
statusCode:
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
})
}
>
@@ -144,10 +226,10 @@ export function RequestLogTable() {
<Input
placeholder={t("usage.searchProviderPlaceholder")}
className="pl-9 bg-background"
value={tempFilters.providerName || ""}
value={draftFilters.providerName || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
setDraftFilters({
...draftFilters,
providerName: e.target.value || undefined,
})
}
@@ -156,10 +238,10 @@ export function RequestLogTable() {
<Input
placeholder={t("usage.searchModelPlaceholder")}
className="w-[180px] bg-background"
value={tempFilters.model || ""}
value={draftFilters.model || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
setDraftFilters({
...draftFilters,
model: e.target.value || undefined,
})
}
@@ -174,14 +256,18 @@ export function RequestLogTable() {
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
tempFilters.startDate
? timestampToLocalDatetime(tempFilters.startDate)
(rollingRangeForDisplay?.startDate ?? draftFilters.startDate)
? timestampToLocalDatetime(
(rollingRangeForDisplay?.startDate ??
draftFilters.startDate) as number,
)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({
...tempFilters,
setDraftTimeMode("fixed");
setDraftFilters({
...draftFilters,
startDate: timestamp,
});
}}
@@ -191,14 +277,18 @@ export function RequestLogTable() {
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
tempFilters.endDate
? timestampToLocalDatetime(tempFilters.endDate)
(rollingRangeForDisplay?.endDate ?? draftFilters.endDate)
? timestampToLocalDatetime(
(rollingRangeForDisplay?.endDate ??
draftFilters.endDate) as number,
)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({
...tempFilters,
setDraftTimeMode("fixed");
setDraftFilters({
...draftFilters,
endDate: timestamp,
});
}}
@@ -234,6 +324,10 @@ export function RequestLogTable() {
</Button>
</div>
</div>
{validationError && (
<div className="text-sm text-red-600">{validationError}</div>
)}
</div>
{isLoading ? (
@@ -293,9 +387,7 @@ export function RequestLogTable() {
logs.map((log) => (
<TableRow key={log.requestId}>
<TableCell>
{new Date(log.createdAt * 1000).toLocaleString(
dateLocale,
)}
{new Date(log.createdAt * 1000).toLocaleString(locale)}
</TableCell>
<TableCell>
{log.providerName || t("usage.unknownProvider")}
@@ -321,19 +413,19 @@ export function RequestLogTable() {
)}
</TableCell>
<TableCell className="text-right">
{log.inputTokens.toLocaleString()}
{fmtInt(log.inputTokens, locale)}
</TableCell>
<TableCell className="text-right">
{log.outputTokens.toLocaleString()}
{fmtInt(log.outputTokens, locale)}
</TableCell>
<TableCell className="text-right">
{log.cacheReadTokens.toLocaleString()}
{fmtInt(log.cacheReadTokens, locale)}
</TableCell>
<TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()}
{fmtInt(log.cacheCreationTokens, locale)}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{parseFloat(log.costMultiplier) !== 1 ? (
{(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? (
<span className="text-orange-600">
×{log.costMultiplier}
</span>
@@ -342,24 +434,30 @@ export function RequestLogTable() {
)}
</TableCell>
<TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)}
{fmtUsd(log.totalCostUsd, 6)}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
{(() => {
const durationSec =
(log.durationMs ?? log.latencyMs) / 1000;
const durationColor =
durationSec <= 5
const durationMs =
typeof log.durationMs === "number"
? log.durationMs
: log.latencyMs;
const durationSec = durationMs / 1000;
const durationColor = Number.isFinite(durationSec)
? durationSec <= 5
? "bg-green-100 text-green-800"
: durationSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900";
: "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
>
{Math.round(durationSec)}s
{Number.isFinite(durationSec)
? `${Math.round(durationSec)}s`
: "--"}
</span>
);
})()}
@@ -367,17 +465,20 @@ export function RequestLogTable() {
log.firstTokenMs != null &&
(() => {
const firstSec = log.firstTokenMs / 1000;
const firstColor =
firstSec <= 5
const firstColor = Number.isFinite(firstSec)
? firstSec <= 5
? "bg-green-100 text-green-800"
: firstSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900";
: "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
>
{firstSec.toFixed(1)}s
{Number.isFinite(firstSec)
? `${firstSec.toFixed(1)}s`
: "--"}
</span>
);
})()}

View File

@@ -8,11 +8,28 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import type { TimeRange } from "@/types/usage";
import { motion } from "framer-motion";
import { BarChart3, ListFilter, Activity } from "lucide-react";
import { BarChart3, ListFilter, Activity, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
export function UsageDashboard() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
const changeRefreshInterval = () => {
const currentIndex = refreshIntervalOptionsMs.indexOf(
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
);
const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
const next = refreshIntervalOptionsMs[nextIndex];
setRefreshIntervalMs(next);
queryClient.invalidateQueries({ queryKey: usageKeys.all });
};
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
@@ -34,32 +51,45 @@ export function UsageDashboard() {
onValueChange={(v) => setTimeRange(v as TimeRange)}
className="w-full sm:w-auto"
>
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
<TabsTrigger
value="1d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
<div className="flex w-full sm:w-auto items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-10 px-2 text-xs text-muted-foreground"
title={t("common.refresh", "刷新")}
onClick={changeRefreshInterval}
>
{t("usage.today")}
</TabsTrigger>
<TabsTrigger
value="7d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last7days")}
</TabsTrigger>
<TabsTrigger
value="30d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last30days")}
</TabsTrigger>
</TabsList>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
</Button>
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
<TabsTrigger
value="1d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.today")}
</TabsTrigger>
<TabsTrigger
value="7d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last7days")}
</TabsTrigger>
<TabsTrigger
value="30d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last30days")}
</TabsTrigger>
</TabsList>
</div>
</Tabs>
</div>
<UsageSummaryCards days={days} />
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
<UsageTrendChart days={days} />
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />
<div className="space-y-4">
<Tabs defaultValue="logs" className="w-full">
@@ -86,15 +116,15 @@ export function UsageDashboard() {
transition={{ delay: 0.2 }}
>
<TabsContent value="logs" className="mt-0">
<RequestLogTable />
<RequestLogTable refreshIntervalMs={refreshIntervalMs} />
</TabsContent>
<TabsContent value="providers" className="mt-0">
<ProviderStatsTable />
<ProviderStatsTable refreshIntervalMs={refreshIntervalMs} />
</TabsContent>
<TabsContent value="models" className="mt-0">
<ModelStatsTable />
<ModelStatsTable refreshIntervalMs={refreshIntervalMs} />
</TabsContent>
</motion.div>
</Tabs>

View File

@@ -4,19 +4,26 @@ import { Card, CardContent } from "@/components/ui/card";
import { useUsageSummary } from "@/lib/query/usage";
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
import { motion } from "framer-motion";
import { fmtUsd, parseFiniteNumber } from "./format";
interface UsageSummaryCardsProps {
days: number;
refreshIntervalMs: number;
}
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
export function UsageSummaryCards({
days,
refreshIntervalMs,
}: UsageSummaryCardsProps) {
const { t } = useTranslation();
const { data: summary, isLoading } = useUsageSummary(days);
const { data: summary, isLoading } = useUsageSummary(days, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const stats = useMemo(() => {
const totalRequests = summary?.totalRequests ?? 0;
const totalCost = parseFloat(summary?.totalCost || "0");
const totalCost = parseFiniteNumber(summary?.totalCost);
const inputTokens = summary?.totalInputTokens ?? 0;
const outputTokens = summary?.totalOutputTokens ?? 0;
@@ -37,7 +44,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
},
{
title: t("usage.totalCost"),
value: `$${totalCost.toFixed(4)}`,
value: totalCost == null ? "--" : fmtUsd(totalCost, 4),
icon: DollarSign,
color: "text-green-500",
bg: "bg-green-500/10",

View File

@@ -11,14 +11,26 @@ import {
} from "recharts";
import { useUsageTrends } from "@/lib/query/usage";
import { Loader2 } from "lucide-react";
import {
fmtInt,
fmtUsd,
getLocaleFromLanguage,
parseFiniteNumber,
} from "./format";
interface UsageTrendChartProps {
days: number;
refreshIntervalMs: number;
}
export function UsageTrendChart({ days }: UsageTrendChartProps) {
export function UsageTrendChart({
days,
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days);
const { data: trends, isLoading } = useUsageTrends(days, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
if (isLoading) {
return (
@@ -29,15 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
}
const isToday = days === 1;
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
const language = i18n.resolvedLanguage || i18n.language || "en";
const dateLocale = getLocaleFromLanguage(language);
const chartData =
trends?.map((stat) => {
const pointDate = new Date(stat.date);
const cost = parseFiniteNumber(stat.totalCost);
return {
rawDate: stat.date,
label: isToday
@@ -56,7 +65,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
outputTokens: stat.totalOutputTokens,
cacheCreationTokens: stat.totalCacheCreationTokens,
cacheReadTokens: stat.totalCacheReadTokens,
cost: parseFloat(stat.totalCost),
cost: cost ?? null,
};
}) || [];
@@ -79,9 +88,9 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
/>
<span className="font-medium">{entry.name}:</span>
<span>
{entry.name.includes(t("usage.cost", "成本"))
? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}`
: entry.value.toLocaleString()}
{entry.dataKey === "cost"
? fmtUsd(entry.value, 6)
: fmtInt(entry.value, dateLocale)}
</span>
</div>
))}

View File

@@ -0,0 +1,39 @@
export function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function fmtInt(
value: unknown,
locale?: string,
fallback: string = "--",
): string {
const num = parseFiniteNumber(value);
if (num == null) return fallback;
return new Intl.NumberFormat(locale).format(Math.trunc(num));
}
export function fmtUsd(
value: unknown,
digits: number,
fallback: string = "--",
): string {
const num = parseFiniteNumber(value);
if (num == null) return fallback;
return `$${num.toFixed(digits)}`;
}
export function getLocaleFromLanguage(language: string): string {
if (!language) return "en-US";
if (language.startsWith("zh")) return "zh-CN";
if (language.startsWith("ja")) return "ja-JP";
return "en-US";
}

View File

@@ -2,6 +2,35 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage";
import type { LogFilters } from "@/types/usage";
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
type UsageQueryOptions = {
refetchInterval?: number | false;
refetchIntervalInBackground?: boolean;
};
type RequestLogsTimeMode = "rolling" | "fixed";
type RequestLogsQueryArgs = {
filters: LogFilters;
timeMode: RequestLogsTimeMode;
page?: number;
pageSize?: number;
rollingWindowSeconds?: number;
options?: UsageQueryOptions;
};
type RequestLogsKey = {
timeMode: RequestLogsTimeMode;
rollingWindowSeconds?: number;
appType?: string;
providerName?: string;
model?: string;
statusCode?: number;
startDate?: number;
endDate?: number;
};
// Query keys
export const usageKeys = {
all: ["usage"] as const,
@@ -9,8 +38,21 @@ export const usageKeys = {
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
modelStats: () => [...usageKeys.all, "model-stats"] as const,
logs: (filters: LogFilters, page: number, pageSize: number) =>
[...usageKeys.all, "logs", filters, page, pageSize] as const,
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
[
...usageKeys.all,
"logs",
key.timeMode,
key.rollingWindowSeconds ?? 0,
key.appType ?? "",
key.providerName ?? "",
key.model ?? "",
key.statusCode ?? -1,
key.startDate ?? 0,
key.endDate ?? 0,
page,
pageSize,
] as const,
detail: (requestId: string) =>
[...usageKeys.all, "detail", requestId] as const,
pricing: () => [...usageKeys.all, "pricing"] as const,
@@ -25,58 +67,85 @@ const getWindow = (days: number) => {
};
// Hooks
export function useUsageSummary(days: number) {
export function useUsageSummary(days: number, options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.summary(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageSummary(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false, // 后台不刷新
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, // 后台不刷新
});
}
export function useUsageTrends(days: number) {
export function useUsageTrends(days: number, options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.trends(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useProviderStats() {
export function useProviderStats(options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.providerStats(),
queryFn: usageApi.getProviderStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useModelStats() {
export function useModelStats(options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.modelStats(),
queryFn: usageApi.getModelStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useRequestLogs(
filters: LogFilters,
page: number = 0,
pageSize: number = 20,
) {
const getRollingRange = (windowSeconds: number) => {
const endDate = Math.floor(Date.now() / 1000);
const startDate = endDate - windowSeconds;
return { startDate, endDate };
};
export function useRequestLogs({
filters,
timeMode,
page = 0,
pageSize = 20,
rollingWindowSeconds = 24 * 60 * 60,
options,
}: RequestLogsQueryArgs) {
const key: RequestLogsKey = {
timeMode,
rollingWindowSeconds:
timeMode === "rolling" ? rollingWindowSeconds : undefined,
appType: filters.appType,
providerName: filters.providerName,
model: filters.model,
statusCode: filters.statusCode,
startDate: timeMode === "fixed" ? filters.startDate : undefined,
endDate: timeMode === "fixed" ? filters.endDate : undefined,
};
return useQuery({
queryKey: usageKeys.logs(filters, page, pageSize),
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize),
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
queryKey: usageKeys.logs(key, page, pageSize),
queryFn: () => {
const effectiveFilters =
timeMode === "rolling"
? { ...filters, ...getRollingRange(rollingWindowSeconds) }
: filters;
return usageApi.getRequestLogs(effectiveFilters, page, pageSize);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}