fix(usage): export filtered events and fix dark mode contrast
Export every request matching the current filters, remember the selected page size, and use dark surfaces for filter menus and request cards.
This commit is contained in:
1 parent
ce3d54acd0
commit
56a88ccb6f
7 files changed
+111
-31
No files matched your search
@@ -4255,7 +4255,7 @@ fn load_usage_events(
|
||||
.map(from_sql_i64)
|
||||
.map_err(|error| format!("Failed to count SQLite usage events: {error}"))?
|
||||
.min(usize::MAX as u64) as usize;
|
||||
let page_size = query.page_size.unwrap_or(50).clamp(20, 200);
|
||||
let page_size = query.page_size.unwrap_or(50).clamp(1, 5_000);
|
||||
let total_pages = total.div_ceil(page_size).max(1);
|
||||
let page = query.page.unwrap_or(1).clamp(1, total_pages);
|
||||
let offset = (page - 1).saturating_mul(page_size);
|
||||
@@ -6544,6 +6544,13 @@ mod tests {
|
||||
assert_eq!(analysis.models[0].key, "gpt-5.6-terra");
|
||||
assert_eq!(events.total, 1);
|
||||
assert_eq!(events.items[0].id, "request-2");
|
||||
let exported = load_usage_events(
|
||||
&connection,
|
||||
&UsageQuery { page_size: Some(5_000), ..query },
|
||||
&config,
|
||||
).unwrap();
|
||||
assert_eq!(exported.page_size, 5_000);
|
||||
assert_eq!(exported.items.len(), 1);
|
||||
drop(connection);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
+3
-2
@@ -738,8 +738,9 @@ export const jaOverrides = {
|
||||
'usage.events.title': 'リクエストイベントログ',
|
||||
'usage.events.description': '選択した期間のリクエストイベントを絞り込んで確認します。',
|
||||
'usage.events.columns': '列',
|
||||
'usage.events.exportPage': 'このページを CSV 出力',
|
||||
'usage.events.exportDescription': '現在のページの全リクエスト項目を CSV ファイルに出力します',
|
||||
'usage.events.exportPage': '絞り込み結果を CSV 出力',
|
||||
'usage.events.exportDescription': '現在の絞り込み結果の全リクエスト項目を CSV ファイルに出力します',
|
||||
'usage.events.exporting': '出力中…',
|
||||
'usage.events.loaded': '読み込み済み {count} / {total}',
|
||||
'usage.events.total': '合計 {count} 件',
|
||||
'usage.events.page': '{page} / {total} ページ',
|
||||
|
||||
@@ -726,8 +726,9 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.events.title': 'Request Event Log',
|
||||
'usage.events.description': 'Filter and inspect request events in the selected time range.',
|
||||
'usage.events.columns': 'Columns',
|
||||
'usage.events.exportPage': 'Export Page CSV',
|
||||
'usage.events.exportDescription': 'Export all request fields on the current page as a CSV file',
|
||||
'usage.events.exportPage': 'Export Filtered CSV',
|
||||
'usage.events.exportDescription': 'Export all request fields matching the current filters as a CSV file',
|
||||
'usage.events.exporting': 'Exporting…',
|
||||
'usage.events.loaded': 'Loaded {count} / {total}',
|
||||
'usage.events.total': '{count} records',
|
||||
'usage.events.page': 'Page {page} of {total}',
|
||||
|
||||
@@ -725,8 +725,9 @@ export const zhCN = {
|
||||
'usage.events.title': '请求事件日志',
|
||||
'usage.events.description': '筛选并查看所选时间范围内的请求事件。',
|
||||
'usage.events.columns': '列',
|
||||
'usage.events.exportPage': '导出本页 CSV',
|
||||
'usage.events.exportDescription': '导出当前页的全部请求字段为 CSV 文件',
|
||||
'usage.events.exportPage': '导出筛选结果 CSV',
|
||||
'usage.events.exportDescription': '导出当前筛选结果中的全部请求字段为 CSV 文件',
|
||||
'usage.events.exporting': '正在导出…',
|
||||
'usage.events.loaded': '已加载 {count} / {total}',
|
||||
'usage.events.total': '共 {count} 条记录',
|
||||
'usage.events.page': '第 {page} / {total} 页',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowDown, ArrowUp, Brain, ChevronLeft, ChevronRight, Columns3Cog, Database, DatabaseZap, Download, RotateCcw, TriangleAlert, X } from 'lucide-react';
|
||||
import { getCurrentLocale, useI18n } from '../i18n';
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
@@ -204,6 +205,39 @@ const getInitialColumnWidths = (): Record<EventColumnKey, number> => {
|
||||
return initial;
|
||||
};
|
||||
|
||||
|
||||
type UsageEventQuery = {
|
||||
start?: string;
|
||||
end?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
source?: string;
|
||||
api_key_hash?: string;
|
||||
failed?: boolean;
|
||||
canceled?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
|
||||
function downloadUsageEvents(records: UsageRecord[]) {
|
||||
const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'response_model', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens', 'estimated_cost_usd', 'pricing_model'];
|
||||
const csvCell = (value: string | number | boolean | null) => {
|
||||
const text = value == null ? '' : String(value);
|
||||
const safe = typeof value === 'string' && /^[\s\u0000-\u001f]*[=+@-]/.test(text) ? `'${text}` : text;
|
||||
return `"${safe.replace(/"/g, '""')}"`;
|
||||
};
|
||||
const rows = records.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.response_model ?? '', record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens, record.cost?.total ?? null, record.cost?.pricing_model ?? '']);
|
||||
const csv = '\uFEFF' + [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `usage-events-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function TableTopScrollbar({
|
||||
tableWrapRef,
|
||||
}: {
|
||||
@@ -439,12 +473,14 @@ function UsageEventCell({
|
||||
export function EventsView({
|
||||
events,
|
||||
pageSize,
|
||||
query,
|
||||
onPage,
|
||||
onPageSizeChange,
|
||||
loading = false,
|
||||
}: {
|
||||
events: UsageEventPage;
|
||||
pageSize: number;
|
||||
query: UsageEventQuery;
|
||||
onPage: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
loading?: boolean;
|
||||
@@ -455,6 +491,7 @@ export function EventsView({
|
||||
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false);
|
||||
const [draftVisibleColumnKeys, setDraftVisibleColumnKeys] = useState<EventColumnKey[]>(visibleColumnKeys);
|
||||
const [resizingCol, setResizingCol] = useState<EventColumnKey | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const columnDialogRef = useDialogFocusTrap<HTMLElement>({
|
||||
active: columnSettingsOpen,
|
||||
@@ -611,23 +648,19 @@ export function EventsView({
|
||||
const startRecordNum = events.total > 0 ? (events.page - 1) * pageSize + 1 : 0;
|
||||
const endRecordNum = Math.min(events.page * pageSize, events.total);
|
||||
|
||||
const exportCurrentPage = () => {
|
||||
const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'response_model', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens', 'estimated_cost_usd', 'pricing_model'];
|
||||
const csvCell = (value: string | number | boolean | null) => {
|
||||
const text = value == null ? '' : String(value);
|
||||
const safe = typeof value === 'string' && /^[\s\u0000-\u001f]*[=+@-]/.test(text) ? `'${text}` : text;
|
||||
return `"${safe.replace(/"/g, '""')}"`;
|
||||
};
|
||||
const rows = events.items.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.response_model ?? '', record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens, record.cost?.total ?? null, record.cost?.pricing_model ?? '']);
|
||||
const csv = '\uFEFF' + [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `usage-events-page-${events.page}-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
const exportFilteredEvents = async () => {
|
||||
if (exporting || events.total === 0) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const exported = await invoke<UsageEventPage>('get_usage_events', {
|
||||
query: { ...query, page: 1, page_size: Math.max(events.total, 1) },
|
||||
});
|
||||
downloadUsageEvents(exported.items);
|
||||
} catch (error) {
|
||||
window.alert(String(error));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -729,8 +762,8 @@ export function EventsView({
|
||||
<RotateCcw size={13} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="usage-events-export-btn" disabled={loading || events.items.length === 0} onClick={exportCurrentPage} title={t('usage.events.exportDescription')}>
|
||||
<Download size={14} aria-hidden="true" /><span>{t('usage.events.exportPage')}</span>
|
||||
<button type="button" className="usage-events-export-btn" disabled={loading || exporting || events.total === 0} onClick={() => void exportFilteredEvents()} title={t('usage.events.exportDescription')}>
|
||||
<Download size={14} aria-hidden="true" /><span>{exporting ? t('usage.events.exporting') : t('usage.events.exportPage')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="usage-pagination-controls">
|
||||
|
||||
@@ -160,6 +160,17 @@ type UsageQuery = {
|
||||
|
||||
const TAB_KEY = 'cpa-gui.usage-records-tab.v1';
|
||||
const RANGE_KEY = 'cpa-gui.usage-records-range.v1';
|
||||
const PAGE_SIZE_KEY = 'cpa-gui.usage-events-page-size.v1';
|
||||
const EVENT_PAGE_SIZES = [20, 50, 100, 200] as const;
|
||||
|
||||
const loadPageSize = (): number => {
|
||||
try {
|
||||
const saved = Number(localStorage.getItem(PAGE_SIZE_KEY));
|
||||
return EVENT_PAGE_SIZES.includes(saved as (typeof EVENT_PAGE_SIZES)[number]) ? saved : 50;
|
||||
} catch {
|
||||
return 50;
|
||||
}
|
||||
};
|
||||
const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], apiKeys: [] };
|
||||
|
||||
const loadTab = (): UsageTab => {
|
||||
@@ -248,7 +259,7 @@ export function UsageRecordsPage() {
|
||||
const [apiKeyHash, setApiKeyHash] = useState('');
|
||||
const [result, setResult] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [pageSize, setPageSize] = useState(loadPageSize);
|
||||
const [status, setStatus] = useState<CollectorStatus | null>(null);
|
||||
const [overview, setOverview] = useState<UsageOverview | null>(null);
|
||||
const [overviewRange, setOverviewRange] = useState<Pick<UsageQuery, 'start' | 'end'>>({});
|
||||
@@ -277,6 +288,13 @@ export function UsageRecordsPage() {
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(PAGE_SIZE_KEY, String(pageSize));
|
||||
} catch {
|
||||
}
|
||||
}, [pageSize]);
|
||||
|
||||
const buildQueries = useCallback(() => {
|
||||
const nextTimeQuery = rangeQuery(range, customStart, customEnd);
|
||||
return {
|
||||
@@ -705,6 +723,7 @@ export function UsageRecordsPage() {
|
||||
events={hasCurrentSnapshot && events ? events : { items: [], total: 0, page, pageSize, totalPages: 1 }}
|
||||
loading={showInitialLoading}
|
||||
pageSize={pageSize}
|
||||
query={buildQueries().query}
|
||||
onPage={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
|
||||
@@ -120,6 +120,23 @@
|
||||
min-width: 0;
|
||||
field-sizing: content;
|
||||
padding: 0 20px 0 0;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
color-scheme: inherit;
|
||||
}
|
||||
|
||||
.usage-records-page .usage-filter-item select option,
|
||||
.usage-records-page .usage-filter-item select optgroup {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .usage-records-page .usage-filter-item select,
|
||||
:root[data-theme='dark'] .usage-records-page .usage-filter-item select option,
|
||||
:root[data-theme='dark'] .usage-records-page .usage-filter-item select optgroup {
|
||||
background-color: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Request log reads as separate rounded cards, matching the reference layout. */
|
||||
@@ -153,8 +170,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
color: #8b97a8;
|
||||
background: var(--usage-log-surface);
|
||||
color: var(--usage-log-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: .01em;
|
||||
@@ -177,7 +194,8 @@
|
||||
height: 78px;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
background: var(--usage-log-surface);
|
||||
color: var(--usage-log-text);
|
||||
box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
|
||||
}
|
||||
|
||||
@@ -190,7 +208,7 @@
|
||||
}
|
||||
|
||||
.usage-records-page .usage-request-log .usage-events-table tbody tr:hover td {
|
||||
background: #fff !important;
|
||||
background: var(--usage-log-hover) !important;
|
||||
box-shadow: 0 8px 22px rgb(16 24 40 / 7%);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user