feat(usage): add configurable speed and cache rate columns
This commit is contained in:
1 parent
2599a8a280
commit
1af6a7cfea
7 files changed
+545
-84
No files matched your search
@@ -194,6 +194,11 @@ export const jaOverrides = {
|
||||
'usage.events.resetColumns': '列幅をリセット',
|
||||
'usage.events.resizeHint': 'ドラッグで列幅を調整、ダブルクリックでリセット',
|
||||
'usage.events.dragHint': 'ヘッダーの境界をドラッグして列幅を調整',
|
||||
'usage.events.columnSettings': '列設定',
|
||||
'usage.events.columnSettingsDescription': 'リクエスト詳細に表示する列を選択します。',
|
||||
'usage.events.columnsSelected': '{total} 列中 {selected} 列を選択',
|
||||
'usage.events.selectAllColumns': 'すべて選択',
|
||||
'usage.events.applyColumns': '適用',
|
||||
'usage.column.time': '日時',
|
||||
'usage.column.model': 'モデル',
|
||||
'usage.column.provider': 'Provider',
|
||||
@@ -206,7 +211,9 @@ export const jaOverrides = {
|
||||
'usage.column.output': '出力',
|
||||
'usage.column.reasoning': '推論',
|
||||
'usage.column.cache': 'キャッシュ',
|
||||
'usage.column.cacheRate': 'キャッシュ率',
|
||||
'usage.column.total': '合計',
|
||||
'usage.column.speed': '生成速度',
|
||||
'usage.key.noRemark': 'メモなし',
|
||||
'usage.previous': '前へ',
|
||||
'usage.next': '次へ',
|
||||
|
||||
@@ -194,6 +194,11 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.events.resetColumns': 'Reset Widths',
|
||||
'usage.events.resizeHint': 'Drag to resize, double-click to reset',
|
||||
'usage.events.dragHint': 'Drag column header borders to resize',
|
||||
'usage.events.columnSettings': 'Column Settings',
|
||||
'usage.events.columnSettingsDescription': 'Choose which columns appear in request details.',
|
||||
'usage.events.columnsSelected': '{selected} of {total} columns selected',
|
||||
'usage.events.selectAllColumns': 'Select All',
|
||||
'usage.events.applyColumns': 'Apply',
|
||||
'usage.column.time': 'Time',
|
||||
'usage.column.model': 'Model',
|
||||
'usage.column.provider': 'Provider',
|
||||
@@ -206,7 +211,9 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.column.output': 'Output',
|
||||
'usage.column.reasoning': 'Reasoning',
|
||||
'usage.column.cache': 'Cache',
|
||||
'usage.column.cacheRate': 'Cache Rate',
|
||||
'usage.column.total': 'Total',
|
||||
'usage.column.speed': 'Generation Speed',
|
||||
'usage.key.noRemark': 'No remark',
|
||||
'usage.previous': 'Previous',
|
||||
'usage.next': 'Next',
|
||||
|
||||
@@ -192,6 +192,11 @@ export const zhCN = {
|
||||
'usage.events.resetColumns': '重置列宽',
|
||||
'usage.events.resizeHint': '拖动调整列宽,双击重置本列',
|
||||
'usage.events.dragHint': '可拖动表头边线调整每列宽度',
|
||||
'usage.events.columnSettings': '列设置',
|
||||
'usage.events.columnSettingsDescription': '选择要在请求明细中显示的列。',
|
||||
'usage.events.columnsSelected': '已选择 {selected} / {total} 列',
|
||||
'usage.events.selectAllColumns': '选择全部',
|
||||
'usage.events.applyColumns': '应用',
|
||||
'usage.column.time': '时间',
|
||||
'usage.column.model': '模型',
|
||||
'usage.column.provider': 'Provider',
|
||||
@@ -204,7 +209,9 @@ export const zhCN = {
|
||||
'usage.column.output': '输出',
|
||||
'usage.column.reasoning': '思考',
|
||||
'usage.column.cache': '缓存',
|
||||
'usage.column.cacheRate': '缓存率',
|
||||
'usage.column.total': '总计',
|
||||
'usage.column.speed': '生成速度',
|
||||
'usage.key.noRemark': '未备注',
|
||||
'usage.previous': '上一页',
|
||||
'usage.next': '下一页',
|
||||
|
||||
+282
-81
@@ -6,6 +6,7 @@ import {
|
||||
BarChart3,
|
||||
CircleDollarSign,
|
||||
Clock3,
|
||||
Columns3Cog,
|
||||
Database,
|
||||
FilterX,
|
||||
Key,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { getCurrentLocale, useI18n } from '../i18n';
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
|
||||
import { formatUsageNumber } from '../services/usageNumber';
|
||||
|
||||
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing';
|
||||
@@ -978,7 +980,9 @@ type EventColumnKey =
|
||||
| 'total'
|
||||
| 'result'
|
||||
| 'latency'
|
||||
| 'ttft';
|
||||
| 'ttft'
|
||||
| 'speed'
|
||||
| 'cacheRate';
|
||||
|
||||
type EventColumnDef = {
|
||||
key: EventColumnKey;
|
||||
@@ -998,13 +1002,42 @@ const EVENT_COLUMNS: readonly EventColumnDef[] = [
|
||||
{ key: 'output', labelKey: 'usage.column.output', defaultWidth: 76, minWidth: 50, align: 'center' },
|
||||
{ key: 'reasoning', labelKey: 'usage.column.reasoning', defaultWidth: 76, minWidth: 50, align: 'center' },
|
||||
{ key: 'cache', labelKey: 'usage.column.cache', defaultWidth: 76, minWidth: 50, align: 'center' },
|
||||
{ key: 'cacheRate', labelKey: 'usage.column.cacheRate', defaultWidth: 88, minWidth: 65, align: 'center' },
|
||||
{ key: 'total', labelKey: 'usage.column.total', defaultWidth: 84, minWidth: 60, align: 'center' },
|
||||
{ key: 'result', labelKey: 'usage.column.result', defaultWidth: 160, minWidth: 90, align: 'center' },
|
||||
{ key: 'latency', labelKey: 'usage.column.latency', defaultWidth: 88, minWidth: 65, align: 'center' },
|
||||
{ key: 'ttft', labelKey: 'usage.column.ttft', defaultWidth: 88, minWidth: 65, align: 'center' },
|
||||
{ key: 'speed', labelKey: 'usage.column.speed', defaultWidth: 92, minWidth: 70, align: 'center' },
|
||||
] as const;
|
||||
|
||||
const EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v1';
|
||||
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v1';
|
||||
|
||||
const getAllEventColumnKeys = () => EVENT_COLUMNS.map((column) => column.key);
|
||||
|
||||
const getInitialVisibleColumns = (): EventColumnKey[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(EVENT_VISIBLE_COLS_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const knownKeys = new Set<EventColumnKey>(getAllEventColumnKeys());
|
||||
const seen = new Set<EventColumnKey>();
|
||||
const savedKeys = parsed.filter((key): key is EventColumnKey => {
|
||||
if (typeof key !== 'string' || !knownKeys.has(key as EventColumnKey) || seen.has(key as EventColumnKey)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key as EventColumnKey);
|
||||
return true;
|
||||
});
|
||||
if (savedKeys.length > 0) return savedKeys;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallback to all columns
|
||||
}
|
||||
return getAllEventColumnKeys();
|
||||
};
|
||||
|
||||
const getInitialColumnWidths = (): Record<EventColumnKey, number> => {
|
||||
const initial: Record<EventColumnKey, number> = {} as any;
|
||||
@@ -1053,12 +1086,140 @@ function UsageResultCell({ record }: { record: UsageRecord }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UsageEventCell({
|
||||
record,
|
||||
columnKey,
|
||||
noRemarkLabel,
|
||||
}: {
|
||||
record: UsageRecord;
|
||||
columnKey: EventColumnKey;
|
||||
noRemarkLabel: string;
|
||||
}) {
|
||||
switch (columnKey) {
|
||||
case 'time':
|
||||
return (
|
||||
<td className="usage-td-time align-center" title={new Date(record.timestamp).toLocaleString()}>
|
||||
{formatTime(record.timestamp)}
|
||||
</td>
|
||||
);
|
||||
case 'model':
|
||||
return (
|
||||
<td className="usage-stacked-cell align-center">
|
||||
<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>
|
||||
);
|
||||
case 'provider':
|
||||
return (
|
||||
<td className="usage-td-provider align-center" title={record.provider || undefined}>
|
||||
<span className="usage-tag-pill">{record.provider || '—'}</span>
|
||||
</td>
|
||||
);
|
||||
case 'source':
|
||||
return (
|
||||
<td className="usage-td-source align-center" title={record.source_display || record.source || undefined}>
|
||||
<span className="usage-tag-pill">{record.source_display || record.source || '—'}</span>
|
||||
</td>
|
||||
);
|
||||
case 'key':
|
||||
return (
|
||||
<td className="usage-stacked-cell align-center">
|
||||
<strong title={record.api_key_remark}>{record.api_key_remark || noRemarkLabel}</strong>
|
||||
<small title={record.api_key_display || undefined}>{record.api_key_display || '—'}</small>
|
||||
</td>
|
||||
);
|
||||
case 'input':
|
||||
return (
|
||||
<td className="usage-td-token align-center" title={`${record.tokens.input_tokens.toLocaleString()} tokens`}>
|
||||
{compactNumber(record.tokens.input_tokens)}
|
||||
</td>
|
||||
);
|
||||
case 'output':
|
||||
return (
|
||||
<td className="usage-td-token align-center" title={`${record.tokens.output_tokens.toLocaleString()} tokens`}>
|
||||
{compactNumber(record.tokens.output_tokens)}
|
||||
</td>
|
||||
);
|
||||
case 'reasoning':
|
||||
return (
|
||||
<td className="usage-td-token align-center" title={`${record.tokens.reasoning_tokens.toLocaleString()} tokens`}>
|
||||
{compactNumber(record.tokens.reasoning_tokens)}
|
||||
</td>
|
||||
);
|
||||
case 'cache':
|
||||
return (
|
||||
<td
|
||||
className="usage-td-token align-center"
|
||||
title={`Read: ${record.tokens.cache_read_tokens.toLocaleString()} tokens${
|
||||
record.tokens.cache_creation_tokens > 0
|
||||
? ` / Creation: ${record.tokens.cache_creation_tokens.toLocaleString()} tokens`
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{compactNumber(record.tokens.cache_read_tokens)}
|
||||
</td>
|
||||
);
|
||||
case 'cacheRate': {
|
||||
const value = formatCacheReadRate({
|
||||
inputTokens: record.tokens.input_tokens,
|
||||
cacheReadTokens: record.tokens.cache_read_tokens,
|
||||
});
|
||||
return <td className="usage-td-cache-rate align-center" title={value === '—' ? undefined : value}>{value}</td>;
|
||||
}
|
||||
case 'total':
|
||||
return (
|
||||
<td className="usage-td-token align-center" title={`${record.tokens.total_tokens.toLocaleString()} tokens`}>
|
||||
<strong>{compactNumber(record.tokens.total_tokens)}</strong>
|
||||
</td>
|
||||
);
|
||||
case 'result':
|
||||
return <UsageResultCell record={record} />;
|
||||
case 'latency':
|
||||
return (
|
||||
<td className="usage-td-latency align-center" title={`${record.latency_ms} ms`}>
|
||||
{compactNumber(record.latency_ms)} ms
|
||||
</td>
|
||||
);
|
||||
case 'ttft':
|
||||
return (
|
||||
<td className="usage-td-ttft align-center" title={record.ttft_ms == null ? undefined : `${record.ttft_ms} ms`}>
|
||||
{record.ttft_ms == null ? '—' : `${compactNumber(record.ttft_ms)} ms`}
|
||||
</td>
|
||||
);
|
||||
case 'speed': {
|
||||
const value = formatGenerationSpeed({
|
||||
outputTokens: record.tokens.output_tokens,
|
||||
latencyMs: record.latency_ms,
|
||||
ttftMs: record.ttft_ms,
|
||||
});
|
||||
return <td className="usage-td-speed align-center" title={value === '—' ? undefined : value}>{value}</td>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [widths, setWidths] = useState<Record<EventColumnKey, number>>(getInitialColumnWidths);
|
||||
const [visibleColumnKeys, setVisibleColumnKeys] = useState<EventColumnKey[]>(getInitialVisibleColumns);
|
||||
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false);
|
||||
const [draftVisibleColumnKeys, setDraftVisibleColumnKeys] = useState<EventColumnKey[]>(visibleColumnKeys);
|
||||
const [resizingCol, setResizingCol] = useState<EventColumnKey | null>(null);
|
||||
const columnDialogRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const visibleColumnKeySet = new Set(visibleColumnKeys);
|
||||
const visibleColumns = EVENT_COLUMNS.filter((column) => visibleColumnKeySet.has(column.key));
|
||||
const isCustomized = EVENT_COLUMNS.some((col) => widths[col.key] !== col.defaultWidth);
|
||||
const noRemarkLabel = t('usage.key.noRemark');
|
||||
|
||||
useEffect(() => {
|
||||
if (columnSettingsOpen) columnDialogRef.current?.focus();
|
||||
}, [columnSettingsOpen]);
|
||||
|
||||
const resetAllWidths = () => {
|
||||
const defaults: Record<EventColumnKey, number> = {} as any;
|
||||
@@ -1071,6 +1232,34 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const openColumnSettings = () => {
|
||||
setDraftVisibleColumnKeys(visibleColumnKeys);
|
||||
setColumnSettingsOpen(true);
|
||||
};
|
||||
|
||||
const toggleDraftColumn = (key: EventColumnKey) => {
|
||||
setDraftVisibleColumnKeys((current) => {
|
||||
if (current.includes(key)) {
|
||||
return current.length > 1 ? current.filter((columnKey) => columnKey !== key) : current;
|
||||
}
|
||||
return EVENT_COLUMNS.filter((column) => current.includes(column.key) || column.key === key).map((column) => column.key);
|
||||
});
|
||||
};
|
||||
|
||||
const applyColumnSettings = () => {
|
||||
const next = draftVisibleColumnKeys.length > 0 ? draftVisibleColumnKeys : getAllEventColumnKeys();
|
||||
setVisibleColumnKeys(next);
|
||||
try {
|
||||
localStorage.setItem(EVENT_VISIBLE_COLS_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {}
|
||||
setColumnSettingsOpen(false);
|
||||
};
|
||||
|
||||
const resetVisibleColumns = () => {
|
||||
const allColumns = getAllEventColumnKeys();
|
||||
setDraftVisibleColumnKeys(allColumns);
|
||||
};
|
||||
|
||||
const resetSingleColumn = (key: EventColumnKey, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1125,7 +1314,7 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
};
|
||||
|
||||
const totalTableWidth = EVENT_COLUMNS.reduce((sum, col) => sum + (widths[col.key] ?? col.defaultWidth), 0);
|
||||
const totalTableWidth = visibleColumns.reduce((sum, col) => sum + (widths[col.key] ?? col.defaultWidth), 0);
|
||||
|
||||
return (
|
||||
<section className="panel usage-events-panel">
|
||||
@@ -1139,6 +1328,15 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
|
||||
</span>
|
||||
</div>
|
||||
<div className="usage-events-summary-right">
|
||||
<button
|
||||
type="button"
|
||||
className="usage-col-settings-btn"
|
||||
onClick={openColumnSettings}
|
||||
title={t('usage.events.columnSettings')}
|
||||
>
|
||||
<Columns3Cog size={13} />
|
||||
<span>{t('usage.events.columnSettings')}</span>
|
||||
</button>
|
||||
{isCustomized ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1160,13 +1358,13 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
|
||||
<div className="usage-table-wrap">
|
||||
<table className="usage-events-table" style={{ width: `${totalTableWidth}px`, minWidth: '100%' }}>
|
||||
<colgroup>
|
||||
{EVENT_COLUMNS.map((col) => (
|
||||
{visibleColumns.map((col) => (
|
||||
<col key={col.key} style={{ width: `${widths[col.key]}px` }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{EVENT_COLUMNS.map((col) => {
|
||||
{visibleColumns.map((col) => {
|
||||
const label = t(col.labelKey);
|
||||
return (
|
||||
<th
|
||||
@@ -1191,83 +1389,14 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
|
||||
<tbody>
|
||||
{events.items.map((record) => (
|
||||
<tr key={record.id}>
|
||||
{/* time */}
|
||||
<td className="usage-td-time align-center" title={new Date(record.timestamp).toLocaleString()}>
|
||||
{formatTime(record.timestamp)}
|
||||
</td>
|
||||
|
||||
{/* model */}
|
||||
<td className="usage-stacked-cell align-center">
|
||||
<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>
|
||||
|
||||
{/* provider */}
|
||||
<td className="usage-td-provider align-center" title={record.provider || undefined}>
|
||||
<span className="usage-tag-pill">{record.provider || '—'}</span>
|
||||
</td>
|
||||
|
||||
{/* source */}
|
||||
<td className="usage-td-source align-center" title={record.source_display || record.source || undefined}>
|
||||
<span className="usage-tag-pill">{record.source_display || record.source || '—'}</span>
|
||||
</td>
|
||||
|
||||
{/* key */}
|
||||
<td className="usage-stacked-cell align-center">
|
||||
<strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong>
|
||||
<small title={record.api_key_display || undefined}>{record.api_key_display || '—'}</small>
|
||||
</td>
|
||||
|
||||
{/* input tokens */}
|
||||
<td className="usage-td-token align-center" title={`${record.tokens.input_tokens.toLocaleString()} tokens`}>
|
||||
{compactNumber(record.tokens.input_tokens)}
|
||||
</td>
|
||||
|
||||
{/* output tokens */}
|
||||
<td className="usage-td-token align-center" title={`${record.tokens.output_tokens.toLocaleString()} tokens`}>
|
||||
{compactNumber(record.tokens.output_tokens)}
|
||||
</td>
|
||||
|
||||
{/* reasoning tokens */}
|
||||
<td className="usage-td-token align-center" title={`${record.tokens.reasoning_tokens.toLocaleString()} tokens`}>
|
||||
{compactNumber(record.tokens.reasoning_tokens)}
|
||||
</td>
|
||||
|
||||
{/* cache tokens */}
|
||||
<td
|
||||
className="usage-td-token align-center"
|
||||
title={`Read: ${record.tokens.cache_read_tokens.toLocaleString()} tokens${
|
||||
record.tokens.cache_creation_tokens > 0
|
||||
? ` / Creation: ${record.tokens.cache_creation_tokens.toLocaleString()} tokens`
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{compactNumber(record.tokens.cache_read_tokens)}
|
||||
</td>
|
||||
|
||||
{/* total tokens */}
|
||||
<td className="usage-td-token align-center" title={`${record.tokens.total_tokens.toLocaleString()} tokens`}>
|
||||
<strong>{compactNumber(record.tokens.total_tokens)}</strong>
|
||||
</td>
|
||||
|
||||
{/* result */}
|
||||
<UsageResultCell record={record} />
|
||||
|
||||
{/* latency */}
|
||||
<td className="usage-td-latency align-center" title={`${record.latency_ms} ms`}>
|
||||
{compactNumber(record.latency_ms)} ms
|
||||
</td>
|
||||
|
||||
{/* ttft */}
|
||||
<td className="usage-td-ttft align-center" title={record.ttft_ms == null ? undefined : `${record.ttft_ms} ms`}>
|
||||
{record.ttft_ms == null ? '—' : `${compactNumber(record.ttft_ms)} ms`}
|
||||
</td>
|
||||
{visibleColumns.map((column) => (
|
||||
<UsageEventCell
|
||||
key={column.key}
|
||||
record={record}
|
||||
columnKey={column.key}
|
||||
noRemarkLabel={noRemarkLabel}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1298,6 +1427,78 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
|
||||
{t('usage.next')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{columnSettingsOpen ? (
|
||||
<div
|
||||
className="config-dialog-backdrop"
|
||||
onMouseDown={(event) => event.currentTarget === event.target && setColumnSettingsOpen(false)}
|
||||
>
|
||||
<section
|
||||
ref={columnDialogRef}
|
||||
className="config-dialog usage-column-dialog"
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-modal="true"
|
||||
aria-labelledby="usage-column-dialog-title"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setColumnSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="usage-column-dialog-heading">
|
||||
<div>
|
||||
<Columns3Cog size={19} aria-hidden="true" />
|
||||
<h2 id="usage-column-dialog-title">{t('usage.events.columnSettings')}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() => setColumnSettingsOpen(false)}
|
||||
title={t('common.close')}
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="usage-column-dialog-description">{t('usage.events.columnSettingsDescription')}</p>
|
||||
<div className="usage-column-options">
|
||||
{EVENT_COLUMNS.map((column) => {
|
||||
const checked = draftVisibleColumnKeys.includes(column.key);
|
||||
return (
|
||||
<label key={column.key} className="usage-column-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={checked && draftVisibleColumnKeys.length === 1}
|
||||
onChange={() => toggleDraftColumn(column.key)}
|
||||
/>
|
||||
<span>{t(column.labelKey)}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="usage-column-dialog-footer">
|
||||
<div className="usage-column-dialog-meta">
|
||||
<span>
|
||||
{t('usage.events.columnsSelected', {
|
||||
selected: draftVisibleColumnKeys.length,
|
||||
total: EVENT_COLUMNS.length,
|
||||
})}
|
||||
</span>
|
||||
<button type="button" className="usage-column-select-all" onClick={resetVisibleColumns}>
|
||||
{t('usage.events.selectAllColumns')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="usage-column-dialog-actions">
|
||||
<button type="button" className="secondary-button" onClick={() => setColumnSettingsOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" className="primary-button" onClick={applyColumnSettings}>
|
||||
{t('usage.events.applyColumns')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
type GenerationSpeedInput = {
|
||||
outputTokens: number;
|
||||
latencyMs: number;
|
||||
ttftMs: number | null;
|
||||
};
|
||||
|
||||
type CacheReadRateInput = {
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
};
|
||||
|
||||
export const calculateGenerationSpeed = ({
|
||||
outputTokens,
|
||||
latencyMs,
|
||||
ttftMs,
|
||||
}: GenerationSpeedInput): number | null => {
|
||||
if (
|
||||
!Number.isFinite(outputTokens) ||
|
||||
!Number.isFinite(latencyMs) ||
|
||||
ttftMs === null ||
|
||||
!Number.isFinite(ttftMs) ||
|
||||
outputTokens <= 0 ||
|
||||
ttftMs <= 0 ||
|
||||
latencyMs <= ttftMs
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const speed = outputTokens / ((latencyMs - ttftMs) / 1_000);
|
||||
return Number.isFinite(speed) && speed > 0 ? speed : null;
|
||||
};
|
||||
|
||||
export const formatGenerationSpeed = (input: GenerationSpeedInput): string => {
|
||||
const speed = calculateGenerationSpeed(input);
|
||||
return speed === null ? '—' : `${speed.toFixed(1)} t/s`;
|
||||
};
|
||||
|
||||
export const calculateCacheReadRate = ({
|
||||
inputTokens,
|
||||
cacheReadTokens,
|
||||
}: CacheReadRateInput): number | null => {
|
||||
if (!Number.isFinite(inputTokens) || !Number.isFinite(cacheReadTokens) || inputTokens <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (cacheReadTokens / inputTokens) * 100;
|
||||
};
|
||||
|
||||
export const formatCacheReadRate = (input: CacheReadRateInput): string => {
|
||||
const rate = calculateCacheReadRate(input);
|
||||
return rate === null ? '—' : `${rate.toFixed(2)}%`;
|
||||
};
|
||||
+144
-3
@@ -7300,7 +7300,8 @@ textarea:focus-visible {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.usage-col-reset-btn {
|
||||
.usage-col-reset-btn,
|
||||
.usage-col-settings-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -7316,12 +7317,135 @@ textarea:focus-visible {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.usage-col-reset-btn:hover {
|
||||
.usage-col-reset-btn:hover,
|
||||
.usage-col-settings-btn:hover {
|
||||
background: var(--theme-e8e5dc);
|
||||
color: var(--theme-2d2a26);
|
||||
border-color: var(--theme-d5d2cb);
|
||||
}
|
||||
|
||||
.usage-column-dialog {
|
||||
display: flex;
|
||||
width: min(540px, 100%);
|
||||
height: auto;
|
||||
max-height: min(680px, calc(100vh - 40px));
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-column-dialog-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.usage-column-dialog-heading > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--theme-5f5a53);
|
||||
}
|
||||
|
||||
.usage-column-dialog-heading h2 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: var(--font-size-dialog-title);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-column-dialog-description {
|
||||
margin: 0;
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.usage-column-options {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.usage-column-option {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 6px;
|
||||
background: var(--theme-fffdf8);
|
||||
color: var(--theme-5f5a53);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.usage-column-option:hover {
|
||||
border-color: var(--theme-d5d2cb);
|
||||
background: var(--theme-faf8f4);
|
||||
}
|
||||
|
||||
.usage-column-option input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;
|
||||
accent-color: var(--theme-3f6f98);
|
||||
}
|
||||
|
||||
.usage-column-option:has(input:disabled) {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.usage-column-dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.usage-column-dialog-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--theme-8a847c);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-column-select-all {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-5f5a53);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-column-select-all:hover {
|
||||
color: var(--theme-2d2a26);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.usage-column-dialog-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-column-dialog-actions button {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.usage-table-wrap {
|
||||
overflow: auto;
|
||||
min-height: 380px;
|
||||
@@ -7447,10 +7571,27 @@ body.table-col-resizing * {
|
||||
|
||||
.usage-events-table td.usage-td-token,
|
||||
.usage-events-table td.usage-td-latency,
|
||||
.usage-events-table td.usage-td-ttft {
|
||||
.usage-events-table td.usage-td-ttft,
|
||||
.usage-events-table td.usage-td-speed,
|
||||
.usage-events-table td.usage-td-cache-rate {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.usage-column-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.usage-column-dialog-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.usage-column-dialog-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-events-table td.usage-td-provider,
|
||||
.usage-events-table td.usage-td-source {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
calculateCacheReadRate,
|
||||
calculateGenerationSpeed,
|
||||
formatCacheReadRate,
|
||||
formatGenerationSpeed,
|
||||
} from '../src/services/usageMetrics';
|
||||
|
||||
describe('generation speed', () => {
|
||||
test('uses only the generation interval after the first token', () => {
|
||||
const input = { outputTokens: 344, latencyMs: 10_600, ttftMs: 2_770 };
|
||||
expect(calculateGenerationSpeed(input)).toBeCloseTo(43.9336, 4);
|
||||
expect(formatGenerationSpeed(input)).toBe('43.9 t/s');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ outputTokens: 344, latencyMs: 10_600, ttftMs: 0 },
|
||||
{ outputTokens: 344, latencyMs: 10_600, ttftMs: null },
|
||||
{ outputTokens: 344, latencyMs: 2_770, ttftMs: 2_770 },
|
||||
{ outputTokens: 344, latencyMs: 2_000, ttftMs: 2_770 },
|
||||
{ outputTokens: 0, latencyMs: 10_600, ttftMs: 2_770 },
|
||||
])('returns an em dash when generation speed cannot be calculated', (input) => {
|
||||
expect(calculateGenerationSpeed(input)).toBeNull();
|
||||
expect(formatGenerationSpeed(input)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache read rate', () => {
|
||||
test('calculates the percentage from cache-read and input tokens', () => {
|
||||
const input = { inputTokens: 1_000, cacheReadTokens: 250 };
|
||||
expect(calculateCacheReadRate(input)).toBe(25);
|
||||
expect(formatCacheReadRate(input)).toBe('25.00%');
|
||||
});
|
||||
|
||||
test('does not clamp values over 100 percent', () => {
|
||||
const input = { inputTokens: 400, cacheReadTokens: 600 };
|
||||
expect(calculateCacheReadRate(input)).toBe(150);
|
||||
expect(formatCacheReadRate(input)).toBe('150.00%');
|
||||
});
|
||||
|
||||
test.each([0, -1])('returns an em dash when input tokens are %s', (inputTokens) => {
|
||||
const input = { inputTokens, cacheReadTokens: 250 };
|
||||
expect(calculateCacheReadRate(input)).toBeNull();
|
||||
expect(formatCacheReadRate(input)).toBe('—');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user