mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-23 06:04:43 +08:00
feat: add session log usage tracking without proxy
Parse Claude Code JSONL session files (~/.claude/projects/) and Codex SQLite database (~/.codex/state_5.sqlite) to track API usage without requiring proxy interception. This enables usage statistics for users who don't use the proxy feature. Key changes: - Add session_usage.rs: incremental JSONL parser with message.id dedup - Add session_usage_codex.rs: import thread-level token data from Codex - Add data_source column to proxy_request_logs (proxy/session_log/codex_db) - Add session_log_sync table for tracking parse offsets - Background sync every 60s + manual sync via DataSourceBar UI - Schema migration v7→v8 - i18n support for zh/en/ja
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usageApi } from "@/lib/api/usage";
|
||||
import { usageKeys } from "@/lib/query/usage";
|
||||
import { Database, FileText, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DataSourceBarProps {
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
proxy: <Database className="h-3.5 w-3.5" />,
|
||||
session_log: <FileText className="h-3.5 w-3.5" />,
|
||||
codex_db: <Database className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const { data: sources } = useQuery({
|
||||
queryKey: [...usageKeys.all, "data-sources"],
|
||||
queryFn: usageApi.getDataSourceBreakdown,
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const result = await usageApi.syncSessionUsage();
|
||||
if (result.imported > 0) {
|
||||
toast.success(
|
||||
t("usage.sessionSync.imported", {
|
||||
count: result.imported,
|
||||
defaultValue: "Imported {{count}} records from session logs",
|
||||
}),
|
||||
);
|
||||
// Refresh all usage data
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
||||
} else {
|
||||
toast.info(
|
||||
t("usage.sessionSync.upToDate", {
|
||||
defaultValue: "Session logs are up to date",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
t("usage.sessionSync.failed", {
|
||||
defaultValue: "Session sync failed",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasNonProxy = sources.some((s) => s.dataSource !== "proxy");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground bg-muted/30 rounded-lg px-4 py-2">
|
||||
<span className="font-medium text-foreground/70">
|
||||
{t("usage.dataSources", { defaultValue: "Data Sources" })}:
|
||||
</span>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={source.dataSource}
|
||||
className="flex items-center gap-1.5 bg-background/50 rounded-md px-2 py-1"
|
||||
>
|
||||
{DATA_SOURCE_ICONS[source.dataSource] ?? (
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{t(`usage.dataSource.${source.dataSource}`, {
|
||||
defaultValue: source.dataSource,
|
||||
})}
|
||||
</span>
|
||||
<span className="font-mono font-medium text-foreground/80">
|
||||
{source.requestCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
title={t("usage.sessionSync.trigger", {
|
||||
defaultValue: "Sync session logs",
|
||||
})}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{hasNonProxy
|
||||
? t("usage.sessionSync.resync", { defaultValue: "Sync" })
|
||||
: t("usage.sessionSync.import", {
|
||||
defaultValue: "Import Sessions",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -371,13 +371,16 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("usage.status")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("usage.source", { defaultValue: "Source" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={11}
|
||||
colSpan={12}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("usage.noData")}
|
||||
@@ -506,6 +509,21 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
|
||||
{log.statusCode}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.dataSource && log.dataSource !== "proxy" ? (
|
||||
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-indigo-100 text-indigo-800">
|
||||
{t(`usage.dataSource.${log.dataSource}`, {
|
||||
defaultValue: log.dataSource,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-gray-100 text-gray-600">
|
||||
{t("usage.dataSource.proxy", {
|
||||
defaultValue: "Proxy",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { motion } from "framer-motion";
|
||||
import {
|
||||
@@ -100,6 +101,8 @@ export function UsageDashboard() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DataSourceBar refreshIntervalMs={refreshIntervalMs} />
|
||||
|
||||
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
|
||||
|
||||
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />
|
||||
|
||||
@@ -1018,6 +1018,21 @@
|
||||
"unknownProvider": "Unknown Provider",
|
||||
"stream": "Stream",
|
||||
"nonStream": "Non-stream",
|
||||
"source": "Source",
|
||||
"dataSources": "Data Sources",
|
||||
"dataSource": {
|
||||
"proxy": "Proxy",
|
||||
"session_log": "Session Log",
|
||||
"codex_db": "Codex DB"
|
||||
},
|
||||
"sessionSync": {
|
||||
"trigger": "Sync session logs",
|
||||
"import": "Import Sessions",
|
||||
"resync": "Sync",
|
||||
"imported": "Imported {{count}} records from session logs",
|
||||
"upToDate": "Session logs are up to date",
|
||||
"failed": "Session sync failed"
|
||||
},
|
||||
"totalRecords": "{{total}} records total",
|
||||
"modelPricing": "Model Pricing",
|
||||
"loadPricingError": "Failed to load pricing data",
|
||||
|
||||
@@ -1018,6 +1018,21 @@
|
||||
"unknownProvider": "不明なプロバイダー",
|
||||
"stream": "ストリーム",
|
||||
"nonStream": "非ストリーム",
|
||||
"source": "ソース",
|
||||
"dataSources": "データソース",
|
||||
"dataSource": {
|
||||
"proxy": "プロキシ",
|
||||
"session_log": "セッションログ",
|
||||
"codex_db": "Codex DB"
|
||||
},
|
||||
"sessionSync": {
|
||||
"trigger": "セッションログを同期",
|
||||
"import": "セッションをインポート",
|
||||
"resync": "同期",
|
||||
"imported": "セッションログから {{count}} 件のレコードをインポートしました",
|
||||
"upToDate": "セッションログは最新です",
|
||||
"failed": "セッション同期に失敗しました"
|
||||
},
|
||||
"totalRecords": "全 {{total}} 件",
|
||||
"modelPricing": "モデル料金",
|
||||
"loadPricingError": "料金データの読み込みに失敗しました",
|
||||
|
||||
@@ -1018,6 +1018,21 @@
|
||||
"unknownProvider": "未知供应商",
|
||||
"stream": "流",
|
||||
"nonStream": "非流",
|
||||
"source": "来源",
|
||||
"dataSources": "数据来源",
|
||||
"dataSource": {
|
||||
"proxy": "代理",
|
||||
"session_log": "会话日志",
|
||||
"codex_db": "Codex 数据库"
|
||||
},
|
||||
"sessionSync": {
|
||||
"trigger": "同步会话日志",
|
||||
"import": "导入会话",
|
||||
"resync": "同步",
|
||||
"imported": "从会话日志导入了 {{count}} 条记录",
|
||||
"upToDate": "会话日志已是最新",
|
||||
"failed": "会话同步失败"
|
||||
},
|
||||
"totalRecords": "共 {{total}} 条记录",
|
||||
"modelPricing": "模型定价",
|
||||
"loadPricingError": "加载定价数据失败",
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
ModelPricing,
|
||||
ProviderLimitStatus,
|
||||
PaginatedLogs,
|
||||
SessionSyncResult,
|
||||
DataSourceSummary,
|
||||
} from "@/types/usage";
|
||||
import type { UsageResult } from "@/types";
|
||||
import type { AppId } from "./types";
|
||||
@@ -115,4 +117,13 @@ export const usageApi = {
|
||||
): Promise<ProviderLimitStatus> => {
|
||||
return invoke("check_provider_limits", { providerId, appType });
|
||||
},
|
||||
|
||||
// Session usage sync
|
||||
syncSessionUsage: async (): Promise<SessionSyncResult> => {
|
||||
return invoke("sync_session_usage");
|
||||
},
|
||||
|
||||
getDataSourceBreakdown: async (): Promise<DataSourceSummary[]> => {
|
||||
return invoke("get_usage_data_sources");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,6 +31,20 @@ export interface RequestLog {
|
||||
statusCode: number;
|
||||
errorMessage?: string;
|
||||
createdAt: number;
|
||||
dataSource?: string;
|
||||
}
|
||||
|
||||
export interface SessionSyncResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
filesScanned: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface DataSourceSummary {
|
||||
dataSource: string;
|
||||
requestCount: number;
|
||||
totalCostUsd: string;
|
||||
}
|
||||
|
||||
export interface PaginatedLogs {
|
||||
|
||||
Reference in New Issue
Block a user