Files
CC-Switch/src/components/usage/UsageDashboard.tsx
T
Jason f061b777b7 feat(usage): add Hermes Agent tracking + fix zero-cost bug + perf
Hermes:
- Parse ~/.hermes/state.db sessions (incl. profiles/*/state.db) into
  proxy_request_logs with data_source='hermes_session', WAL-aware
  incremental sync, Hermes-reported cost preferred over model_pricing
  fallback

Zero-cost bug (dashboard showed \$0 totals):
- GPT-5.5 family default pricing (~83% of affected rows used GPT-5.5)
- find_model_pricing_row: ASCII-lowercase normalization so
  "OpenAI/GPT-5.5@HIGH" matches seeded "gpt-5.5"
- Startup cost backfill in async task: scan rows where total_cost <= 0
  but tokens > 0, recompute via model_pricing in a single transaction

Performance:
- Add (app_type, created_at DESC) covering index for dashboard range
  queries
- Add expression index on COALESCE(data_source, 'proxy') so dedup EXISTS
  subqueries use index lookup instead of full scan; drop superseded
  idx_request_logs_dedup_lookup

Refactor:
- row_to_request_log_detail helper (3-way de-dup; fixes cost_multiplier
  \"1\" vs \"1.0\" drift between callers)
- Promote get_sync_state/update_sync_state to shared session_usage
  module (4 copies -> 1)
- run_step helper in lib.rs replaces 9 if-let-Err blocks
- maybe_backfill_log_costs returns bool to skip duplicate total_cost
  parsing in caller
2026-04-29 22:41:46 +08:00

223 lines
7.9 KiB
TypeScript

import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { UsageSummaryCards } from "./UsageSummaryCards";
import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import type { AppTypeFilter, UsageRangeSelection } from "@/types/usage";
import { motion } from "framer-motion";
import {
BarChart3,
ListFilter,
Activity,
RefreshCw,
Coins,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { cn } from "@/lib/utils";
import { getLocaleFromLanguage } from "./format";
import { getUsageRangePresetLabel, resolveUsageRange } from "@/lib/usageRange";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const APP_FILTER_OPTIONS: AppTypeFilter[] = [
"all",
"claude",
"codex",
"gemini",
"hermes",
];
export function UsageDashboard() {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
const [appType, setAppType] = useState<AppTypeFilter>("all");
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;
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
const next = refreshIntervalOptionsMs[nextIndex];
setRefreshIntervalMs(next);
queryClient.invalidateQueries({ queryKey: usageKeys.all });
};
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
const resolvedRange = useMemo(() => resolveUsageRange(range), [range]);
const rangeLabel = useMemo(() => {
if (range.preset !== "custom") {
return getUsageRangePresetLabel(range.preset, t);
}
return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date(
resolvedRange.endDate * 1000,
).toLocaleString(locale)}`;
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="space-y-8 pb-8"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("usage.subtitle")}
</p>
</div>
</div>
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4">
<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 className="ml-auto flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-muted-foreground"
title={t("common.refresh", "刷新")}
onClick={changeRefreshInterval}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
</Button>
<UsageDateRangePicker
selection={range}
triggerLabel={rangeLabel}
onApply={(nextRange) => setRange(nextRange)}
/>
</div>
</div>
</div>
</div>
<UsageSummaryCards
range={range}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
<UsageTrendChart
range={range}
rangeLabel={rangeLabel}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
<div className="space-y-4">
<Tabs defaultValue="logs" className="w-full">
<div className="flex items-center justify-between mb-4">
<TabsList className="bg-muted/50">
<TabsTrigger value="logs" className="gap-2">
<ListFilter className="h-4 w-4" />
{t("usage.requestLogs")}
</TabsTrigger>
<TabsTrigger value="providers" className="gap-2">
<Activity className="h-4 w-4" />
{t("usage.providerStats")}
</TabsTrigger>
<TabsTrigger value="models" className="gap-2">
<BarChart3 className="h-4 w-4" />
{t("usage.modelStats")}
</TabsTrigger>
</TabsList>
</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<TabsContent value="logs" className="mt-0">
<RequestLogTable
range={range}
rangeLabel={rangeLabel}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
onRangeChange={setRange}
/>
</TabsContent>
<TabsContent value="providers" className="mt-0">
<ProviderStatsTable
range={range}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
<TabsContent value="models" className="mt-0">
<ModelStatsTable
range={range}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
</motion.div>
</Tabs>
</div>
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
<AccordionItem
value="pricing"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.pricing.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.pricing.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
</Accordion>
</motion.div>
);
}