refactor(ui): simplify and polish usage records view

This commit is contained in:
lzt404 committed 2026-10-05 16:10:37 +08:00
1 parent b3c6aa6fad
commit f4bba22817
10 files changed
+481 -108

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
## v0.3.22 Release Notes
- The project is currently improving its UI. Suggestions and feedback are welcome in the community chat or on GitHub.
## Added
- Added quota reset support for Claude accounts.
## Fixed
- Fixed layout issues on the quota lookup page.
## v0.3.21 Release Notes
## Added
- Added plugin management and a plugin store with support for installation, updates, version selection, enabling and disabling, uninstallation, configuration editing, and OAuth authorization provided by plugins.
- Added Muse (Meta) OAuth device authorization, along with support for detecting and launching DeepSeek Harness Desktop on Windows.
- Added support for custom system prompts for Codex models. Saved prompts are synchronized to the model catalog, and model defaults can be restored.
- Added batch model health checks. Request records now show the actual response model and flag cases where it differs from the requested model.
- Added per-request cost estimates and CSV export to request records. Reasoning effort is shown by default to make usage review and troubleshooting easier.
## Improved
- Updated the bundled CLIProxyAPI core to **8.0.13** and adapted the management API and configuration editor for v8.
- Redesigned the dashboard, usage analytics, and main management pages with a consistent light theme, navigation, and table styling; improved dark-mode contrast and narrow-window layouts.
- Reworked credential management into a compact list showing plans, status, request statistics, and quotas, with pagination, enable/disable controls, expandable details, and improved cooldown countdowns and reset operations.
- Added more structured settings for requests, OAuth, plugins, concurrency, and providers; improved search, validation, conflict warnings, restoring defaults, and restart notices.
- Added support in API access for native v8 provider groups and per-credential editing, deletion, and reordering. Model aliases are displayed in a separate tab.
- Added search, pagination, and saved display preferences to the client list; expanded installation-path detection and prioritized installed clients.
- Added “Install Bundled Core” to version management, allowing direct installation from the local core package included with the application.
## Fixed
- Fixed migration from v7 to v8 and migration of mixed-format configurations; improved preservation of keys, providers, model aliases, and custom fields, and prevented external refreshes from overwriting unsaved configuration drafts.
- Fixed cooldown states being mixed up for same-named credentials from different accounts, and fixed OAuth model-exclusion updates affecting other providers.
- Fixed cost aggregation when requests with different context lengths are combined, and improved the display of the coverage of cost estimates.
- Fixed model-alias source validation and inconsistent Codex model ID usage; installed plugins and runtime data are now preserved when rebuilding portable distributions.
+37
View File
@@ -0,0 +1,37 @@
## v0.3.22 リリースノート
- 現在、本プロジェクトでは UI の改善を進めています。ご提案やご意見がありましたら、グループチャットまたは GitHub でぜひお寄せください。
## 新機能
- Claude アカウントのクォータをリセットする機能を追加しました。
## 修正
- クォータ確認ページの UI レイアウトが崩れる問題を修正しました。
## v0.3.21 リリースノート
## 新機能
- プラグイン管理とプラグインストアを追加しました。インストール、更新、バージョン選択、有効化・無効化、アンインストール、設定編集、およびプラグインが提供する OAuth 認証に対応します。
- Muse(Meta)の OAuth デバイス認証を追加しました。Windows での DeepSeek Harness Desktop の検出と起動にも対応しました。
- Codex モデルのシステムプロンプトをカスタマイズできるようになりました。保存時にモデルカタログへ同期され、モデルの既定値に戻すこともできます。
- モデルの一括ヘルスチェックを追加しました。リクエスト履歴には実際の応答モデルを表示し、要求したモデルと異なる場合に通知します。
- リクエスト記録にリクエスト単位の推定費用と CSV エクスポートを追加しました。推論レベルを既定で表示し、使用量の確認や問題の調査をしやすくしました。
## 改善
- 同梱の CLIProxyAPI コアを **8.0.13** に更新し、管理 API と設定エディターを v8 に対応させました。
- ダッシュボード、使用量分析、主要な管理ページを刷新しました。ライトテーマ、ナビゲーション、表のスタイルを統一し、ダークモードのコントラストと狭いウィンドウでのレイアウトを改善しました。
- 認証情報の管理をコンパクトな一覧に変更しました。プラン、状態、リクエスト統計、クォータをまとめて表示し、ページ切り替え、有効化・無効化、詳細の展開に対応するとともに、クールダウンの残り時間表示とリセット操作を改善しました。
- リクエスト、OAuth、プラグイン、同時実行、プロバイダーの設定項目を拡充しました。検索、入力検証、競合の警告、既定値への復元、再起動の案内も改善しました。
- API 接続で v8 ネイティブのプロバイダーグループと、認証情報ごとの編集・削除・並べ替えに対応しました。モデルエイリアスは専用タブに移動しました。
- クライアント一覧に検索、ページ切り替え、表示設定の保存を追加しました。インストール先の検出範囲を広げ、インストール済みクライアントを優先して表示します。
- バージョン管理に「同梱コアのインストール」を追加し、アプリに同梱されたローカルのコアパッケージから直接インストールできるようにしました。
## 修正
- v7 から v8 への移行および新旧の形式が混在する設定の移行を修正し、キー、プロバイダー、モデルエイリアス、カスタム項目などの保持を改善しました。外部からの更新で未保存の設定が上書きされる問題も修正しました。
- 異なるアカウントの同名の認証情報でクールダウン状態が混同される問題と、OAuth のモデル除外設定の更新が他のプロバイダーに影響する問題を修正しました。
- コンテキスト長が異なるリクエストをまとめて集計する際の費用計算を修正し、費用推定の対象範囲の表示を改善しました。
- モデルエイリアスの参照元の検証と Codex モデル ID の不統一を修正しました。また、ポータブル版の再構築時にインストール済みプラグインと実行データを保持するようにしました。
+35
View File
@@ -0,0 +1,35 @@
## v0.3.22 发布说明
- 最近当前项目在优化UI,如果您有相关的建议或意见,欢迎在群聊或Github提出。
## 新增
- 添加Claude账号的额度重置功能。
## 修复
- 修复额度查询页面UI混乱问题。
## v0.3.21 发布说明
## 新增
- 新增插件管理与插件商店,支持安装、更新、选择版本、启停、卸载和配置编辑,并接入插件提供的 OAuth 授权。
- 新增 Muse(Meta)OAuth 设备授权;支持在 Windows 上检测并启动 DeepSeek Harness Desktop。
- 支持自定义 Codex 模型的系统提示词,保存后同步到模型目录,并可恢复模型默认值。
- 新增批量模型健康检查;请求记录展示实际响应模型,并提示请求模型与响应模型不一致的情况。
- 请求记录新增单次请求费用估算和 CSV 导出,默认展示思考等级,方便核对用量与排查问题。
## 优化
- 内置 CLIProxyAPI 内核升级至 **8.0.13**,管理接口与配置编辑适配 v8。
- 重构首页、用量分析及主要管理页面,统一浅色主题、导航和表格样式,改善深色模式对比度与窄窗口布局。
- 凭证管理改为紧凑列表,集中展示套餐、状态、请求统计与配额,支持分页、启停和详情展开,并完善冷却倒计时与重置操作。
- 高级设置新增更多结构化配置项,覆盖请求、OAuth、插件、并发与服务商设置;完善搜索、校验、冲突提示、恢复默认值及重启提示。
- API 接入支持 v8 原生服务商分组和逐条凭证编辑、删除、排序,模型别名单独展示在标签页中。
- 客户端列表支持搜索、分页和显示偏好保存,扩展安装路径检测并优先展示已安装客户端。
- 版本管理提供“安装内置内核”,可直接使用随应用附带的本地内核包。
## 修复
- 修复 v7 到 v8 及混合格式配置的迁移问题,完善密钥、服务商、模型别名、自定义字段等配置的保留,并避免外部刷新覆盖未保存的设置草稿。
- 修复不同账号的同名凭证冷却状态混淆,以及 OAuth 模型排除设置更新时影响其他服务商的问题。
- 修复不同上下文长度请求混合统计时的费用汇总,改进费用估算覆盖范围的展示。
- 修复模型别名来源校验与 Codex 模型 ID 使用不一致的问题,并在重建便携版时保留已安装插件及运行数据。
+37
View File
@@ -0,0 +1,37 @@
## v0.3.22 發布說明
- 本專案近期正在改善 UI,歡迎在群組聊天或 GitHub 提出建議與意見。
## 新增
- 新增 Claude 帳號的額度重設功能。
## 修正
- 修正額度查詢頁面的 UI 排版混亂問題。
## v0.3.21 發布說明
## 新增
- 新增外掛管理與外掛商店,支援安裝、更新、選擇版本、啟用與停用、解除安裝及編輯設定,並整合外掛提供的 OAuth 授權。
- 新增 Muse(Meta)OAuth 裝置授權;支援在 Windows 上偵測並啟動 DeepSeek Harness Desktop。
- 支援自訂 Codex 模型的系統提示詞,儲存後同步至模型目錄,並可還原模型預設值。
- 新增批次模型健康檢查;請求記錄顯示實際回應模型,並提示請求模型與回應模型不一致的情況。
- 請求記錄新增單次請求費用估算與 CSV 匯出,預設顯示推理等級,方便核對用量與排查問題。
## 改善
- 內建 CLIProxyAPI 核心升級至 **8.0.13**,管理介面與設定編輯適配 v8。
- 重新設計首頁、用量分析及主要管理頁面,統一淺色主題、導覽與表格樣式,改善深色模式對比度及窄視窗版面。
- 憑證管理改為精簡列表,集中顯示方案、狀態、請求統計與配額,支援分頁、啟用與停用及展開詳細資料,並完善冷卻倒數與重設操作。
- 進階設定新增更多結構化設定項目,涵蓋請求、OAuth、外掛、並行處理與服務供應商設定;完善搜尋、驗證、衝突提示、還原預設值及重新啟動提示。
- API 接入支援 v8 原生服務供應商群組,以及逐筆憑證編輯、刪除與排序,模型別名改於獨立分頁顯示。
- 用戶端列表支援搜尋、分頁及儲存顯示偏好,擴充安裝路徑偵測並優先顯示已安裝的用戶端。
- 版本管理提供「安裝內建核心」,可直接使用應用程式隨附的本機核心套件。
## 修正
- 修正 v7 升級至 v8 及混合格式設定的遷移問題,改善金鑰、服務供應商、模型別名、自訂欄位等設定的保留,並避免外部重新整理覆蓋尚未儲存的設定草稿。
- 修正不同帳號的同名憑證冷卻狀態混淆,以及更新 OAuth 模型排除設定時影響其他服務供應商的問題。
- 修正混合統計不同上下文長度請求時的費用彙總,改善費用估算涵蓋範圍的顯示。
- 修正模型別名來源驗證與 Codex 模型 ID 使用不一致的問題,並在重建可攜版時保留已安裝的外掛及執行資料。
+4
View File
@@ -563,6 +563,10 @@ export const jaOverrides = {
'usage.tab.pricing': '料金統計',
'usage.tab.dataManagement': 'データ管理',
'usage.refresh': '更新',
'usage.filter.title': '絞り込み',
'usage.filter.more': 'その他の絞り込み',
'usage.filter.hideMore': '絞り込みを閉じる',
'usage.filter.active': '{count} 件を適用中',
'usage.filter.timeRange': '期間',
'usage.range.4h': '過去 4 時間',
'usage.range.24h': '過去 24 時間',
+4
View File
@@ -550,6 +550,10 @@ export const en: Record<MessageKey, string> = {
'usage.tab.pricing': 'Price Statistics',
'usage.tab.dataManagement': 'Data Management',
'usage.refresh': 'Refresh',
'usage.filter.title': 'Filters',
'usage.filter.more': 'More filters',
'usage.filter.hideMore': 'Hide filters',
'usage.filter.active': '{count} active',
'usage.filter.timeRange': 'Time range',
'usage.range.4h': 'Last 4 Hours',
'usage.range.24h': 'Last 24 Hours',
+4
View File
@@ -549,6 +549,10 @@ export const zhCN = {
'usage.tab.pricing': '价格统计',
'usage.tab.dataManagement': '数据管理',
'usage.refresh': '刷新数据',
'usage.filter.title': '筛选条件',
'usage.filter.more': '更多筛选',
'usage.filter.hideMore': '收起筛选',
'usage.filter.active': '已启用 {count} 项',
'usage.filter.timeRange': '时间范围',
'usage.range.4h': '最近 4 小时',
'usage.range.24h': '最近 24 小时',
+70 -37
View File
@@ -84,30 +84,33 @@ type EventColumnDef = {
};
const EVENT_COLUMNS: readonly EventColumnDef[] = [
{ key: 'time', labelKey: 'usage.column.time', defaultWidth: 84, minWidth: 76, align: 'left' },
{ key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 108, minWidth: 88, align: 'left' },
{ key: 'time', labelKey: 'usage.column.time', defaultWidth: 82, minWidth: 76, align: 'left' },
{ key: 'model', labelKey: 'usage.column.model', defaultWidth: 150, minWidth: 112, align: 'left' },
{ key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 92, minWidth: 84, align: 'left' },
{ key: 'result', labelKey: 'usage.column.result', defaultWidth: 78, minWidth: 68, align: 'left' },
{ key: 'total', labelKey: 'usage.column.tokens', defaultWidth: 112, minWidth: 100, align: 'left' },
{ key: 'cache', labelKey: 'usage.column.cache', defaultWidth: 94, minWidth: 84, align: 'left' },
{ key: 'latency', labelKey: 'usage.column.latency', defaultWidth: 104, minWidth: 92, align: 'left' },
{ key: 'speed', labelKey: 'usage.column.speed', defaultWidth: 82, minWidth: 72, align: 'left' },
{ key: 'cost', labelKey: 'usage.column.cost', defaultWidth: 96, minWidth: 86, align: 'left' },
{ key: 'key', labelKey: 'usage.column.key', defaultWidth: 112, minWidth: 96, align: 'left' },
{ key: 'source', labelKey: 'usage.column.source', defaultWidth: 128, minWidth: 104, align: 'left' },
{ key: 'model', labelKey: 'usage.column.model', defaultWidth: 160, minWidth: 104, align: 'left' },
{ key: 'effort', labelKey: 'usage.column.effort', defaultWidth: 76, minWidth: 64, align: 'left' },
{ key: 'result', labelKey: 'usage.column.result', defaultWidth: 80, minWidth: 68, align: 'left' },
{ key: 'request', labelKey: 'usage.column.request', defaultWidth: 128, minWidth: 88, align: 'left' },
{ key: 'latency', labelKey: 'usage.column.latency', defaultWidth: 112, minWidth: 96, align: 'left' },
{ key: 'speed', labelKey: 'usage.column.speed', defaultWidth: 88, minWidth: 72, align: 'left' },
{ key: 'total', labelKey: 'usage.column.tokens', defaultWidth: 120, minWidth: 112, align: 'left' },
{ key: 'cache', labelKey: 'usage.column.cache', defaultWidth: 104, minWidth: 88, align: 'left' },
{ key: 'cost', labelKey: 'usage.column.cost', defaultWidth: 112, minWidth: 96, align: 'left' },
] as const;
const DEFAULT_EVENT_VISIBLE_COLUMNS: readonly EventColumnKey[] = [
'time', 'model', 'provider', 'result', 'total', 'cache', 'latency', 'speed', 'cost',
];
const PREVIOUS_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly string[] = [
'time', 'provider', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'cost',
];
const LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly string[] = [
'time', 'model', 'input', 'output', 'cache', 'cacheRate', 'total', 'speed', 'ttft', 'latency', 'result', 'provider', 'source',
];
const EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v3';
const LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v2';
const EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v4';
const LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v3';
const PREVIOUS_COMPACT_WIDTHS: Partial<Record<EventColumnKey, number>> = {
key: 120, source: 180, model: 132, request: 112,
};
@@ -116,8 +119,12 @@ const PREVIOUS_EVENT_COLUMN_WIDTHS: Record<EventColumnKey, number> = {
request: 145, latency: 125, speed: 110, total: 145, cache: 135, provider: 135,
cost: 112,
};
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v5';
const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v4';
const PREVIOUS_GENEROUS_DEFAULT_WIDTHS: Record<EventColumnKey, number> = {
time: 84, model: 160, provider: 108, result: 80, total: 120, cache: 104,
latency: 112, speed: 88, cost: 112, key: 112, source: 128, effort: 76, request: 128,
};
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v6';
const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v5';
const getAllEventColumnKeys = () => EVENT_COLUMNS.map((column) => column.key);
@@ -141,10 +148,12 @@ const getInitialVisibleColumns = (): EventColumnKey[] => {
return true;
});
if (savedKeys.length > 0) {
const wasLegacyDefault = currentRaw === null
&& parsed.length === LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.length
&& LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key));
if (wasLegacyDefault) return [...DEFAULT_EVENT_VISIBLE_COLUMNS];
const wasPreviousDefault = currentRaw === null
&& (parsed.length === PREVIOUS_DEFAULT_EVENT_VISIBLE_COLUMNS.length
&& PREVIOUS_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key))
|| parsed.length === LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.length
&& LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key)));
if (wasPreviousDefault) return [...DEFAULT_EVENT_VISIBLE_COLUMNS];
if (currentRaw === null) {
if (!savedKeys.includes('effort')) savedKeys.push('effort');
if (previousRaw === null && !savedKeys.includes('cost')) savedKeys.push('cost');
@@ -177,9 +186,14 @@ const getInitialColumnWidths = (): Record<EventColumnKey, number> => {
parsed[col.key] >= col.minWidth
) {
// Adopt tighter defaults while retaining columns the user resized.
const oldDefault = previousRaw === null ? PREVIOUS_EVENT_COLUMN_WIDTHS[col.key]
: (PREVIOUS_COMPACT_WIDTHS[col.key] ?? col.defaultWidth);
const wasDefault = currentRaw === null && parsed[col.key] === oldDefault;
const previousDefault = PREVIOUS_EVENT_COLUMN_WIDTHS[col.key];
const compactDefault = PREVIOUS_COMPACT_WIDTHS[col.key];
const generousDefault = PREVIOUS_GENEROUS_DEFAULT_WIDTHS[col.key];
const wasDefault = currentRaw === null && (
parsed[col.key] === previousDefault
|| parsed[col.key] === generousDefault
|| (compactDefault !== undefined && parsed[col.key] === compactDefault)
);
if (!wasDefault) initial[col.key] = Math.min(800, Math.round(parsed[col.key]));
}
}
@@ -273,7 +287,7 @@ function UsageResultCell({ record }: { record: UsageRecord }) {
const { t } = useI18n();
const state = record.canceled ? 'canceled' : record.failed ? 'failed' : 'success';
const detail = [
record.failure_status > 0 ? `HTTP ${record.failure_status}` : '',
record.failure_status > 0 && (state !== 'success' || record.failure_status >= 400) ? `HTTP ${record.failure_status}` : '',
record.failure_body.trim(),
]
.filter(Boolean)
@@ -293,10 +307,14 @@ function UsageEventCell({
record,
columnKey,
noRemarkLabel,
showDate,
compact,
}: {
record: UsageRecord;
columnKey: EventColumnKey;
noRemarkLabel: string;
showDate?: boolean;
compact?: boolean;
}) {
const { t, formatDate } = useI18n();
@@ -305,7 +323,7 @@ function UsageEventCell({
return (
<td className="usage-td-time usage-stacked-cell align-left" title={formatDate(record.timestamp)}>
<strong>{formatTime(record.timestamp)}</strong>
<small>{formatEventDate(record.timestamp)}</small>
{showDate !== false ? <small>{formatEventDate(record.timestamp)}</small> : null}
</td>
);
case 'model': {
@@ -380,12 +398,17 @@ function UsageEventCell({
>
<strong>{formatCacheReadRate({ inputTokens: record.tokens.input_tokens, cacheReadTokens: record.tokens.cache_read_tokens })}</strong>
<div className="usage-event-metrics"><span className="tone-cache-read" title={`${t('usage.token.cacheRead')}: ${record.tokens.cache_read_tokens.toLocaleString()}`} aria-label={`${t('usage.token.cacheRead')}: ${record.tokens.cache_read_tokens}`}><Database size={11} aria-hidden="true" />{compactNumber(record.tokens.cache_read_tokens)}</span></div>
<div className="usage-event-metrics"><span className="tone-cache-write" title={`${t('usage.token.cacheCreation')}: ${record.tokens.cache_creation_tokens.toLocaleString()}`} aria-label={`${t('usage.token.cacheCreation')}: ${record.tokens.cache_creation_tokens}`}><DatabaseZap size={11} aria-hidden="true" />{compactNumber(record.tokens.cache_creation_tokens)}</span></div>
<div className="usage-event-metrics usage-cache-write"><span className="tone-cache-write" title={`${t('usage.token.cacheCreation')}: ${record.tokens.cache_creation_tokens.toLocaleString()}`} aria-label={`${t('usage.token.cacheCreation')}: ${record.tokens.cache_creation_tokens}`}><DatabaseZap size={11} aria-hidden="true" />{compactNumber(record.tokens.cache_creation_tokens)}</span></div>
</td>
);
case 'total':
return (
<td className="usage-td-token usage-td-total align-left" title={`${record.tokens.total_tokens.toLocaleString()} tokens`}>
<td
className="usage-td-token usage-td-total align-left"
title={compact
? `${record.tokens.total_tokens.toLocaleString()} tokens · ${t('usage.column.input')}: ${record.tokens.input_tokens.toLocaleString()} · ${t('usage.column.output')}: ${record.tokens.output_tokens.toLocaleString()} · ${t('usage.column.reasoning')}: ${record.tokens.reasoning_tokens.toLocaleString()}`
: `${record.tokens.total_tokens.toLocaleString()} tokens`}
>
<strong>{compactNumber(record.tokens.total_tokens)}</strong>
<div className="usage-event-metrics"><span className="tone-input" title={`${t('usage.column.input')}: ${record.tokens.input_tokens.toLocaleString()}`} aria-label={`${t('usage.column.input')}: ${record.tokens.input_tokens}`}><ArrowUp size={11} aria-hidden="true" />{compactNumber(record.tokens.input_tokens)}</span></div>
<div className="usage-event-metrics">
@@ -443,6 +466,8 @@ export function EventsView({
const visibleColumnKeySet = new Set(visibleColumnKeys);
const visibleColumns = EVENT_COLUMNS.filter((column) => visibleColumnKeySet.has(column.key));
const isCompactDefault = visibleColumns.length === DEFAULT_EVENT_VISIBLE_COLUMNS.length
&& DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => visibleColumnKeySet.has(key));
const isCustomized = EVENT_COLUMNS.some((col) => widths[col.key] !== col.defaultWidth);
const noRemarkLabel = t('usage.key.noRemark');
@@ -606,7 +631,7 @@ export function EventsView({
};
return (
<section className="panel usage-events-panel usage-request-log" aria-label={t('usage.events.title')} aria-busy={loading}>
<section className={`panel usage-events-panel usage-request-log${isCompactDefault ? ' usage-events-compact' : ''}`} aria-label={t('usage.events.title')} aria-busy={loading}>
{loading ? <div className="usage-empty" role="status"><Database size={20} aria-hidden="true" /><span>{t('usage.loading')}</span></div> : events.items.length ? (
<div ref={tableWrapRef} className="usage-table-wrap" tabIndex={0} role="region" aria-label={t('usage.events.title')}>
<table
@@ -651,18 +676,26 @@ export function EventsView({
</tr>
</thead>
<tbody>
{events.items.map((record) => (
<tr key={record.row_id}>
{visibleColumns.map((column) => (
<UsageEventCell
key={column.key}
record={record}
columnKey={column.key}
noRemarkLabel={noRemarkLabel}
/>
))}
</tr>
))}
{events.items.map((record, index) => {
const previousRecord = events.items[index - 1];
const showDate = index === 0
|| !previousRecord
|| formatEventDate(previousRecord.timestamp) !== formatEventDate(record.timestamp);
return (
<tr key={record.row_id}>
{visibleColumns.map((column) => (
<UsageEventCell
key={column.key}
record={record}
columnKey={column.key}
noRemarkLabel={noRemarkLabel}
showDate={showDate}
compact={isCompactDefault}
/>
))}
</tr>
);
})}
</tbody>
</table>
</div>
+83 -71
View File
@@ -6,6 +6,7 @@ import {
Activity,
Database,
Gauge,
ListFilter,
Pencil,
RefreshCw,
Trash2,
@@ -247,6 +248,7 @@ export function UsageRecordsPage() {
const [source, setSource] = useState('');
const [apiKeyHash, setApiKeyHash] = useState('');
const [result, setResult] = useState('all');
const [advancedFiltersOpen, setAdvancedFiltersOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [status, setStatus] = useState<CollectorStatus | null>(null);
@@ -445,6 +447,7 @@ export function UsageRecordsPage() {
};
const collectorTone = status?.state === 'error' ? 'error' : status?.state === 'collecting' ? 'success' : '';
const activeAdvancedFilterCount = [model, provider, source, apiKeyHash].filter(Boolean).length;
const hasCurrentSnapshot = loadedScopeKey === scopeKey;
const showInitialLoading =
activeTab !== 'data-management' &&
@@ -467,12 +470,18 @@ export function UsageRecordsPage() {
};
const filterPanel = (
<section className="panel usage-filter-panel">
<div className="usage-filter-row">
<div className="usage-filter-group">
<label className="usage-filter-item">
<span className="sr-only">{t('usage.filter.timeRange')}</span>
<select
<section className="panel usage-filter-panel">
<div className="usage-filter-header">
<span className="usage-filter-title">{t('usage.filter.title')}</span>
{activeAdvancedFilterCount > 0 ? (
<span className="usage-filter-active">{t('usage.filter.active', { count: activeAdvancedFilterCount })}</span>
) : null}
</div>
<div className="usage-filter-row">
<div className="usage-filter-group usage-filter-primary">
<label className="usage-filter-item">
<span className="usage-filter-label">{t('usage.filter.timeRange')}</span>
<select
value={range}
onChange={(event) => {
setRange(event.currentTarget.value as UsageRange);
@@ -491,71 +500,7 @@ export function UsageRecordsPage() {
</label>
<label className="usage-filter-item">
<span className="sr-only">{t('usage.filter.model')}</span>
<select
value={model}
onChange={(event) => changeFilter(setModel, event.currentTarget.value)}
aria-label={t('usage.filter.model')}
>
<option value="">{t('usage.filter.allModels')}</option>
{filterOptions(optionsAnalysis.models).map((item) => (
<option value={item.key} key={item.key}>
{item.label}
</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="sr-only">{t('usage.column.provider')}</span>
<select
value={provider}
onChange={(event) => changeFilter(setProvider, event.currentTarget.value)}
aria-label={t('usage.column.provider')}
>
<option value="">{t('usage.filter.allProviders')}</option>
{filterOptions(optionsAnalysis.providers).map((item) => (
<option value={item.key} key={item.key}>
{item.label}
</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="sr-only">{t('usage.filter.source')}</span>
<select
value={source}
onChange={(event) => changeFilter(setSource, event.currentTarget.value)}
aria-label={t('usage.filter.source')}
>
<option value="">{t('usage.filter.allSources')}</option>
{filterOptions(optionsAnalysis.sources).map((item) => (
<option value={item.key} key={item.key}>
{item.label}
</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="sr-only">{t('apiAccess.field.key')}</span>
<select
value={apiKeyHash}
onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)}
aria-label={t('apiAccess.field.key')}
>
<option value="">{t('usage.filter.allKeys')}</option>
{filterOptions(optionsAnalysis.apiKeys).map((item) => (
<option value={item.key} key={item.key}>
{item.label}
</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="sr-only">{t('usage.filter.result')}</span>
<span className="usage-filter-label">{t('usage.filter.result')}</span>
<select
value={result}
onChange={(event) => changeFilter(setResult, event.currentTarget.value)}
@@ -568,8 +513,75 @@ export function UsageRecordsPage() {
</select>
</label>
</div>
<button
type="button"
className={`usage-filter-toggle${advancedFiltersOpen ? ' is-open' : ''}`}
aria-expanded={advancedFiltersOpen}
onClick={() => setAdvancedFiltersOpen((open) => !open)}
>
<ListFilter size={15} aria-hidden="true" />
<span>{advancedFiltersOpen ? t('usage.filter.hideMore') : t('usage.filter.more')}</span>
{activeAdvancedFilterCount > 0 ? <b>{activeAdvancedFilterCount}</b> : null}
</button>
</div>
{advancedFiltersOpen ? (
<div className="usage-filter-advanced">
<label className="usage-filter-item">
<span className="usage-filter-label">{t('usage.filter.model')}</span>
<select
value={model}
onChange={(event) => changeFilter(setModel, event.currentTarget.value)}
aria-label={t('usage.filter.model')}
>
<option value="">{t('usage.filter.allModels')}</option>
{filterOptions(optionsAnalysis.models).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">{t('usage.column.provider')}</span>
<select
value={provider}
onChange={(event) => changeFilter(setProvider, event.currentTarget.value)}
aria-label={t('usage.column.provider')}
>
<option value="">{t('usage.filter.allProviders')}</option>
{filterOptions(optionsAnalysis.providers).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">{t('usage.filter.source')}</span>
<select
value={source}
onChange={(event) => changeFilter(setSource, event.currentTarget.value)}
aria-label={t('usage.filter.source')}
>
<option value="">{t('usage.filter.allSources')}</option>
{filterOptions(optionsAnalysis.sources).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">{t('apiAccess.field.key')}</span>
<select
value={apiKeyHash}
onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)}
aria-label={t('apiAccess.field.key')}
>
<option value="">{t('usage.filter.allKeys')}</option>
{filterOptions(optionsAnalysis.apiKeys).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
</div>
) : null}
{range === 'custom' ? (
<div className="usage-custom-range">
<input
+170
View File
@@ -97,3 +97,173 @@
.usage-records-page .usage-tab-panel .usage-price-row-actions button:hover {
background: var(--usage-page-hover);
}
/* Usage records: keep the common path quiet, and move diagnostic filters out of the way. */
.usage-records-page .usage-filter-panel {
gap: 8px;
}
.usage-records-page .usage-filter-header {
display: flex;
align-items: center;
gap: 8px;
min-height: 22px;
}
.usage-records-page .usage-filter-title {
color: var(--text-primary);
font-size: var(--font-size-caption);
font-weight: 650;
}
.usage-records-page .usage-filter-active {
padding: 2px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--ui-accent) 10%, transparent);
color: var(--ui-accent-strong);
font-size: 11px;
font-weight: 600;
}
.usage-records-page .usage-filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.usage-records-page .usage-filter-group.usage-filter-primary {
display: flex;
flex: 0 1 auto;
min-width: 0;
flex-wrap: nowrap;
grid-template-columns: none;
}
.usage-records-page .usage-filter-primary .usage-filter-item {
flex: 0 1 230px;
min-width: 170px;
}
.usage-records-page .usage-filter-toggle {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 11px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-primary);
color: var(--text-secondary);
font: inherit;
font-size: var(--font-size-caption);
font-weight: 600;
cursor: pointer;
transition: border-color .15s ease, background .15s ease, color .15s ease;
}
.usage-records-page .usage-filter-toggle:hover,
.usage-records-page .usage-filter-toggle.is-open {
border-color: var(--ui-accent);
background: color-mix(in srgb, var(--ui-accent) 7%, var(--bg-primary));
color: var(--ui-accent-strong);
}
.usage-records-page .usage-filter-toggle b {
display: inline-grid;
min-width: 18px;
height: 18px;
place-items: center;
padding: 0 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--ui-accent) 14%, transparent);
font-size: 10px;
}
.usage-records-page .usage-filter-advanced {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.usage-records-page .usage-filter-advanced .usage-filter-item {
min-width: 0;
}
.usage-records-page .usage-custom-range {
padding-top: 8px;
}
/* The default event view is intentionally compact; secondary diagnostics remain available via tooltips and Columns. */
.usage-records-page .usage-request-log .usage-events-table th,
.usage-records-page .usage-request-log .usage-events-table th .usage-th-content {
height: 38px;
}
.usage-records-page .usage-request-log .usage-events-table td {
height: 60px;
padding: 7px 9px;
}
.usage-records-page .usage-request-log .usage-events-table td strong {
font-size: 12px;
}
.usage-records-page .usage-request-log .usage-events-table td small {
line-height: 1.4;
}
.usage-records-page .usage-request-log .usage-event-metrics {
gap: 2px 7px;
margin-top: 2px;
}
.usage-records-page .usage-request-log.usage-events-compact .usage-cache-write,
.usage-records-page .usage-request-log.usage-events-compact .tone-reasoning {
display: none;
}
.usage-records-page .usage-request-log .usage-events-table tbody tr:nth-child(even) td {
background: color-mix(in srgb, var(--usage-log-surface) 97%, var(--ui-accent));
}
@media (max-width: 760px) {
.usage-records-page .usage-filter-row {
align-items: stretch;
flex-direction: column;
}
.usage-records-page .usage-filter-group.usage-filter-primary {
width: 100%;
flex-wrap: wrap;
}
.usage-records-page .usage-filter-primary .usage-filter-item {
flex: 1 1 0;
min-width: 0;
}
.usage-records-page .usage-filter-toggle {
width: 100%;
}
.usage-records-page .usage-filter-advanced {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.usage-records-page .usage-filter-group.usage-filter-primary,
.usage-records-page .usage-filter-advanced {
grid-template-columns: 1fr;
flex-direction: column;
}
.usage-records-page .usage-filter-advanced {
display: grid;
}
}