feat: add per-app usage filtering (Claude/Codex/Gemini)

Add dashboard-level app type filter to usage statistics, replacing the
DataSourceBar with a more useful segmented control. All components
(summary cards, trend chart, provider stats, model stats, request logs)
now respond to the selected app filter.

Backend: add optional app_type parameter to get_usage_summary,
get_daily_trends, get_provider_stats, and get_model_stats queries.
Frontend: new AppTypeFilter type, updated query keys with appType
dimension for proper cache separation, and RequestLogTable local
filter auto-locks when dashboard filter is active.
This commit is contained in:
Jason
2026-04-06 22:41:35 +08:00
parent c0bcd19d44
commit 687ffc237d
14 changed files with 337 additions and 121 deletions
+6 -2
View File
@@ -11,12 +11,16 @@ import { useModelStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
interface ModelStatsTableProps {
appType?: string;
refreshIntervalMs: number;
}
export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) {
export function ModelStatsTable({
appType,
refreshIntervalMs,
}: ModelStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats({
const { data: stats, isLoading } = useModelStats(appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
+3 -1
View File
@@ -11,14 +11,16 @@ import { useProviderStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
interface ProviderStatsTableProps {
appType?: string;
refreshIntervalMs: number;
}
export function ProviderStatsTable({
appType,
refreshIntervalMs,
}: ProviderStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats({
const { data: stats, isLoading } = useProviderStats(appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
+18 -3
View File
@@ -29,6 +29,7 @@ import {
} from "./format";
interface RequestLogTableProps {
appType?: string;
refreshIntervalMs: number;
}
@@ -37,7 +38,10 @@ const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
type TimeMode = "rolling" | "fixed";
export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
export function RequestLogTable({
appType: dashboardAppType,
refreshIntervalMs,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
@@ -56,8 +60,14 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
const pageSize = 20;
const [validationError, setValidationError] = useState<string | null>(null);
// When dashboard-level app filter is active (not "all"), override the local appType filter
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
? { ...appliedFilters, appType: dashboardAppType }
: appliedFilters;
const { data: result, isLoading } = useRequestLogs({
filters: appliedFilters,
filters: effectiveFilters,
timeMode: appliedTimeMode,
rollingWindowSeconds: ONE_DAY_SECONDS,
page,
@@ -174,13 +184,18 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
<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={draftFilters.appType || "all"}
value={
dashboardAppTypeActive
? dashboardAppType
: draftFilters.appType || "all"
}
onValueChange={(v) =>
setDraftFilters({
...draftFilters,
appType: v === "all" ? undefined : v,
})
}
disabled={!!dashboardAppTypeActive}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.appType")} />
+70 -8
View File
@@ -6,8 +6,8 @@ import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import { DataSourceBar } from "./DataSourceBar";
import type { TimeRange } from "@/types/usage";
import type { AppTypeFilter, TimeRange } from "@/types/usage";
import { useUsageSummary } from "@/lib/query/usage";
import { motion } from "framer-motion";
import {
BarChart3,
@@ -26,11 +26,21 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { cn } from "@/lib/utils";
import { fmtUsd, parseFiniteNumber } from "./format";
const APP_FILTER_OPTIONS: AppTypeFilter[] = [
"all",
"claude",
"codex",
"gemini",
];
export function UsageDashboard() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const [appType, setAppType] = useState<AppTypeFilter>("all");
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
@@ -47,6 +57,11 @@ export function UsageDashboard() {
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
// Summary data for the app filter bar
const { data: summaryData } = useUsageSummary(days, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
@@ -101,11 +116,49 @@ export function UsageDashboard() {
</Tabs>
</div>
<DataSourceBar refreshIntervalMs={refreshIntervalMs} />
{/* App type filter bar (replaces DataSourceBar) */}
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{APP_FILTER_OPTIONS.map((type) => (
<button
key={type}
type="button"
onClick={() => setAppType(type)}
className={cn(
"px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
appType === type
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
: "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
)}
>
{t(`usage.appFilter.${type}`)}
</button>
))}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
{(summaryData?.totalRequests ?? 0).toLocaleString()}{" "}
{t("usage.requestsLabel")}
</span>
<span className="text-border">|</span>
<span>
{fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "}
{t("usage.costLabel")}
</span>
</div>
</div>
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
<UsageSummaryCards
days={days}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />
<UsageTrendChart
days={days}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
<div className="space-y-4">
<Tabs defaultValue="logs" className="w-full">
@@ -132,15 +185,24 @@ export function UsageDashboard() {
transition={{ delay: 0.2 }}
>
<TabsContent value="logs" className="mt-0">
<RequestLogTable refreshIntervalMs={refreshIntervalMs} />
<RequestLogTable
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
<TabsContent value="providers" className="mt-0">
<ProviderStatsTable refreshIntervalMs={refreshIntervalMs} />
<ProviderStatsTable
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
<TabsContent value="models" className="mt-0">
<ModelStatsTable refreshIntervalMs={refreshIntervalMs} />
<ModelStatsTable
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
</motion.div>
</Tabs>
+3 -1
View File
@@ -8,16 +8,18 @@ import { fmtUsd, parseFiniteNumber } from "./format";
interface UsageSummaryCardsProps {
days: number;
appType?: string;
refreshIntervalMs: number;
}
export function UsageSummaryCards({
days,
appType,
refreshIntervalMs,
}: UsageSummaryCardsProps) {
const { t } = useTranslation();
const { data: summary, isLoading } = useUsageSummary(days, {
const { data: summary, isLoading } = useUsageSummary(days, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
+3 -1
View File
@@ -20,15 +20,17 @@ import {
interface UsageTrendChartProps {
days: number;
appType?: string;
refreshIntervalMs: number;
}
export function UsageTrendChart({
days,
appType,
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days, {
const { data: trends, isLoading } = useUsageTrends(days, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});