feat(usage): add cost analytics and refine dashboard layout
This commit is contained in:
1 parent
3d2a7f357e
commit
7b9566ef73
6 files changed
+1427
-22
No files matched your search
+52
-2
@@ -98,6 +98,7 @@ export const zhCN = {
|
||||
'usage.tab.overview': '总览',
|
||||
'usage.tab.analysis': '分析',
|
||||
'usage.tab.events': '请求明细',
|
||||
'usage.tab.pricing': '价格统计',
|
||||
'usage.filter.timeRange': '时间范围',
|
||||
'usage.range.4h': '最近 4 小时',
|
||||
'usage.range.24h': '最近 24 小时',
|
||||
@@ -128,9 +129,33 @@ export const zhCN = {
|
||||
'usage.stat.reasoningMeta': '思考 {tokens} Token',
|
||||
'usage.stat.averageLatency': '平均延迟',
|
||||
'usage.stat.tps': 'TPS',
|
||||
'usage.stat.performanceMeta': '{rpm} RPM · {tpm} TPM · 平均延迟 {latency} ms',
|
||||
'usage.stat.performanceMeta': '{rpm} RPM · 平均延迟 {latency} ms',
|
||||
'usage.stat.cacheHitRate': '缓存命中率',
|
||||
'usage.stat.cacheHitMeta': '命中 {hit} / 输入 {input} Token',
|
||||
'usage.stat.estimatedCost': '预估成本',
|
||||
'usage.stat.costMeta': '已计价 {priced} / {total} 次请求 · 仅供参考',
|
||||
'usage.pricing.coverage': '已计价 {priced} / {total} 次请求 · 可用 {saved} 个模型价格',
|
||||
'usage.pricing.search': '搜索模型',
|
||||
'usage.pricing.add': '手动添加',
|
||||
'usage.pricing.sync': '同步价格',
|
||||
'usage.pricing.syncing': '同步中',
|
||||
'usage.pricing.syncResult': '价格更新完成:导入 {imported},保留手动价格 {skipped},未匹配 {unmatched}',
|
||||
'usage.pricing.syncFallback': '远程价格读取失败,已继续使用软件内置价格',
|
||||
'usage.pricing.model': '模型',
|
||||
'usage.pricing.calls': '请求数',
|
||||
'usage.pricing.cost': '预估成本',
|
||||
'usage.pricing.prompt': '输入价 ($/1M)',
|
||||
'usage.pricing.completion': '输出价 ($/1M)',
|
||||
'usage.pricing.cache': '兼容缓存价 ($/1M)',
|
||||
'usage.pricing.cacheRead': '缓存读取价 ($/1M)',
|
||||
'usage.pricing.cacheCreation': '缓存创建价 ($/1M)',
|
||||
'usage.pricing.actions': '操作',
|
||||
'usage.pricing.optional': '可选,留空使用回退价',
|
||||
'usage.pricing.modelRequired': '请输入模型名称',
|
||||
'usage.pricing.saving': '保存中',
|
||||
'usage.pricing.saved': '模型价格已保存,总览成本已更新',
|
||||
'usage.pricing.deleted': '模型价格已删除,总览成本已更新',
|
||||
'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?',
|
||||
'usage.trend.title': '请求与 Token 趋势',
|
||||
'usage.trend.description': '按系统本地小时聚合',
|
||||
'usage.token.title': 'Token 构成',
|
||||
@@ -917,6 +942,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.tab.overview': 'Overview',
|
||||
'usage.tab.analysis': 'Analysis',
|
||||
'usage.tab.events': 'Request Details',
|
||||
'usage.tab.pricing': 'Price Statistics',
|
||||
'usage.filter.timeRange': 'Time range',
|
||||
'usage.range.4h': 'Last 4 Hours',
|
||||
'usage.range.24h': 'Last 24 Hours',
|
||||
@@ -947,9 +973,33 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.stat.reasoningMeta': 'Reasoning {tokens} tokens',
|
||||
'usage.stat.averageLatency': 'Average Latency',
|
||||
'usage.stat.tps': 'TPS',
|
||||
'usage.stat.performanceMeta': '{rpm} RPM · {tpm} TPM · Avg latency {latency} ms',
|
||||
'usage.stat.performanceMeta': '{rpm} RPM · Avg latency {latency} ms',
|
||||
'usage.stat.cacheHitRate': 'Cache Hit Rate',
|
||||
'usage.stat.cacheHitMeta': '{hit} hit / {input} input tokens',
|
||||
'usage.stat.estimatedCost': 'Estimated Cost',
|
||||
'usage.stat.costMeta': '{priced} / {total} requests priced · Estimate only',
|
||||
'usage.pricing.coverage': '{priced} / {total} requests priced · {saved} model prices available',
|
||||
'usage.pricing.search': 'Search model',
|
||||
'usage.pricing.add': 'Add Manually',
|
||||
'usage.pricing.sync': 'Sync Prices',
|
||||
'usage.pricing.syncing': 'Syncing',
|
||||
'usage.pricing.syncResult': 'Prices updated: {imported} imported, {skipped} manual prices kept, {unmatched} unmatched',
|
||||
'usage.pricing.syncFallback': 'Remote prices could not be read; built-in prices remain active',
|
||||
'usage.pricing.model': 'Model',
|
||||
'usage.pricing.calls': 'Requests',
|
||||
'usage.pricing.cost': 'Estimated Cost',
|
||||
'usage.pricing.prompt': 'Input ($/1M)',
|
||||
'usage.pricing.completion': 'Output ($/1M)',
|
||||
'usage.pricing.cache': 'Legacy Cache ($/1M)',
|
||||
'usage.pricing.cacheRead': 'Cache Read ($/1M)',
|
||||
'usage.pricing.cacheCreation': 'Cache Creation ($/1M)',
|
||||
'usage.pricing.actions': 'Actions',
|
||||
'usage.pricing.optional': 'Optional; blank uses fallback',
|
||||
'usage.pricing.modelRequired': 'Enter a model name',
|
||||
'usage.pricing.saving': 'Saving',
|
||||
'usage.pricing.saved': 'Model price saved; overview cost updated',
|
||||
'usage.pricing.deleted': 'Model price deleted; overview cost updated',
|
||||
'usage.pricing.deleteConfirm': 'Delete the price for {model}?',
|
||||
'usage.trend.title': 'Request and Token Trend',
|
||||
'usage.trend.description': 'Grouped by local system hour',
|
||||
'usage.token.title': 'Token Breakdown',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { Activity, BarChart3, Clock3, Database, List, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
|
||||
import { Activity, BarChart3, CircleDollarSign, Clock3, Database, List, Pencil, RefreshCw, ShieldCheck, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react';
|
||||
import { getCurrentLocale, useI18n } from '../i18n';
|
||||
|
||||
type UsageTab = 'overview' | 'analysis' | 'events';
|
||||
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing';
|
||||
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
|
||||
|
||||
type CollectorStatus = {
|
||||
@@ -38,6 +38,8 @@ type UsageOverview = {
|
||||
tps: number;
|
||||
averageLatencyMs: number;
|
||||
cacheHitRate: number;
|
||||
estimatedCost: number;
|
||||
pricedRequests: number;
|
||||
timeline: TimelinePoint[];
|
||||
};
|
||||
|
||||
@@ -90,6 +92,49 @@ type UsageEventPage = {
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type ModelPrice = {
|
||||
model: string;
|
||||
prompt: number;
|
||||
completion: number;
|
||||
cache: number;
|
||||
cacheRead: number;
|
||||
cacheCreation: number;
|
||||
promptConfigured: boolean;
|
||||
completionConfigured: boolean;
|
||||
cacheReadConfigured: boolean;
|
||||
cacheCreationConfigured: boolean;
|
||||
source: string;
|
||||
sourceModelId: string;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
type UsagePriceRow = {
|
||||
model: string;
|
||||
requests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
totalTokens: number;
|
||||
estimatedCost: number;
|
||||
price: ModelPrice | null;
|
||||
};
|
||||
|
||||
type UsagePricing = {
|
||||
rows: UsagePriceRow[];
|
||||
totalCost: number;
|
||||
totalRequests: number;
|
||||
pricedRequests: number;
|
||||
savedPrices: number;
|
||||
};
|
||||
|
||||
type ModelPriceSyncResult = {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
unmatched: string[];
|
||||
usedBuiltin: boolean;
|
||||
};
|
||||
|
||||
type UsageQuery = {
|
||||
start?: string;
|
||||
end?: string;
|
||||
@@ -109,7 +154,7 @@ const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], a
|
||||
const loadTab = (): UsageTab => {
|
||||
try {
|
||||
const saved = localStorage.getItem(TAB_KEY);
|
||||
return saved === 'analysis' || saved === 'events' ? saved : 'overview';
|
||||
return saved === 'analysis' || saved === 'events' || saved === 'pricing' ? saved : 'overview';
|
||||
} catch {
|
||||
return 'overview';
|
||||
}
|
||||
@@ -150,6 +195,22 @@ const compactNumber = (value: number) => new Intl.NumberFormat(getCurrentLocale(
|
||||
maximumFractionDigits: 1,
|
||||
}).format(Number.isFinite(value) ? value : 0);
|
||||
|
||||
const formatUsd = (value: number) => {
|
||||
const amount = Number.isFinite(value) ? value : 0;
|
||||
const absolute = Math.abs(amount);
|
||||
const maximumFractionDigits = absolute === 0 || absolute >= 1
|
||||
? 2
|
||||
: absolute >= 0.01
|
||||
? 4
|
||||
: absolute >= 0.0001
|
||||
? 6
|
||||
: 8;
|
||||
return `$${new Intl.NumberFormat(getCurrentLocale(), {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits,
|
||||
}).format(amount)}`;
|
||||
};
|
||||
|
||||
const formatTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(getCurrentLocale(), {
|
||||
@@ -176,6 +237,7 @@ export function UsageRecordsPage() {
|
||||
const [analysis, setAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
|
||||
const [optionsAnalysis, setOptionsAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
|
||||
const [events, setEvents] = useState<UsageEventPage | null>(null);
|
||||
const [pricing, setPricing] = useState<UsagePricing | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const requestIdRef = useRef(0);
|
||||
@@ -232,7 +294,7 @@ export function UsageRecordsPage() {
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setOverview(nextOverview);
|
||||
setAnalysis(nextAnalysis);
|
||||
} else {
|
||||
} else if (activeTab === 'events') {
|
||||
const [nextStatus, nextOptions, nextEvents] = await Promise.all([
|
||||
statusRequest,
|
||||
optionsRequest,
|
||||
@@ -244,6 +306,16 @@ export function UsageRecordsPage() {
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setEvents(nextEvents);
|
||||
} else {
|
||||
const [nextStatus, nextOptions, nextPricing] = await Promise.all([
|
||||
statusRequest,
|
||||
optionsRequest,
|
||||
invoke<UsagePricing>('get_usage_pricing', { query }),
|
||||
]);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setPricing(nextPricing);
|
||||
}
|
||||
setError('');
|
||||
} catch (requestError) {
|
||||
@@ -291,6 +363,7 @@ export function UsageRecordsPage() {
|
||||
(activeTab === 'overview' && !overview)
|
||||
|| (activeTab === 'analysis' && !overview)
|
||||
|| (activeTab === 'events' && !events)
|
||||
|| (activeTab === 'pricing' && !pricing)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -315,6 +388,7 @@ export function UsageRecordsPage() {
|
||||
<button type="button" className={activeTab === 'overview' ? 'active' : ''} onClick={() => setActiveTab('overview')}><Activity size={15} />{t('usage.tab.overview')}</button>
|
||||
<button type="button" className={activeTab === 'analysis' ? 'active' : ''} onClick={() => setActiveTab('analysis')}><BarChart3 size={15} />{t('usage.tab.analysis')}</button>
|
||||
<button type="button" className={activeTab === 'events' ? 'active' : ''} onClick={() => setActiveTab('events')}><List size={15} />{t('usage.tab.events')}</button>
|
||||
<button type="button" className={activeTab === 'pricing' ? 'active' : ''} onClick={() => setActiveTab('pricing')}><CircleDollarSign size={15} />{t('usage.tab.pricing')}</button>
|
||||
</div>
|
||||
|
||||
<section className="panel usage-filter-panel">
|
||||
@@ -334,6 +408,7 @@ export function UsageRecordsPage() {
|
||||
{activeTab === 'overview' && overview ? <OverviewView overview={overview} /> : null}
|
||||
{activeTab === 'analysis' ? <AnalysisView analysis={analysis} overview={overview} /> : null}
|
||||
{activeTab === 'events' && events ? <EventsView events={events} onPage={setPage} /> : null}
|
||||
{activeTab === 'pricing' && pricing ? <PricingView pricing={pricing} query={buildQueries().query} onChanged={() => loadData(true)} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -344,8 +419,9 @@ function OverviewView({ overview }: { overview: UsageOverview }) {
|
||||
{ icon: Activity, label: t('usage.stat.requests'), value: compactNumber(overview.totalRequests), meta: t('usage.stat.requestMeta', { success: overview.successCount, failed: overview.failureCount }) },
|
||||
{ icon: Sparkles, label: t('usage.stat.tokens'), value: compactNumber(overview.totalTokens), meta: t('usage.stat.tokenMeta', { input: compactNumber(overview.inputTokens), output: compactNumber(overview.outputTokens) }) },
|
||||
{ icon: ShieldCheck, label: t('usage.stat.successRate'), value: `${overview.successRate.toFixed(1)}%`, meta: t('usage.stat.reasoningMeta', { tokens: compactNumber(overview.reasoningTokens) }) },
|
||||
{ icon: Clock3, label: t('usage.stat.tps'), value: `${overview.tps.toFixed(1)} TPS`, meta: t('usage.stat.performanceMeta', { rpm: overview.rpm.toFixed(2), tpm: compactNumber(overview.tpm), latency: Math.round(overview.averageLatencyMs) }) },
|
||||
{ icon: Clock3, label: t('usage.stat.tps'), value: `${overview.tps.toFixed(1)} TPS`, meta: t('usage.stat.performanceMeta', { rpm: overview.rpm.toFixed(2), latency: Math.round(overview.averageLatencyMs) }) },
|
||||
{ icon: Database, label: t('usage.stat.cacheHitRate'), value: `${(overview.cacheHitRate * 100).toFixed(1)}%`, meta: t('usage.stat.cacheHitMeta', { hit: compactNumber(overview.cacheReadTokens), input: compactNumber(overview.inputTokens) }) },
|
||||
{ icon: CircleDollarSign, label: t('usage.stat.estimatedCost'), value: formatUsd(overview.estimatedCost), meta: t('usage.stat.costMeta', { priced: compactNumber(overview.pricedRequests), total: compactNumber(overview.totalRequests) }) },
|
||||
];
|
||||
return <div className="usage-overview-layout">
|
||||
<div className="usage-stat-grid">{cards.map(({ icon: Icon, label, value, meta }) => <article className="panel usage-stat-card" key={label}><span><Icon size={16} />{label}</span><strong>{value}</strong><small>{meta}</small></article>)}</div>
|
||||
@@ -388,7 +464,140 @@ function CategoryPanel({ title, items, compactLabels = false }: { title: string;
|
||||
|
||||
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
|
||||
const { t } = useI18n();
|
||||
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source_display || undefined}>{record.source_display || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
|
||||
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source_display || undefined}>{record.source_display || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
|
||||
}
|
||||
|
||||
type PriceDraft = {
|
||||
model: string;
|
||||
prompt: string;
|
||||
completion: string;
|
||||
cache: string;
|
||||
cacheRead: string;
|
||||
cacheCreation: string;
|
||||
};
|
||||
|
||||
const emptyPriceDraft = (): PriceDraft => ({ model: '', prompt: '', completion: '', cache: '', cacheRead: '', cacheCreation: '' });
|
||||
const priceDraftFor = (model = '', price?: ModelPrice | null): PriceDraft => ({
|
||||
model,
|
||||
prompt: price ? String(price.prompt) : '',
|
||||
completion: price ? String(price.completion) : '',
|
||||
cache: price ? String(price.cache) : '',
|
||||
cacheRead: price && (price.cacheReadConfigured || price.cacheRead > 0) ? String(price.cacheRead) : '',
|
||||
cacheCreation: price && (price.cacheCreationConfigured || price.cacheCreation > 0) ? String(price.cacheCreation) : '',
|
||||
});
|
||||
|
||||
const parsePrice = (value: string) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||
};
|
||||
|
||||
const priceUnit = (value: number | undefined) => Number.isFinite(value) ? `$${Number(value).toFixed(4)}` : '—';
|
||||
|
||||
function PricingView({ pricing, query, onChanged }: { pricing: UsagePricing; query: UsageQuery; onChanged: () => void | Promise<void> }) {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const [draft, setDraft] = useState<PriceDraft | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [localError, setLocalError] = useState('');
|
||||
const visibleRows = pricing.rows.filter((row) => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return !keyword || row.model.toLowerCase().includes(keyword);
|
||||
});
|
||||
|
||||
const savePrice = async () => {
|
||||
if (!draft?.model.trim()) {
|
||||
setLocalError(t('usage.pricing.modelRequired'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
await invoke('save_usage_model_price', {
|
||||
price: {
|
||||
model: draft.model.trim(),
|
||||
prompt: parsePrice(draft.prompt),
|
||||
completion: parsePrice(draft.completion),
|
||||
cache: draft.cache.trim() ? parsePrice(draft.cache) : parsePrice(draft.prompt),
|
||||
cacheRead: parsePrice(draft.cacheRead),
|
||||
cacheCreation: parsePrice(draft.cacheCreation),
|
||||
promptConfigured: draft.prompt.trim() !== '',
|
||||
completionConfigured: draft.completion.trim() !== '',
|
||||
cacheReadConfigured: draft.cacheRead.trim() !== '',
|
||||
cacheCreationConfigured: draft.cacheCreation.trim() !== '',
|
||||
source: 'manual',
|
||||
sourceModelId: '',
|
||||
updatedAtMs: 0,
|
||||
} satisfies ModelPrice,
|
||||
});
|
||||
setDraft(null);
|
||||
setMessage(t('usage.pricing.saved'));
|
||||
await onChanged();
|
||||
} catch (saveError) {
|
||||
setLocalError(String(saveError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePrice = async (model: string) => {
|
||||
if (!window.confirm(t('usage.pricing.deleteConfirm', { model }))) return;
|
||||
setLocalError('');
|
||||
try {
|
||||
await invoke('delete_usage_model_price', { model });
|
||||
setMessage(t('usage.pricing.deleted'));
|
||||
await onChanged();
|
||||
} catch (deleteError) {
|
||||
setLocalError(String(deleteError));
|
||||
}
|
||||
};
|
||||
|
||||
const syncPrices = async () => {
|
||||
setSyncing(true);
|
||||
setLocalError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await invoke<ModelPriceSyncResult>('sync_usage_model_prices', { query });
|
||||
setMessage(result.usedBuiltin
|
||||
? t('usage.pricing.syncFallback')
|
||||
: t('usage.pricing.syncResult', { imported: result.imported, skipped: result.skipped, unmatched: result.unmatched.length }));
|
||||
await onChanged();
|
||||
} catch (syncError) {
|
||||
setLocalError(String(syncError));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <section className="panel usage-pricing-panel">
|
||||
<div className="usage-pricing-toolbar">
|
||||
<div className="usage-pricing-summary">
|
||||
<strong>{formatUsd(pricing.totalCost)}</strong>
|
||||
<span>{t('usage.pricing.coverage', { priced: compactNumber(pricing.pricedRequests), total: compactNumber(pricing.totalRequests), saved: compactNumber(pricing.savedPrices) })}</span>
|
||||
</div>
|
||||
<div className="usage-pricing-actions">
|
||||
<input value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={t('usage.pricing.search')} aria-label={t('usage.pricing.search')} />
|
||||
<button type="button" className="secondary-button" onClick={() => setDraft(emptyPriceDraft())}>{t('usage.pricing.add')}</button>
|
||||
<button type="button" className="primary-button" disabled={syncing} onClick={() => void syncPrices()}><RefreshCw size={14} className={syncing ? 'spin' : ''} />{syncing ? t('usage.pricing.syncing') : t('usage.pricing.sync')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError ? <div className="management-alert error">{localError}</div> : null}
|
||||
{message ? <div className="management-alert success">{message}</div> : null}
|
||||
|
||||
{draft ? <div className="usage-price-editor">
|
||||
<label><span>{t('usage.pricing.model')}</span><input value={draft.model} onChange={(event) => setDraft({ ...draft, model: event.currentTarget.value })} placeholder="gpt-5.6-terra" /></label>
|
||||
<label><span>{t('usage.pricing.prompt')}</span><input type="number" min="0" step="0.0001" value={draft.prompt} onChange={(event) => setDraft({ ...draft, prompt: event.currentTarget.value })} /></label>
|
||||
<label><span>{t('usage.pricing.completion')}</span><input type="number" min="0" step="0.0001" value={draft.completion} onChange={(event) => setDraft({ ...draft, completion: event.currentTarget.value })} /></label>
|
||||
<label><span>{t('usage.pricing.cache')}</span><input type="number" min="0" step="0.0001" value={draft.cache} onChange={(event) => setDraft({ ...draft, cache: event.currentTarget.value })} /></label>
|
||||
<label><span>{t('usage.pricing.cacheRead')}</span><input type="number" min="0" step="0.0001" value={draft.cacheRead} onChange={(event) => setDraft({ ...draft, cacheRead: event.currentTarget.value })} placeholder={t('usage.pricing.optional')} /></label>
|
||||
<label><span>{t('usage.pricing.cacheCreation')}</span><input type="number" min="0" step="0.0001" value={draft.cacheCreation} onChange={(event) => setDraft({ ...draft, cacheCreation: event.currentTarget.value })} placeholder={t('usage.pricing.optional')} /></label>
|
||||
<div className="usage-price-editor-actions"><button type="button" className="secondary-button" onClick={() => setDraft(null)}><X size={14} />{t('common.cancel')}</button><button type="button" className="primary-button" disabled={saving} onClick={() => void savePrice()}>{saving ? t('usage.pricing.saving') : t('common.save')}</button></div>
|
||||
</div> : null}
|
||||
|
||||
{visibleRows.length ? <div className="usage-table-wrap usage-pricing-table-wrap"><table className="usage-pricing-table"><thead><tr><th>{t('usage.pricing.model')}</th><th>{t('usage.pricing.calls')}</th><th>Token</th><th>{t('usage.pricing.cost')}</th><th>{t('usage.pricing.prompt')}</th><th>{t('usage.pricing.completion')}</th><th>{t('usage.pricing.cacheRead')}</th><th>{t('usage.pricing.cacheCreation')}</th><th>{t('usage.pricing.actions')}</th></tr></thead><tbody>{visibleRows.map((row) => <tr key={row.model}><td><strong>{row.model}</strong></td><td>{compactNumber(row.requests)}</td><td>{compactNumber(row.totalTokens)}</td><td><strong>{row.price ? formatUsd(row.estimatedCost) : '—'}</strong></td><td>{row.price ? priceUnit(row.price.prompt) : '—'}</td><td>{row.price ? priceUnit(row.price.completion) : '—'}</td><td>{row.price ? priceUnit(row.price.cacheReadConfigured || row.price.cacheRead > 0 ? row.price.cacheRead : row.price.cache) : '—'}</td><td>{row.price ? priceUnit(row.price.cacheCreationConfigured || row.price.cacheCreation > 0 ? row.price.cacheCreation : row.price.prompt) : '—'}</td><td><div className="usage-price-row-actions"><button type="button" className="icon-button" title={t('common.edit')} onClick={() => setDraft(priceDraftFor(row.model, row.price))}><Pencil size={14} /></button>{row.price?.source === 'manual' ? <button type="button" className="icon-button danger" title={t('common.delete')} onClick={() => void deletePrice(row.model)}><Trash2 size={14} /></button> : null}</div></td></tr>)}</tbody></table></div> : <UsageEmpty />}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function UsageEmpty() {
|
||||
|
||||
+246
-10
@@ -5211,17 +5211,25 @@ input:focus-visible {
|
||||
|
||||
.usage-overview-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr);
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.usage-stat-grid {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.usage-trend-panel {
|
||||
grid-column: 1 / 5;
|
||||
}
|
||||
|
||||
.usage-health-panel {
|
||||
grid-column: 5 / 7;
|
||||
}
|
||||
|
||||
.usage-stat-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
@@ -5422,8 +5430,9 @@ input:focus-visible {
|
||||
|
||||
.usage-events-table {
|
||||
width: 100%;
|
||||
min-width: 1320px;
|
||||
min-width: 1140px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -5449,20 +5458,85 @@ input:focus-visible {
|
||||
.usage-events-table th:first-child,
|
||||
.usage-events-table th:nth-child(2),
|
||||
.usage-events-table th:nth-child(3),
|
||||
.usage-events-table th:nth-child(4),
|
||||
.usage-events-table th:nth-child(5),
|
||||
.usage-events-table td:first-child,
|
||||
.usage-events-table td:nth-child(2),
|
||||
.usage-events-table td:nth-child(3),
|
||||
.usage-events-table td:nth-child(4),
|
||||
.usage-events-table td:nth-child(3) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.usage-events-table th:first-child {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(2) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(3) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(4) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(5) {
|
||||
width: 78px;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(n + 6):nth-child(-n + 10) {
|
||||
width: 64px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-events-table td:nth-child(n + 6):nth-child(-n + 10) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(11) {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(12) {
|
||||
width: 85px;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(13) {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(-n + 5),
|
||||
.usage-events-table td:nth-child(-n + 5) {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(5),
|
||||
.usage-events-table td:nth-child(5) {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(4),
|
||||
.usage-events-table td:nth-child(4) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.usage-events-table th:nth-child(6),
|
||||
.usage-events-table td:nth-child(6) {
|
||||
padding-left: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.usage-events-table .usage-stacked-cell {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
max-width: 180px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.usage-events-table .usage-stacked-cell > strong,
|
||||
.usage-events-table .usage-stacked-cell > small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.usage-events-table td strong,
|
||||
@@ -5502,6 +5576,140 @@ input:focus-visible {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.usage-pricing-panel {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.usage-pricing-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-pricing-summary {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.usage-pricing-summary strong {
|
||||
color: #2d2a26;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.usage-pricing-summary span {
|
||||
color: #8a847c;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.usage-pricing-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-pricing-actions input {
|
||||
width: 220px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d5d2cb;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #2d2a26;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-pricing-actions button,
|
||||
.usage-price-editor-actions button {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.usage-price-editor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd8ce;
|
||||
border-radius: 8px;
|
||||
background: #f8f5ee;
|
||||
}
|
||||
|
||||
.usage-price-editor label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.usage-price-editor label > span {
|
||||
color: #6f6961;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.usage-price-editor input {
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #d5d2cb;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #2d2a26;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-price-editor-actions {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-pricing-table-wrap {
|
||||
min-height: 390px;
|
||||
}
|
||||
|
||||
.usage-pricing-table {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.usage-pricing-table th,
|
||||
.usage-pricing-table td {
|
||||
height: 44px;
|
||||
padding: 7px 9px;
|
||||
border-bottom: 1px solid #eeeae2;
|
||||
color: #5f5a53;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-pricing-table th {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
background: #f4f1ea;
|
||||
color: #7a746c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.usage-pricing-table th:first-child,
|
||||
.usage-pricing-table td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.usage-price-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.real-auth-files-panel {
|
||||
display: block;
|
||||
height: auto;
|
||||
@@ -6230,6 +6438,16 @@ input:focus-visible {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.usage-overview-layout {
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-trend-panel,
|
||||
.usage-health-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.usage-stat-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -6365,6 +6583,24 @@ input:focus-visible {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.usage-pricing-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.usage-pricing-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.usage-pricing-actions input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.usage-price-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.thinking-alias-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user