feat(usage): lift provider/model filters to dashboard-wide scope

The provider/model filters only lived inside the request-log table, so
there was no way to see "how much did app X spend on source Y" across
the whole dashboard. Promote them to the top bar next to the app
filter, applying globally to the hero summary, trend chart, request
logs, and both stats tabs.

Backend: the five stats queries (summary, summary-by-app, trends,
provider stats, model stats) accept optional provider_name/model
filters, applied to both the detail and daily-rollup branches (the
rollup PK already carries provider_id/model/pricing_model). Sources
match by exact display name via provider_name_coalesce, so session
placeholder rows like "Claude (Session)" are selectable; models match
by effective pricing model (pricing_model falling back to model), the
same grouping key the model-stats tab uses. Request-log filtering
switches from LIKE to these exact semantics.

Frontend: two truncating dropdowns list only sources/models that have
data in the current range, with the model list cascading from the
selected source. Dynamic option values are prefix-encoded so a source
literally named "all" cannot collide with the sentinel, query keys
fall back to null instead of "all", and the option queries follow the
dashboard refresh interval (otherwise their default 30s polling drags
same-key stats queries along even with refresh disabled). The
request-log filter bar keeps only the log-specific status-code select.
Labels read "sources" rather than "providers" because direct-connect
session buckets sit alongside real providers. i18n updated across
zh/en/ja/zh-TW.
This commit is contained in:
Jason
2026-06-11 16:14:20 +08:00
parent c701068f0c
commit a75f479576
15 changed files with 871 additions and 281 deletions
+27 -127
View File
@@ -19,13 +19,12 @@ import {
} from "@/components/ui/select";
import { useRequestLogs } from "@/lib/query/usage";
import {
KNOWN_APP_TYPES,
getFreshInputTokens,
isUnpricedUsage,
type LogFilters,
type UsageRangeSelection,
} from "@/types/usage";
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import {
fmtInt,
@@ -38,6 +37,8 @@ interface RequestLogTableProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
onRangeChange?: (range: UsageRangeSelection) => void;
}
@@ -46,21 +47,29 @@ export function RequestLogTable({
range,
rangeLabel,
appType: dashboardAppType,
providerName,
model,
refreshIntervalMs,
onRangeChange,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
// 应用/Provider/模型筛选已上移到 Dashboard 顶栏(全局生效);
// 这里只保留日志特有的状态码筛选。
const [statusCode, setStatusCode] = useState<number | undefined>(undefined);
const [page, setPage] = useState(0);
const [pageInput, setPageInput] = useState("");
const pageSize = 20;
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
? { ...appliedFilters, appType: dashboardAppType }
: appliedFilters;
const effectiveFilters: LogFilters = {
appType:
dashboardAppType && dashboardAppType !== "all"
? dashboardAppType
: undefined,
providerName,
model,
statusCode,
};
const { data: result, isLoading } = useRequestLogs({
filters: effectiveFilters,
@@ -80,37 +89,13 @@ export function RequestLogTable({
setPage(0);
}, [
dashboardAppType,
providerName,
model,
range.customEndDate,
range.customStartDate,
range.preset,
]);
const handleSearch = () => {
setAppliedFilters(draftFilters);
setPage(0);
};
const handleReset = () => {
setDraftFilters({});
setAppliedFilters({});
setPage(0);
};
const applySelectFilter = <K extends keyof LogFilters>(
key: K,
value: LogFilters[K],
) => {
setDraftFilters((prev) => ({
...prev,
[key]: value,
}));
setAppliedFilters((prev) => ({
...prev,
[key]: value,
}));
setPage(0);
};
const handleGoToPage = () => {
const trimmed = pageInput.trim();
if (!/^\d+$/.test(trimmed)) return;
@@ -127,44 +112,16 @@ export function RequestLogTable({
<div className="space-y-4">
<div className="rounded-lg border bg-card/50 p-2 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-1.5">
{/* App type */}
<Select
value={
dashboardAppTypeActive
? dashboardAppType
: draftFilters.appType || "all"
}
onValueChange={(v) =>
applySelectFilter("appType", v === "all" ? undefined : v)
}
disabled={!!dashboardAppTypeActive}
>
<SelectTrigger className="h-8 w-[110px] bg-background text-xs">
<SelectValue placeholder={t("usage.appType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
{KNOWN_APP_TYPES.map((at) => (
<SelectItem key={at} value={at}>
{t(`usage.appFilter.${at}`, { defaultValue: at })}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status code */}
<Select
value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
applySelectFilter(
"statusCode",
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
)
}
value={statusCode?.toString() || "all"}
onValueChange={(v) => {
const parsed = Number.parseInt(v, 10);
setStatusCode(
v === "all" || !Number.isFinite(parsed) ? undefined : parsed,
);
setPage(0);
}}
>
<SelectTrigger className="h-8 w-[100px] bg-background text-xs">
<SelectValue placeholder={t("usage.statusCode")} />
@@ -179,43 +136,6 @@ export function RequestLogTable({
</SelectContent>
</Select>
{/* Provider search */}
<div className="relative min-w-[140px] flex-1">
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("usage.searchProviderPlaceholder")}
className="h-8 bg-background pl-7 text-xs"
value={draftFilters.providerName || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
providerName: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{/* Model search */}
<div className="relative min-w-[120px] flex-1">
<Input
placeholder={t("usage.searchModelPlaceholder")}
className="h-8 bg-background text-xs"
value={draftFilters.model || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
model: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{onRangeChange && (
<UsageDateRangePicker
selection={range}
@@ -223,26 +143,6 @@ export function RequestLogTable({
onApply={onRangeChange}
/>
)}
{/* Search & Reset (icon-only) */}
<Button
size="icon"
variant="default"
onClick={handleSearch}
className="h-8 w-8"
title={t("common.search")}
>
<Search className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="outline"
onClick={handleReset}
className="h-8 w-8"
title={t("common.reset")}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>