Browse Source

feat(my-usage): Statistics Summary with auto-refresh, collapsible logs (#559)

* feat(my-usage): add Statistics Summary with auto-refresh, improve UX

- Add StatisticsSummaryCard with date range picker and 30s auto-refresh
- Add getMyStatsSummary API for fetching stats by time range
- Show expiration date in parentheses for Expired status
- Remove Today's Statistics (functionality merged into Statistics Summary)
- Remove subtitle from header
- Add translations for stats section (en/ru/ja/zh-CN/zh-TW)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* test(my-usage): add unit tests for getMyStatsSummary

- Register getMyStatsSummary endpoint in Actions API with OpenAPI schema
- Add 3 tests: unauthorized 401, basic aggregation with warmup exclusion,
  date range filtering
- Tests verify key/user breakdown separation and data isolation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(ui): logs table and heading style improvements

- Use currency symbol instead of code in logs table
- Improve Russian translations
- Unify heading style in provider-group-info

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* feat(my-usage): collapsible usage logs with header summary

Make Usage Logs section collapsible like Quota card with informative
header showing:
- Last request status with relative time (color-coded)
- Success rate percentage with ✓/✗ indicator
- Active filters count badge
- Auto-refresh indicator

Header adapts for mobile with compact layout. Collapsed by default.

- Replace Card with Radix Collapsible component
- Add metrics calculation (lastLog, successRate, activeFiltersCount)
- Add responsive header (desktop/mobile variants)
- Add logsCollapsible translations for all 5 locales

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(ui): correct RefreshCw animation in usage-logs-section

Animation should trigger on isRefreshing state, not on autoRefreshSeconds config value.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(ui): address CodeRabbit review feedback

- Add optional chaining for CURRENCY_CONFIG to prevent runtime errors
- Use ∞ symbol for unlimited RPM instead of "neverExpires" text
- Add index to React keys in model breakdown to prevent duplicates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* chore: format code (fix-my-usage-43169ad)

---------

Co-authored-by: John Doe <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
miraserver 1 tháng trước cách đây
mục cha
commit
0d32316147

+ 2 - 2
messages/en/dashboard.json

@@ -79,8 +79,8 @@
       "minRetryCountPlaceholder": "Enter minimum retries",
       "apply": "Apply Filter",
       "reset": "Reset",
-      "last7days": "Last 7 Days",
-      "last30days": "Last 30 Days",
+      "last7days": "7d",
+      "last30days": "30d",
       "customRange": "Custom Range",
       "export": "Export",
       "exporting": "Exporting...",

+ 39 - 17
messages/en/myUsage.json

@@ -1,7 +1,7 @@
 {
   "header": {
     "title": "My Usage",
-    "subtitle": "View your quotas and usage logs",
+    "welcome": "Welcome, {name}",
     "logout": "Logout",
     "keyLabel": "Key",
     "userLabel": "User",
@@ -29,21 +29,6 @@
     "unlimited": "Unlimited",
     "empty": "No quota data"
   },
-  "today": {
-    "title": "Today's Usage",
-    "autoRefresh": "Auto refresh every {seconds}s",
-    "refresh": "Refresh",
-    "calls": "Calls",
-    "tokensIn": "Input tokens",
-    "tokensOut": "Output tokens",
-    "cost": "{currency} cost",
-    "modelBreakdown": "By model",
-    "unknownModel": "Unknown model",
-    "billingModel": "Billing model: {model}",
-    "callsShort": "{count} calls",
-    "tokensShort": "In {in} / Out {out}",
-    "noData": "No data today"
-  },
   "logs": {
     "title": "Usage Logs",
     "autoRefresh": "Auto refresh every {seconds}s",
@@ -64,7 +49,7 @@
       "tokens": "Tokens (in/out)",
       "cacheWrite": "Cache Write",
       "cacheRead": "Cache Read",
-      "cost": "{currency} cost",
+      "cost": "Cost",
       "status": "Status",
       "endpoint": "Endpoint"
     },
@@ -79,15 +64,52 @@
     "title": "Expiration",
     "keyExpires": "Key Expires",
     "userExpires": "User Expires",
+    "rpmLimit": "RPM Limit",
     "neverExpires": "Never",
     "expired": "Expired",
     "expiresIn": "in {time}",
     "expiringWarning": "Expiring Soon"
   },
   "providerGroup": {
+    "title": "Provider Groups",
     "keyGroup": "Key Group",
     "userGroup": "User Group",
     "allProviders": "All Providers",
     "inheritedFromUser": "Inherited from User"
+  },
+  "stats": {
+    "title": "Statistics Summary",
+    "autoRefresh": "Auto refresh every {seconds}s",
+    "totalRequests": "Total Requests",
+    "totalCost": "Total Cost",
+    "totalTokens": "Total Tokens",
+    "cacheTokens": "Cache Tokens",
+    "input": "Input",
+    "output": "Output",
+    "write": "Write",
+    "read": "Read",
+    "modelBreakdown": "Model Breakdown",
+    "keyStats": "Key",
+    "userStats": "User",
+    "noData": "No data for selected period",
+    "unknownModel": "Unknown"
+  },
+  "accessRestrictions": {
+    "title": "Access Restrictions",
+    "models": "Models",
+    "clients": "Clients",
+    "noRestrictions": "No restrictions"
+  },
+  "quotaCollapsible": {
+    "title": "Quota Usage",
+    "daily": "Daily",
+    "monthly": "Monthly",
+    "total": "Total"
+  },
+  "logsCollapsible": {
+    "title": "Usage Logs",
+    "lastStatus": "Last: {code} ({time})",
+    "successRate": "{rate}%",
+    "noData": "No data"
   }
 }

+ 2 - 2
messages/ja/dashboard.json

@@ -79,8 +79,8 @@
       "minRetryCountPlaceholder": "回数を入力(0 で制限なし)",
       "apply": "フィルターを適用",
       "reset": "リセット",
-      "last7days": "過去7日",
-      "last30days": "過去30日",
+      "last7days": "7日",
+      "last30days": "30日",
       "customRange": "カスタム範囲",
       "export": "エクスポート",
       "exporting": "エクスポート中...",

+ 39 - 17
messages/ja/myUsage.json

@@ -1,7 +1,7 @@
 {
   "header": {
     "title": "マイ利用状況",
-    "subtitle": "クォータと利用ログを確認",
+    "welcome": "ようこそ、{name}さん",
     "logout": "ログアウト",
     "keyLabel": "キー",
     "userLabel": "ユーザー",
@@ -29,21 +29,6 @@
     "unlimited": "無制限",
     "empty": "クォータ情報がありません"
   },
-  "today": {
-    "title": "本日の利用",
-    "autoRefresh": "{seconds}秒ごとに自動更新",
-    "refresh": "更新",
-    "calls": "リクエスト数",
-    "tokensIn": "入力トークン",
-    "tokensOut": "出力トークン",
-    "cost": "{currency} コスト",
-    "modelBreakdown": "モデル別",
-    "unknownModel": "不明なモデル",
-    "billingModel": "課金モデル: {model}",
-    "callsShort": "{count} 回",
-    "tokensShort": "入力 {in} / 出力 {out}",
-    "noData": "本日のデータはありません"
-  },
   "logs": {
     "title": "利用ログ",
     "autoRefresh": "{seconds}秒ごとに自動更新",
@@ -64,7 +49,7 @@
       "tokens": "トークン (入/出)",
       "cacheWrite": "キャッシュ書込",
       "cacheRead": "キャッシュ読取",
-      "cost": "{currency} コスト",
+      "cost": "コスト",
       "status": "ステータス",
       "endpoint": "エンドポイント"
     },
@@ -79,15 +64,52 @@
     "title": "有効期限",
     "keyExpires": "キーの期限",
     "userExpires": "ユーザーの期限",
+    "rpmLimit": "RPM制限",
     "neverExpires": "期限なし",
     "expired": "期限切れ",
     "expiresIn": "{time} で期限",
     "expiringWarning": "まもなく期限"
   },
   "providerGroup": {
+    "title": "プロバイダーグループ",
     "keyGroup": "キーグループ",
     "userGroup": "ユーザーグループ",
     "allProviders": "すべてのプロバイダー",
     "inheritedFromUser": "ユーザーから継承"
+  },
+  "stats": {
+    "title": "統計サマリー",
+    "autoRefresh": "{seconds}秒ごとに自動更新",
+    "totalRequests": "リクエスト総数",
+    "totalCost": "総コスト",
+    "totalTokens": "トークン総数",
+    "cacheTokens": "キャッシュトークン",
+    "input": "入力",
+    "output": "出力",
+    "write": "書込",
+    "read": "読取",
+    "modelBreakdown": "モデル別",
+    "keyStats": "キー",
+    "userStats": "ユーザー",
+    "noData": "選択期間のデータがありません",
+    "unknownModel": "不明"
+  },
+  "accessRestrictions": {
+    "title": "アクセス制限",
+    "models": "モデル",
+    "clients": "クライアント",
+    "noRestrictions": "制限なし"
+  },
+  "quotaCollapsible": {
+    "title": "クォータ使用状況",
+    "daily": "日次",
+    "monthly": "月次",
+    "total": "合計"
+  },
+  "logsCollapsible": {
+    "title": "使用ログ",
+    "lastStatus": "最終: {code} ({time})",
+    "successRate": "{rate}%",
+    "noData": "データなし"
   }
 }

+ 2 - 2
messages/ru/dashboard.json

@@ -79,8 +79,8 @@
       "minRetryCountPlaceholder": "Введите минимум (0 — без ограничения)",
       "apply": "Применить фильтр",
       "reset": "Сброс",
-      "last7days": "Последние 7 дней",
-      "last30days": "Последние 30 дней",
+      "last7days": "7д",
+      "last30days": "30д",
       "customRange": "Произвольный диапазон",
       "export": "Экспорт",
       "exporting": "Экспорт...",

+ 41 - 19
messages/ru/myUsage.json

@@ -1,7 +1,7 @@
 {
   "header": {
     "title": "Мои расходы",
-    "subtitle": "Лимиты и журналы использования",
+    "welcome": "Добро пожаловать, {name}",
     "logout": "Выйти",
     "keyLabel": "Ключ",
     "userLabel": "Пользователь",
@@ -29,21 +29,6 @@
     "unlimited": "Без лимита",
     "empty": "Нет данных о лимитах"
   },
-  "today": {
-    "title": "Использование сегодня",
-    "autoRefresh": "Автообновление каждые {seconds}с",
-    "refresh": "Обновить",
-    "calls": "Запросы",
-    "tokensIn": "Входные токены",
-    "tokensOut": "Выходные токены",
-    "cost": "Стоимость {currency}",
-    "modelBreakdown": "По моделям",
-    "unknownModel": "Неизвестная модель",
-    "billingModel": "Биллинговая модель: {model}",
-    "callsShort": "{count} раз",
-    "tokensShort": "Вх {in} / Вых {out}",
-    "noData": "Нет данных за сегодня"
-  },
   "logs": {
     "title": "Журнал использования",
     "autoRefresh": "Автообновление каждые {seconds}с",
@@ -62,9 +47,9 @@
       "time": "Время",
       "model": "Модель",
       "tokens": "Токены (вх/вых)",
-      "cacheWrite": "Запись кэша",
-      "cacheRead": "Чтение кэша",
-      "cost": "Стоимость {currency}",
+      "cacheWrite": "Запись кэш",
+      "cacheRead": "Чтение кэш",
+      "cost": "Цена",
       "status": "Статус",
       "endpoint": "API Endpoint"
     },
@@ -79,15 +64,52 @@
     "title": "Срок действия",
     "keyExpires": "Срок ключа",
     "userExpires": "Срок пользователя",
+    "rpmLimit": "Лимит RPM",
     "neverExpires": "Бессрочно",
     "expired": "Истёк",
     "expiresIn": "через {time}",
     "expiringWarning": "Скоро истечёт"
   },
   "providerGroup": {
+    "title": "Группы провайдеров",
     "keyGroup": "Группа ключа",
     "userGroup": "Группа пользователя",
     "allProviders": "Все провайдеры",
     "inheritedFromUser": "Наследовано от пользователя"
+  },
+  "stats": {
+    "title": "Сводка статистики",
+    "autoRefresh": "Автообновление каждые {seconds}с",
+    "totalRequests": "Всего запросов",
+    "totalCost": "Общая стоимость",
+    "totalTokens": "Всего токенов",
+    "cacheTokens": "Токены кэша",
+    "input": "Вход",
+    "output": "Выход",
+    "write": "Запись",
+    "read": "Чтение",
+    "modelBreakdown": "По моделям",
+    "keyStats": "Ключ",
+    "userStats": "Пользователь",
+    "noData": "Нет данных за выбранный период",
+    "unknownModel": "Неизвестно"
+  },
+  "accessRestrictions": {
+    "title": "Ограничения доступа",
+    "models": "Модели",
+    "clients": "Клиенты",
+    "noRestrictions": "Без ограничений"
+  },
+  "quotaCollapsible": {
+    "title": "Использование квоты",
+    "daily": "День",
+    "monthly": "Месяц",
+    "total": "Всего"
+  },
+  "logsCollapsible": {
+    "title": "Журнал запросов",
+    "lastStatus": "Посл.: {code} ({time})",
+    "successRate": "{rate}%",
+    "noData": "Нет данных"
   }
 }

+ 39 - 17
messages/zh-CN/myUsage.json

@@ -1,7 +1,7 @@
 {
   "header": {
     "title": "我的用量",
-    "subtitle": "查看额度与使用记录",
+    "welcome": "欢迎,{name}",
     "logout": "退出登录",
     "keyLabel": "密钥",
     "userLabel": "用户",
@@ -29,21 +29,6 @@
     "unlimited": "不限",
     "empty": "暂无额度数据"
   },
-  "today": {
-    "title": "今日使用",
-    "autoRefresh": "每{seconds}s自动刷新",
-    "refresh": "刷新",
-    "calls": "调用次数",
-    "tokensIn": "输入 Tokens",
-    "tokensOut": "输出 Tokens",
-    "cost": "{currency} 消耗",
-    "modelBreakdown": "按模型",
-    "unknownModel": "未知模型",
-    "billingModel": "计费模型:{model}",
-    "callsShort": "{count} 次",
-    "tokensShort": "入 {in} / 出 {out}",
-    "noData": "今日暂无数据"
-  },
   "logs": {
     "title": "使用日志",
     "autoRefresh": "每 {seconds} 秒自动刷新",
@@ -64,7 +49,7 @@
       "tokens": "Tokens (入/出)",
       "cacheWrite": "缓存写入",
       "cacheRead": "缓存读取",
-      "cost": "{currency} 消耗",
+      "cost": "消耗",
       "status": "状态",
       "endpoint": "API 端点"
     },
@@ -79,15 +64,52 @@
     "title": "过期时间",
     "keyExpires": "密钥过期",
     "userExpires": "用户过期",
+    "rpmLimit": "RPM限制",
     "neverExpires": "永不过期",
     "expired": "已过期",
     "expiresIn": "剩余 {time}",
     "expiringWarning": "即将过期"
   },
   "providerGroup": {
+    "title": "供应商分组",
     "keyGroup": "密钥分组",
     "userGroup": "用户分组",
     "allProviders": "全部供应商",
     "inheritedFromUser": "继承自用户"
+  },
+  "stats": {
+    "title": "统计摘要",
+    "autoRefresh": "每{seconds}秒自动刷新",
+    "totalRequests": "总请求数",
+    "totalCost": "总费用",
+    "totalTokens": "总Token数",
+    "cacheTokens": "缓存Token",
+    "input": "输入",
+    "output": "输出",
+    "write": "写入",
+    "read": "读取",
+    "modelBreakdown": "按模型",
+    "keyStats": "密钥",
+    "userStats": "用户",
+    "noData": "所选时段无数据",
+    "unknownModel": "未知"
+  },
+  "accessRestrictions": {
+    "title": "访问限制",
+    "models": "模型",
+    "clients": "客户端",
+    "noRestrictions": "无限制"
+  },
+  "quotaCollapsible": {
+    "title": "配额使用",
+    "daily": "日",
+    "monthly": "月",
+    "total": "总计"
+  },
+  "logsCollapsible": {
+    "title": "使用日志",
+    "lastStatus": "最近: {code} ({time})",
+    "successRate": "{rate}%",
+    "noData": "无数据"
   }
 }

+ 39 - 17
messages/zh-TW/myUsage.json

@@ -1,7 +1,7 @@
 {
   "header": {
     "title": "我的用量",
-    "subtitle": "查看額度與使用記錄",
+    "welcome": "歡迎,{name}",
     "logout": "登出",
     "keyLabel": "金鑰",
     "userLabel": "使用者",
@@ -29,21 +29,6 @@
     "unlimited": "不限",
     "empty": "暫無額度資料"
   },
-  "today": {
-    "title": "今日使用",
-    "autoRefresh": "每{seconds}s自動刷新",
-    "refresh": "刷新",
-    "calls": "呼叫次數",
-    "tokensIn": "輸入 Tokens",
-    "tokensOut": "輸出 Tokens",
-    "cost": "{currency} 花費",
-    "modelBreakdown": "按模型",
-    "unknownModel": "未知模型",
-    "billingModel": "計費模型:{model}",
-    "callsShort": "{count} 次",
-    "tokensShort": "入 {in} / 出 {out}",
-    "noData": "今日無資料"
-  },
   "logs": {
     "title": "使用紀錄",
     "autoRefresh": "每 {seconds} 秒自動刷新",
@@ -64,7 +49,7 @@
       "tokens": "Tokens (入/出)",
       "cacheWrite": "快取寫入",
       "cacheRead": "快取讀取",
-      "cost": "{currency} 花費",
+      "cost": "花費",
       "status": "狀態",
       "endpoint": "API 端點"
     },
@@ -79,15 +64,52 @@
     "title": "到期時間",
     "keyExpires": "金鑰到期",
     "userExpires": "使用者到期",
+    "rpmLimit": "RPM限制",
     "neverExpires": "永不過期",
     "expired": "已過期",
     "expiresIn": "剩餘 {time}",
     "expiringWarning": "即將到期"
   },
   "providerGroup": {
+    "title": "供應商分組",
     "keyGroup": "金鑰分組",
     "userGroup": "使用者分組",
     "allProviders": "全部供應商",
     "inheritedFromUser": "繼承自使用者"
+  },
+  "stats": {
+    "title": "統計摘要",
+    "autoRefresh": "每{seconds}秒自動刷新",
+    "totalRequests": "總請求數",
+    "totalCost": "總費用",
+    "totalTokens": "總Token數",
+    "cacheTokens": "快取Token",
+    "input": "輸入",
+    "output": "輸出",
+    "write": "寫入",
+    "read": "讀取",
+    "modelBreakdown": "按模型",
+    "keyStats": "金鑰",
+    "userStats": "使用者",
+    "noData": "所選時段無資料",
+    "unknownModel": "未知"
+  },
+  "accessRestrictions": {
+    "title": "存取限制",
+    "models": "模型",
+    "clients": "客戶端",
+    "noRestrictions": "無限制"
+  },
+  "quotaCollapsible": {
+    "title": "配額使用",
+    "daily": "日",
+    "monthly": "月",
+    "total": "總計"
+  },
+  "logsCollapsible": {
+    "title": "使用記錄",
+    "lastStatus": "最近: {code} ({time})",
+    "successRate": "{rate}%",
+    "noData": "無資料"
   }
 }

+ 127 - 0
src/actions/my-usage.ts

@@ -12,11 +12,13 @@ import type { CurrencyCode } from "@/lib/utils";
 import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions";
 import { getSystemSettings } from "@/repository/system-config";
 import {
+  findUsageLogsStats,
   findUsageLogsWithDetails,
   getDistinctEndpointsForKey,
   getDistinctModelsForKey,
   getTotalUsageForKey,
   type UsageLogFilters,
+  type UsageLogSummary,
 } from "@/repository/usage-logs";
 import type { BillingModelSource } from "@/types/system-config";
 import type { ActionResult } from "./types";
@@ -54,6 +56,7 @@ export interface MyUsageQuota {
   userLimitMonthlyUsd: number | null;
   userLimitTotalUsd: number | null;
   userLimitConcurrentSessions: number | null;
+  userRpmLimit: number | null;
   userCurrent5hUsd: number;
   userCurrentDailyUsd: number;
   userCurrentWeeklyUsd: number;
@@ -71,6 +74,9 @@ export interface MyUsageQuota {
   keyName: string;
   keyIsEnabled: boolean;
 
+  userAllowedModels: string[];
+  userAllowedClients: string[];
+
   expiresAt: Date | null;
   dailyResetMode: "fixed" | "rolling";
   dailyResetTime: string;
@@ -246,6 +252,7 @@ export async function getMyQuota(): Promise<ActionResult<MyUsageQuota>> {
       userLimitMonthlyUsd: user.limitMonthlyUsd ?? null,
       userLimitTotalUsd: user.limitTotalUsd ?? null,
       userLimitConcurrentSessions: user.limitConcurrentSessions ?? null,
+      userRpmLimit: user.rpm ?? null,
       userCurrent5hUsd: userCost5h,
       userCurrentDailyUsd: userCostDaily,
       userCurrentWeeklyUsd: userCostWeekly,
@@ -263,6 +270,9 @@ export async function getMyQuota(): Promise<ActionResult<MyUsageQuota>> {
       keyName: key.name,
       keyIsEnabled: key.isEnabled ?? true,
 
+      userAllowedModels: user.allowedModels ?? [],
+      userAllowedClients: user.allowedClients ?? [],
+
       expiresAt: key.expiresAt ?? null,
       dailyResetMode: key.dailyResetMode ?? "fixed",
       dailyResetTime: key.dailyResetTime ?? "00:00",
@@ -488,3 +498,120 @@ async function getUserConcurrentSessions(userId: number): Promise<number> {
     return 0;
   }
 }
+
+export interface MyStatsSummaryFilters {
+  startDate?: string; // "YYYY-MM-DD"
+  endDate?: string; // "YYYY-MM-DD"
+}
+
+export interface ModelBreakdownItem {
+  model: string | null;
+  requests: number;
+  cost: number;
+  inputTokens: number;
+  outputTokens: number;
+}
+
+export interface MyStatsSummary extends UsageLogSummary {
+  keyModelBreakdown: ModelBreakdownItem[];
+  userModelBreakdown: ModelBreakdownItem[];
+  currencyCode: CurrencyCode;
+}
+
+/**
+ * Get aggregated statistics for a date range
+ * Uses findUsageLogsStats for efficient aggregation
+ */
+export async function getMyStatsSummary(
+  filters: MyStatsSummaryFilters = {}
+): Promise<ActionResult<MyStatsSummary>> {
+  try {
+    const session = await getSession({ allowReadOnlyAccess: true });
+    if (!session) return { ok: false, error: "Unauthorized" };
+
+    const settings = await getSystemSettings();
+    const currencyCode = settings.currencyDisplay;
+
+    const startTime = filters.startDate
+      ? new Date(`${filters.startDate}T00:00:00`).getTime()
+      : undefined;
+    const endTime = filters.endDate
+      ? new Date(`${filters.endDate}T23:59:59.999`).getTime()
+      : undefined;
+
+    // Get aggregated stats using existing repository function
+    const stats = await findUsageLogsStats({
+      keyId: session.key.id,
+      startTime,
+      endTime,
+    });
+
+    // Get model breakdown for current key
+    const keyBreakdown = await db
+      .select({
+        model: messageRequest.model,
+        requests: sql<number>`count(*)::int`,
+        cost: sql<string>`COALESCE(sum(${messageRequest.costUsd}), 0)`,
+        inputTokens: sql<number>`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`,
+        outputTokens: sql<number>`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`,
+      })
+      .from(messageRequest)
+      .where(
+        and(
+          eq(messageRequest.key, session.key.key),
+          isNull(messageRequest.deletedAt),
+          EXCLUDE_WARMUP_CONDITION,
+          startTime ? gte(messageRequest.createdAt, new Date(startTime)) : undefined,
+          endTime ? lt(messageRequest.createdAt, new Date(endTime)) : undefined
+        )
+      )
+      .groupBy(messageRequest.model)
+      .orderBy(sql`sum(${messageRequest.costUsd}) DESC`);
+
+    // Get model breakdown for user (all keys)
+    const userBreakdown = await db
+      .select({
+        model: messageRequest.model,
+        requests: sql<number>`count(*)::int`,
+        cost: sql<string>`COALESCE(sum(${messageRequest.costUsd}), 0)`,
+        inputTokens: sql<number>`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`,
+        outputTokens: sql<number>`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`,
+      })
+      .from(messageRequest)
+      .where(
+        and(
+          eq(messageRequest.userId, session.user.id),
+          isNull(messageRequest.deletedAt),
+          EXCLUDE_WARMUP_CONDITION,
+          startTime ? gte(messageRequest.createdAt, new Date(startTime)) : undefined,
+          endTime ? lt(messageRequest.createdAt, new Date(endTime)) : undefined
+        )
+      )
+      .groupBy(messageRequest.model)
+      .orderBy(sql`sum(${messageRequest.costUsd}) DESC`);
+
+    const result: MyStatsSummary = {
+      ...stats,
+      keyModelBreakdown: keyBreakdown.map((row) => ({
+        model: row.model,
+        requests: row.requests,
+        cost: Number(row.cost ?? 0),
+        inputTokens: row.inputTokens,
+        outputTokens: row.outputTokens,
+      })),
+      userModelBreakdown: userBreakdown.map((row) => ({
+        model: row.model,
+        requests: row.requests,
+        cost: Number(row.cost ?? 0),
+        inputTokens: row.inputTokens,
+        outputTokens: row.outputTokens,
+      })),
+      currencyCode,
+    };
+
+    return { ok: true, data: result };
+  } catch (error) {
+    logger.error("[my-usage] getMyStatsSummary failed", error);
+    return { ok: false, error: "Failed to get statistics summary" };
+  }
+}

+ 191 - 0
src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx

@@ -0,0 +1,191 @@
+"use client";
+
+import { AlertTriangle, ChevronDown, Infinity, PieChart } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import type { MyUsageQuota } from "@/actions/my-usage";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import type { CurrencyCode } from "@/lib/utils";
+import { cn } from "@/lib/utils";
+import { calculateUsagePercent } from "@/lib/utils/limit-helpers";
+import { QuotaCards } from "./quota-cards";
+
+interface CollapsibleQuotaCardProps {
+  quota: MyUsageQuota | null;
+  loading?: boolean;
+  currencyCode?: CurrencyCode;
+  keyExpiresAt?: Date | null;
+  userExpiresAt?: Date | null;
+  defaultOpen?: boolean;
+}
+
+export function CollapsibleQuotaCard({
+  quota,
+  loading = false,
+  currencyCode = "USD",
+  keyExpiresAt,
+  userExpiresAt,
+  defaultOpen = false,
+}: CollapsibleQuotaCardProps) {
+  const [isOpen, setIsOpen] = useState(defaultOpen);
+  const t = useTranslations("myUsage.quotaCollapsible");
+
+  // Calculate summary metrics
+  const keyDailyPct = calculateUsagePercent(
+    quota?.keyCurrentDailyUsd ?? 0,
+    quota?.keyLimitDailyUsd ?? null
+  );
+  const userDailyPct = calculateUsagePercent(
+    quota?.userCurrentDailyUsd ?? 0,
+    quota?.userLimitDailyUsd ?? null
+  );
+  const keyMonthlyPct = calculateUsagePercent(
+    quota?.keyCurrentMonthlyUsd ?? 0,
+    quota?.keyLimitMonthlyUsd ?? null
+  );
+  const userMonthlyPct = calculateUsagePercent(
+    quota?.userCurrentMonthlyUsd ?? 0,
+    quota?.userLimitMonthlyUsd ?? null
+  );
+  const keyTotalPct = calculateUsagePercent(
+    quota?.keyCurrentTotalUsd ?? 0,
+    quota?.keyLimitTotalUsd ?? null
+  );
+  const userTotalPct = calculateUsagePercent(
+    quota?.userCurrentTotalUsd ?? 0,
+    quota?.userLimitTotalUsd ?? null
+  );
+
+  // Use user-level percentages for summary display (null = unlimited)
+  const dailyPct = userDailyPct;
+  const monthlyPct = userMonthlyPct;
+  const totalPct = userTotalPct;
+
+  const hasWarning =
+    (dailyPct !== null && dailyPct >= 80) ||
+    (monthlyPct !== null && monthlyPct >= 80) ||
+    (totalPct !== null && totalPct >= 80);
+  const hasDanger =
+    (dailyPct !== null && dailyPct >= 95) ||
+    (monthlyPct !== null && monthlyPct >= 95) ||
+    (totalPct !== null && totalPct >= 95);
+
+  const getPercentColor = (pct: number | null) => {
+    if (pct === null) return "text-muted-foreground";
+    if (pct >= 95) return "text-destructive";
+    if (pct >= 80) return "text-amber-600 dark:text-amber-400";
+    return "text-foreground";
+  };
+
+  return (
+    <Collapsible open={isOpen} onOpenChange={setIsOpen}>
+      <div className="rounded-lg border bg-card">
+        <CollapsibleTrigger asChild>
+          <button
+            type="button"
+            className={cn(
+              "flex w-full items-center justify-between gap-4 p-4 text-left transition-colors hover:bg-muted/50",
+              isOpen && "border-b"
+            )}
+          >
+            <div className="flex items-center gap-3">
+              <div
+                className={cn(
+                  "flex h-8 w-8 items-center justify-center rounded-full",
+                  hasDanger
+                    ? "bg-destructive/10 text-destructive"
+                    : hasWarning
+                      ? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
+                      : "bg-primary/10 text-primary"
+                )}
+              >
+                <PieChart className="h-4 w-4" />
+              </div>
+              <span className="text-sm font-semibold">{t("title")}</span>
+            </div>
+
+            <div className="flex items-center gap-4">
+              {/* Compact metrics */}
+              <div className="hidden items-center gap-4 text-sm sm:flex">
+                <div className="flex items-center gap-1.5">
+                  <span className="text-muted-foreground">{t("daily")}:</span>
+                  {dailyPct === null ? (
+                    <Infinity className="h-4 w-4 text-muted-foreground" />
+                  ) : (
+                    <>
+                      <span className={cn("font-semibold", getPercentColor(dailyPct))}>
+                        {Math.round(dailyPct)}%
+                      </span>
+                      {dailyPct >= 80 && <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />}
+                    </>
+                  )}
+                </div>
+                <span className="text-muted-foreground/50">|</span>
+                <div className="flex items-center gap-1.5">
+                  <span className="text-muted-foreground">{t("monthly")}:</span>
+                  {monthlyPct === null ? (
+                    <Infinity className="h-4 w-4 text-muted-foreground" />
+                  ) : (
+                    <>
+                      <span className={cn("font-semibold", getPercentColor(monthlyPct))}>
+                        {Math.round(monthlyPct)}%
+                      </span>
+                      {monthlyPct >= 80 && <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />}
+                    </>
+                  )}
+                </div>
+                <span className="text-muted-foreground/50">|</span>
+                <div className="flex items-center gap-1.5">
+                  <span className="text-muted-foreground">{t("total")}:</span>
+                  {totalPct === null ? (
+                    <Infinity className="h-4 w-4 text-muted-foreground" />
+                  ) : (
+                    <>
+                      <span className={cn("font-semibold", getPercentColor(totalPct))}>
+                        {Math.round(totalPct)}%
+                      </span>
+                      {totalPct >= 80 && <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />}
+                    </>
+                  )}
+                </div>
+              </div>
+
+              {/* Mobile compact view */}
+              <div className="flex items-center gap-2 text-xs sm:hidden">
+                <span className={cn("font-semibold", getPercentColor(dailyPct))}>
+                  D:{dailyPct === null ? "∞" : `${Math.round(dailyPct)}%`}
+                </span>
+                <span className={cn("font-semibold", getPercentColor(monthlyPct))}>
+                  M:{monthlyPct === null ? "∞" : `${Math.round(monthlyPct)}%`}
+                </span>
+                <span className={cn("font-semibold", getPercentColor(totalPct))}>
+                  T:{totalPct === null ? "∞" : `${Math.round(totalPct)}%`}
+                </span>
+                {hasWarning && <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />}
+              </div>
+
+              <ChevronDown
+                className={cn(
+                  "h-4 w-4 text-muted-foreground transition-transform duration-200",
+                  isOpen && "rotate-180"
+                )}
+              />
+            </div>
+          </button>
+        </CollapsibleTrigger>
+
+        <CollapsibleContent>
+          <div className="p-4">
+            <QuotaCards
+              quota={quota}
+              loading={loading}
+              currencyCode={currencyCode}
+              keyExpiresAt={keyExpiresAt}
+              userExpiresAt={userExpiresAt}
+            />
+          </div>
+        </CollapsibleContent>
+      </div>
+    </Collapsible>
+  );
+}

+ 19 - 3
src/app/[locale]/my-usage/_components/expiration-info.tsx

@@ -9,6 +9,7 @@ import { formatDate, getLocaleDateFormat } from "@/lib/utils/date-format";
 interface ExpirationInfoProps {
   keyExpiresAt: Date | null;
   userExpiresAt: Date | null;
+  userRpmLimit?: number | null;
   className?: string;
 }
 
@@ -17,7 +18,12 @@ const ONE_DAY_IN_SECONDS = 24 * 60 * 60;
 
 type ExpireStatus = "none" | "normal" | "warning" | "danger" | "expired";
 
-export function ExpirationInfo({ keyExpiresAt, userExpiresAt, className }: ExpirationInfoProps) {
+export function ExpirationInfo({
+  keyExpiresAt,
+  userExpiresAt,
+  userRpmLimit,
+  className,
+}: ExpirationInfoProps) {
   const t = useTranslations("myUsage.expiration");
   const locale = useLocale();
 
@@ -67,7 +73,9 @@ export function ExpirationInfo({ keyExpiresAt, userExpiresAt, className }: Expir
         <p className="text-xs font-medium text-muted-foreground">{label}</p>
         <div className="flex items-center gap-2">
           <span className={cn("text-sm font-semibold", statusStyles[status])}>
-            {status === "expired" ? t("expired") : formatExpiry(value)}
+            {status === "expired"
+              ? `${t("expired")} (${formatExpiry(value)})`
+              : formatExpiry(value)}
           </span>
         </div>
         {showCountdown ? (
@@ -81,9 +89,17 @@ export function ExpirationInfo({ keyExpiresAt, userExpiresAt, className }: Expir
   };
 
   return (
-    <div className={cn("grid gap-3 sm:grid-cols-2", className)}>
+    <div className={cn("grid gap-3 sm:grid-cols-3", className)}>
       {renderItem(t("keyExpires"), keyExpiresAt, keyCountdown)}
       {renderItem(t("userExpires"), userExpiresAt, userCountdown)}
+      <div className="space-y-2 rounded-md border border-border/60 bg-card/50 p-3">
+        <p className="text-xs font-medium text-muted-foreground">{t("rpmLimit")}</p>
+        <div className="flex items-center gap-2">
+          <span className="text-sm font-semibold text-foreground">
+            {userRpmLimit != null ? userRpmLimit.toLocaleString() : "∞"}
+          </span>
+        </div>
+      </div>
     </div>
   );
 }

+ 0 - 13
src/app/[locale]/my-usage/_components/loading-states.test.tsx

@@ -3,18 +3,11 @@ import { renderToStaticMarkup } from "react-dom/server";
 import { NextIntlClientProvider } from "next-intl";
 import { describe, expect, test } from "vitest";
 import { QuotaCards } from "./quota-cards";
-import { TodayUsageCard } from "./today-usage-card";
 
 const messages = {
   myUsage: {
     quota: {},
     expiration: {},
-    today: {
-      title: "Today",
-      autoRefresh: "Auto refresh {seconds}s",
-      refresh: "Refresh",
-      modelBreakdown: "Model breakdown",
-    },
   },
   common: {
     loading: "Loading...",
@@ -35,10 +28,4 @@ describe("my-usage loading states", () => {
     expect(html).toContain("Loading...");
     expect(html).toContain('data-slot="skeleton"');
   });
-
-  test("TodayUsageCard renders skeletons and loading label when loading", () => {
-    const html = renderWithIntl(<TodayUsageCard stats={null} loading autoRefreshSeconds={30} />);
-    expect(html).toContain("Loading...");
-    expect(html).toContain('data-slot="skeleton"');
-  });
 });

+ 3 - 2
src/app/[locale]/my-usage/_components/my-usage-header.tsx

@@ -71,7 +71,9 @@ export function MyUsageHeader({
     <div className="flex items-center justify-between gap-4">
       <div className="space-y-2">
         <div className="flex flex-wrap items-center gap-2">
-          <h1 className="text-xl font-semibold leading-tight">{t("title")}</h1>
+          <h1 className="text-xl font-semibold leading-tight">
+            {userName ? t("welcome", { name: userName }) : t("title")}
+          </h1>
           {renderCountdownChip(tExpiration("keyExpires"), keyExpiresAt, keyCountdown)}
           {renderCountdownChip(tExpiration("userExpires"), userExpiresAt, userCountdown)}
         </div>
@@ -85,7 +87,6 @@ export function MyUsageHeader({
             <span>{userName ?? "—"}</span>
           </span>
         </div>
-        <p className="text-sm text-muted-foreground">{t("subtitle")}</p>
       </div>
       <div className="flex items-center gap-2">
         <Button variant="outline" size="sm" onClick={handleLogout} className="gap-2">

+ 52 - 15
src/app/[locale]/my-usage/_components/provider-group-info.tsx

@@ -1,44 +1,81 @@
 "use client";
 
+import { Layers, ShieldCheck } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { Badge } from "@/components/ui/badge";
 import { cn } from "@/lib/utils";
 
 interface ProviderGroupInfoProps {
   keyProviderGroup: string | null;
   userProviderGroup: string | null;
+  userAllowedModels?: string[];
+  userAllowedClients?: string[];
   className?: string;
 }
 
 export function ProviderGroupInfo({
   keyProviderGroup,
   userProviderGroup,
+  userAllowedModels = [],
+  userAllowedClients = [],
   className,
 }: ProviderGroupInfoProps) {
-  const t = useTranslations("myUsage.providerGroup");
+  const tGroup = useTranslations("myUsage.providerGroup");
+  const tRestrictions = useTranslations("myUsage.accessRestrictions");
 
-  const keyDisplay = keyProviderGroup ?? userProviderGroup ?? t("allProviders");
-  const userDisplay = userProviderGroup ?? t("allProviders");
+  const keyDisplay = keyProviderGroup ?? userProviderGroup ?? tGroup("allProviders");
+  const userDisplay = userProviderGroup ?? tGroup("allProviders");
   const inherited = !keyProviderGroup && !!userProviderGroup;
 
-  const badgeClass = "gap-1 rounded-full bg-card/60 text-xs font-medium";
+  const modelsDisplay =
+    userAllowedModels.length > 0 ? userAllowedModels.join(", ") : tRestrictions("noRestrictions");
+  const clientsDisplay =
+    userAllowedClients.length > 0 ? userAllowedClients.join(", ") : tRestrictions("noRestrictions");
 
   return (
     <div
       className={cn(
-        "flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 p-3",
+        "grid grid-cols-1 gap-4 rounded-lg border bg-muted/40 p-4 sm:grid-cols-2",
         className
       )}
     >
-      <Badge variant="outline" className={badgeClass}>
-        <span className="text-muted-foreground">{t("keyGroup")}:</span>
-        <span className="text-foreground">{keyDisplay}</span>
-        {inherited ? <span className="text-muted-foreground">{t("inheritedFromUser")}</span> : null}
-      </Badge>
-      <Badge variant="outline" className={badgeClass}>
-        <span className="text-muted-foreground">{t("userGroup")}:</span>
-        <span className="text-foreground">{userDisplay}</span>
-      </Badge>
+      {/* Provider Groups */}
+      <div className="space-y-2">
+        <div className="flex items-center gap-2 text-base font-semibold">
+          <Layers className="h-4 w-4" />
+          <span>{tGroup("title")}</span>
+        </div>
+        <div className="space-y-1">
+          <div className="flex items-baseline gap-1.5">
+            <span className="text-xs text-muted-foreground">{tGroup("keyGroup")}:</span>
+            <span className="text-sm font-semibold text-foreground">{keyDisplay}</span>
+            {inherited && (
+              <span className="text-xs text-muted-foreground">({tGroup("inheritedFromUser")})</span>
+            )}
+          </div>
+          <div className="flex items-baseline gap-1.5">
+            <span className="text-xs text-muted-foreground">{tGroup("userGroup")}:</span>
+            <span className="text-sm font-semibold text-foreground">{userDisplay}</span>
+          </div>
+        </div>
+      </div>
+
+      {/* Access Restrictions */}
+      <div className="space-y-2">
+        <div className="flex items-center gap-2 text-base font-semibold">
+          <ShieldCheck className="h-4 w-4" />
+          <span>{tRestrictions("title")}</span>
+        </div>
+        <div className="space-y-1">
+          <div className="flex items-baseline gap-1.5">
+            <span className="text-xs text-muted-foreground">{tRestrictions("models")}:</span>
+            <span className="text-sm font-semibold text-foreground">{modelsDisplay}</span>
+          </div>
+          <div className="flex items-baseline gap-1.5">
+            <span className="text-xs text-muted-foreground">{tRestrictions("clients")}:</span>
+            <span className="text-sm font-semibold text-foreground">{clientsDisplay}</span>
+          </div>
+        </div>
+      </div>
     </div>
   );
 }

+ 297 - 0
src/app/[locale]/my-usage/_components/statistics-summary-card.tsx

@@ -0,0 +1,297 @@
+"use client";
+
+import { BarChart3, RefreshCw } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getMyStatsSummary, type MyStatsSummary } from "@/actions/my-usage";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { formatTokenAmount } from "@/lib/utils";
+import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency";
+import { LogsDateRangePicker } from "../../dashboard/logs/_components/logs-date-range-picker";
+
+interface StatisticsSummaryCardProps {
+  className?: string;
+  autoRefreshSeconds?: number;
+}
+
+export function StatisticsSummaryCard({
+  className,
+  autoRefreshSeconds = 30,
+}: StatisticsSummaryCardProps) {
+  const t = useTranslations("myUsage.stats");
+  const [stats, setStats] = useState<MyStatsSummary | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [refreshing, setRefreshing] = useState(false);
+  const [dateRange, setDateRange] = useState<{ startDate?: string; endDate?: string }>(() => {
+    const today = new Date().toISOString().split("T")[0];
+    return { startDate: today, endDate: today };
+  });
+  const intervalRef = useRef<NodeJS.Timeout | null>(null);
+
+  const loadStats = useCallback(async () => {
+    const result = await getMyStatsSummary({
+      startDate: dateRange.startDate,
+      endDate: dateRange.endDate,
+    });
+    if (result.ok) {
+      setStats(result.data);
+    }
+  }, [dateRange.startDate, dateRange.endDate]);
+
+  // Initial load on date range change
+  useEffect(() => {
+    setLoading(true);
+    loadStats().finally(() => setLoading(false));
+  }, [loadStats]);
+
+  // Auto-refresh with visibility change handling
+  useEffect(() => {
+    const POLL_INTERVAL = autoRefreshSeconds * 1000;
+
+    const startPolling = () => {
+      if (intervalRef.current) {
+        clearInterval(intervalRef.current);
+      }
+      intervalRef.current = setInterval(() => {
+        loadStats();
+      }, POLL_INTERVAL);
+    };
+
+    const stopPolling = () => {
+      if (intervalRef.current) {
+        clearInterval(intervalRef.current);
+        intervalRef.current = null;
+      }
+    };
+
+    const handleVisibilityChange = () => {
+      if (document.hidden) {
+        stopPolling();
+      } else {
+        loadStats();
+        startPolling();
+      }
+    };
+
+    startPolling();
+    document.addEventListener("visibilitychange", handleVisibilityChange);
+
+    return () => {
+      stopPolling();
+      document.removeEventListener("visibilitychange", handleVisibilityChange);
+    };
+  }, [loadStats, autoRefreshSeconds]);
+
+  const handleRefresh = useCallback(async () => {
+    setRefreshing(true);
+    await loadStats();
+    setRefreshing(false);
+  }, [loadStats]);
+
+  const handleDateRangeChange = useCallback((range: { startDate?: string; endDate?: string }) => {
+    setDateRange(range);
+  }, []);
+
+  const isLoading = loading || refreshing;
+  const currencyCode = stats?.currencyCode ?? "USD";
+
+  return (
+    <Card className={className}>
+      <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between space-y-0 pb-4">
+        <div>
+          <CardTitle className="text-base font-semibold flex items-center gap-2">
+            <BarChart3 className="h-4 w-4" />
+            {t("title")}
+          </CardTitle>
+          <p className="text-xs text-muted-foreground mt-1">
+            {t("autoRefresh", { seconds: autoRefreshSeconds })}
+          </p>
+        </div>
+        <div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
+          <LogsDateRangePicker
+            startDate={dateRange.startDate}
+            endDate={dateRange.endDate}
+            onDateRangeChange={handleDateRangeChange}
+          />
+          <Button
+            size="sm"
+            variant="outline"
+            className="h-8 gap-2"
+            onClick={handleRefresh}
+            disabled={isLoading}
+          >
+            <RefreshCw className={`h-3.5 w-3.5 ${isLoading ? "animate-spin" : ""}`} />
+          </Button>
+        </div>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        {loading ? (
+          <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
+            {Array.from({ length: 4 }).map((_, index) => (
+              <div key={index} className="rounded-lg border bg-card/50 p-4 space-y-2">
+                <Skeleton className="h-4 w-24" />
+                <Skeleton className="h-8 w-32" />
+              </div>
+            ))}
+          </div>
+        ) : stats ? (
+          <>
+            {/* Main metrics */}
+            <div className="grid gap-4 grid-cols-2 md:grid-cols-4">
+              {/* Total Requests */}
+              <div className="p-4 border rounded-lg">
+                <div className="text-sm text-muted-foreground mb-1">{t("totalRequests")}</div>
+                <div className="text-2xl font-mono font-semibold">
+                  {stats.totalRequests.toLocaleString()}
+                </div>
+              </div>
+
+              {/* Total Cost */}
+              <div className="p-4 border rounded-lg">
+                <div className="text-sm text-muted-foreground mb-1">{t("totalCost")}</div>
+                <div className="text-2xl font-mono font-semibold">
+                  {formatCurrency(stats.totalCost, currencyCode)}
+                </div>
+              </div>
+
+              {/* Total Tokens */}
+              <div className="p-4 border rounded-lg">
+                <div className="text-sm text-muted-foreground mb-1">{t("totalTokens")}</div>
+                <div className="text-2xl font-mono font-semibold">
+                  {formatTokenAmount(stats.totalTokens)}
+                </div>
+                <div className="mt-2 text-xs text-muted-foreground space-y-1">
+                  <div className="flex justify-between">
+                    <span>{t("input")}:</span>
+                    <span className="font-mono">{formatTokenAmount(stats.totalInputTokens)}</span>
+                  </div>
+                  <div className="flex justify-between">
+                    <span>{t("output")}:</span>
+                    <span className="font-mono">{formatTokenAmount(stats.totalOutputTokens)}</span>
+                  </div>
+                </div>
+              </div>
+
+              {/* Cache Tokens */}
+              <div className="p-4 border rounded-lg">
+                <div className="text-sm text-muted-foreground mb-1">{t("cacheTokens")}</div>
+                <div className="text-2xl font-mono font-semibold">
+                  {formatTokenAmount(stats.totalCacheCreationTokens + stats.totalCacheReadTokens)}
+                </div>
+                <div className="mt-2 text-xs text-muted-foreground space-y-1">
+                  <div className="flex justify-between">
+                    <span>{t("write")}:</span>
+                    <span className="font-mono">
+                      {formatTokenAmount(stats.totalCacheCreationTokens)}
+                    </span>
+                  </div>
+                  <div className="flex justify-between">
+                    <span>{t("read")}:</span>
+                    <span className="font-mono">
+                      {formatTokenAmount(stats.totalCacheReadTokens)}
+                    </span>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <Separator />
+
+            {/* Model Breakdown - 2 columns: Key | User */}
+            <div className="space-y-3">
+              <p className="text-sm font-medium text-muted-foreground">{t("modelBreakdown")}</p>
+              <div className="grid gap-4 md:grid-cols-2">
+                {/* Key Stats */}
+                <div className="space-y-2">
+                  <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
+                    {t("keyStats")}
+                  </p>
+                  {stats.keyModelBreakdown.length > 0 ? (
+                    <div className="space-y-2">
+                      {stats.keyModelBreakdown.map((item, index) => (
+                        <ModelBreakdownRow
+                          key={`key-${item.model ?? "unknown"}-${index}`}
+                          model={item.model}
+                          requests={item.requests}
+                          cost={item.cost}
+                          inputTokens={item.inputTokens}
+                          outputTokens={item.outputTokens}
+                          currencyCode={currencyCode}
+                        />
+                      ))}
+                    </div>
+                  ) : (
+                    <p className="text-sm text-muted-foreground py-2">{t("noData")}</p>
+                  )}
+                </div>
+
+                {/* User Stats */}
+                <div className="space-y-2">
+                  <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
+                    {t("userStats")}
+                  </p>
+                  {stats.userModelBreakdown.length > 0 ? (
+                    <div className="space-y-2">
+                      {stats.userModelBreakdown.map((item, index) => (
+                        <ModelBreakdownRow
+                          key={`user-${item.model ?? "unknown"}-${index}`}
+                          model={item.model}
+                          requests={item.requests}
+                          cost={item.cost}
+                          inputTokens={item.inputTokens}
+                          outputTokens={item.outputTokens}
+                          currencyCode={currencyCode}
+                        />
+                      ))}
+                    </div>
+                  ) : (
+                    <p className="text-sm text-muted-foreground py-2">{t("noData")}</p>
+                  )}
+                </div>
+              </div>
+            </div>
+          </>
+        ) : (
+          <p className="text-sm text-muted-foreground text-center py-4">{t("noData")}</p>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+interface ModelBreakdownRowProps {
+  model: string | null;
+  requests: number;
+  cost: number;
+  inputTokens: number;
+  outputTokens: number;
+  currencyCode: CurrencyCode;
+}
+
+function ModelBreakdownRow({
+  model,
+  requests,
+  cost,
+  inputTokens,
+  outputTokens,
+  currencyCode,
+}: ModelBreakdownRowProps) {
+  const t = useTranslations("myUsage.stats");
+
+  return (
+    <div className="flex items-center justify-between rounded-md border px-3 py-2">
+      <div className="flex flex-col text-sm min-w-0">
+        <span className="font-medium text-foreground truncate">{model || t("unknownModel")}</span>
+        <span className="text-xs text-muted-foreground">
+          {requests.toLocaleString()} req · {formatTokenAmount(inputTokens + outputTokens)} tok
+        </span>
+      </div>
+      <div className="text-right text-sm font-semibold text-foreground whitespace-nowrap ml-2">
+        {formatCurrency(cost, currencyCode)}
+      </div>
+    </div>
+  );
+}

+ 0 - 131
src/app/[locale]/my-usage/_components/today-usage-card.tsx

@@ -1,131 +0,0 @@
-"use client";
-
-import { Loader2, RefreshCw } from "lucide-react";
-import { useTranslations } from "next-intl";
-import type { MyTodayStats } from "@/actions/my-usage";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Separator } from "@/components/ui/separator";
-import { Skeleton } from "@/components/ui/skeleton";
-
-interface TodayUsageCardProps {
-  stats: MyTodayStats | null;
-  loading?: boolean;
-  refreshing?: boolean;
-  onRefresh?: () => void;
-  autoRefreshSeconds?: number;
-}
-
-export function TodayUsageCard({
-  stats,
-  loading = false,
-  refreshing = false,
-  onRefresh,
-  autoRefreshSeconds = 30,
-}: TodayUsageCardProps) {
-  const t = useTranslations("myUsage.today");
-  const tCommon = useTranslations("common");
-  const isInitialLoading = loading && !stats;
-  const isButtonLoading = loading || refreshing;
-
-  return (
-    <Card>
-      <CardHeader className="flex flex-row items-center justify-between space-y-0">
-        <CardTitle className="text-base font-semibold">{t("title")}</CardTitle>
-        <div className="flex items-center gap-2 text-xs text-muted-foreground">
-          <span>{t("autoRefresh", { seconds: autoRefreshSeconds })}</span>
-          <Button
-            size="sm"
-            variant="outline"
-            className="h-8 gap-2"
-            onClick={onRefresh}
-            disabled={isButtonLoading}
-          >
-            <RefreshCw className={`h-3.5 w-3.5 ${isButtonLoading ? "animate-spin" : ""}`} />
-            {t("refresh")}
-          </Button>
-        </div>
-      </CardHeader>
-      <CardContent className="space-y-4">
-        {isInitialLoading ? (
-          <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
-            {Array.from({ length: 4 }).map((_, index) => (
-              <div key={index} className="rounded-lg border bg-card/50 px-3 py-2 space-y-2">
-                <Skeleton className="h-3 w-16" />
-                <Skeleton className="h-5 w-20" />
-              </div>
-            ))}
-          </div>
-        ) : (
-          <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
-            <Metric label={t("calls")} value={stats?.calls ?? 0} />
-            <Metric label={t("tokensIn")} value={stats?.inputTokens ?? 0} />
-            <Metric label={t("tokensOut")} value={stats?.outputTokens ?? 0} />
-            <Metric
-              label={t("cost", { currency: stats?.currencyCode ?? "USD" })}
-              value={Number(stats?.costUsd ?? 0).toFixed(4)}
-            />
-          </div>
-        )}
-
-        <Separator />
-
-        <div className="space-y-2">
-          <p className="text-sm font-medium text-muted-foreground">{t("modelBreakdown")}</p>
-          {isInitialLoading ? (
-            <div className="space-y-2">
-              {Array.from({ length: 3 }).map((_, index) => (
-                <div key={index} className="rounded-md border px-3 py-2 space-y-2">
-                  <Skeleton className="h-4 w-32" />
-                  <Skeleton className="h-3 w-48" />
-                </div>
-              ))}
-              <div className="flex items-center gap-2 text-xs text-muted-foreground">
-                <Loader2 className="h-3 w-3 animate-spin" />
-                <span>{tCommon("loading")}</span>
-              </div>
-            </div>
-          ) : stats && stats.modelBreakdown.length > 0 ? (
-            <div className="space-y-2">
-              {stats.modelBreakdown.map((item) => (
-                <div
-                  key={`${item.model ?? "unknown"}-${item.billingModel ?? "billing"}`}
-                  className="flex items-center justify-between rounded-md border px-3 py-2"
-                >
-                  <div className="flex flex-col text-sm">
-                    <span className="font-medium text-foreground">
-                      {item.model || t("unknownModel")}
-                    </span>
-                    {item.billingModel && item.billingModel !== item.model ? (
-                      <span className="text-xs text-muted-foreground">
-                        {t("billingModel", { model: item.billingModel })}
-                      </span>
-                    ) : null}
-                  </div>
-                  <div className="text-right text-xs text-muted-foreground space-y-0.5">
-                    <div>{t("callsShort", { count: item.calls })}</div>
-                    <div>{t("tokensShort", { in: item.inputTokens, out: item.outputTokens })}</div>
-                    <div className="font-semibold text-foreground">
-                      {`${stats.currencyCode || "USD"} ${Number(item.costUsd ?? 0).toFixed(4)}`}
-                    </div>
-                  </div>
-                </div>
-              ))}
-            </div>
-          ) : (
-            <p className="text-sm text-muted-foreground">{t("noData")}</p>
-          )}
-        </div>
-      </CardContent>
-    </Card>
-  );
-}
-
-function Metric({ label, value }: { label: string; value: number | string }) {
-  return (
-    <div className="rounded-lg border bg-card/50 px-3 py-2">
-      <p className="text-xs text-muted-foreground">{label}</p>
-      <p className="text-lg font-semibold leading-tight">{value}</p>
-    </div>
-  );
-}

+ 330 - 152
src/app/[locale]/my-usage/_components/usage-logs-section.tsx

@@ -1,8 +1,8 @@
 "use client";
 
-import { Loader2 } from "lucide-react";
+import { Check, ChevronDown, Filter, Loader2, RefreshCw, ScrollText, X } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useRef, useState, useTransition } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
 import {
   getMyAvailableEndpoints,
   getMyAvailableModels,
@@ -10,8 +10,9 @@ import {
   type MyUsageLogsResult,
 } from "@/actions/my-usage";
 import { LogsDateRangePicker } from "@/app/[locale]/dashboard/logs/_components/logs-date-range-picker";
+import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import {
@@ -21,12 +22,14 @@ import {
   SelectTrigger,
   SelectValue,
 } from "@/components/ui/select";
+import { cn } from "@/lib/utils";
 import { UsageLogsTable } from "./usage-logs-table";
 
 interface UsageLogsSectionProps {
   initialData?: MyUsageLogsResult | null;
   loading?: boolean;
   autoRefreshSeconds?: number;
+  defaultOpen?: boolean;
 }
 
 interface Filters {
@@ -44,10 +47,13 @@ export function UsageLogsSection({
   initialData = null,
   loading = false,
   autoRefreshSeconds,
+  defaultOpen = false,
 }: UsageLogsSectionProps) {
   const t = useTranslations("myUsage.logs");
+  const tCollapsible = useTranslations("myUsage.logsCollapsible");
   const tDashboard = useTranslations("dashboard");
   const tCommon = useTranslations("common");
+  const [isOpen, setIsOpen] = useState(defaultOpen);
   const [models, setModels] = useState<string[]>([]);
   const [endpoints, setEndpoints] = useState<string[]>([]);
   const [isModelsLoading, setIsModelsLoading] = useState(true);
@@ -58,6 +64,51 @@ export function UsageLogsSection({
   const [isPending, startTransition] = useTransition();
   const [error, setError] = useState<string | null>(null);
 
+  // Compute metrics for header summary
+  const logs = data?.logs ?? [];
+
+  const activeFiltersCount = useMemo(() => {
+    let count = 0;
+    if (appliedFilters.startDate || appliedFilters.endDate) count++;
+    if (appliedFilters.model) count++;
+    if (appliedFilters.endpoint) count++;
+    if (appliedFilters.statusCode || appliedFilters.excludeStatusCode200) count++;
+    if (appliedFilters.minRetryCount) count++;
+    return count;
+  }, [appliedFilters]);
+
+  const lastLog = useMemo(() => {
+    if (!logs || logs.length === 0) return null;
+    return logs[0]; // First log is the most recent (sorted by createdAt DESC)
+  }, [logs]);
+
+  const lastStatusText = useMemo(() => {
+    if (!lastLog?.createdAt) return null;
+    const now = new Date();
+    const logTime = new Date(lastLog.createdAt);
+    const diffMs = now.getTime() - logTime.getTime();
+    const diffMins = Math.floor(diffMs / 60000);
+
+    if (diffMins < 1) return "now";
+    if (diffMins < 60) return `${diffMins}m ago`;
+    const diffHours = Math.floor(diffMins / 60);
+    if (diffHours < 24) return `${diffHours}h ago`;
+    return `${Math.floor(diffHours / 24)}d ago`;
+  }, [lastLog]);
+
+  const successRate = useMemo(() => {
+    if (!logs || logs.length === 0) return null;
+    const successCount = logs.filter((log) => log.statusCode && log.statusCode < 400).length;
+    return Math.round((successCount / logs.length) * 100);
+  }, [logs]);
+
+  const lastStatusColor = useMemo(() => {
+    if (!lastLog?.statusCode) return "";
+    if (lastLog.statusCode === 200) return "text-green-600 dark:text-green-400";
+    if (lastLog.statusCode >= 400) return "text-red-600 dark:text-red-400";
+    return "";
+  }, [lastLog]);
+
   // Sync initialData from parent when it becomes available
   // (useState only uses initialData on first mount, not on subsequent updates)
   useEffect(() => {
@@ -187,159 +238,286 @@ export function UsageLogsSection({
   const isRefreshing = isPending && Boolean(data);
 
   return (
-    <Card>
-      <CardHeader className="flex flex-row items-center justify-between">
-        <CardTitle>{t("title")}</CardTitle>
-        {autoRefreshSeconds ? (
-          <span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
-            {t("autoRefresh", { seconds: autoRefreshSeconds })}
-          </span>
-        ) : null}
-      </CardHeader>
-      <CardContent className="space-y-4">
-        <div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-12">
-          <div className="space-y-1.5 lg:col-span-4">
-            <Label>
-              {t("filters.startDate")} / {t("filters.endDate")}
-            </Label>
-            <LogsDateRangePicker
-              startDate={draftFilters.startDate}
-              endDate={draftFilters.endDate}
-              onDateRangeChange={handleDateRangeChange}
-            />
-          </div>
-          <div className="space-y-1.5 lg:col-span-4">
-            <Label>{t("filters.model")}</Label>
-            <Select
-              value={draftFilters.model ?? "__all__"}
-              onValueChange={(value) =>
-                handleFilterChange({
-                  model: value === "__all__" ? undefined : value,
-                })
-              }
-              disabled={isModelsLoading}
-            >
-              <SelectTrigger>
-                <SelectValue
-                  placeholder={isModelsLoading ? tCommon("loading") : t("filters.allModels")}
+    <Collapsible open={isOpen} onOpenChange={setIsOpen}>
+      <div className="rounded-lg border bg-card">
+        <CollapsibleTrigger asChild>
+          <button
+            className={cn(
+              "flex w-full items-center justify-between gap-4 p-4",
+              "hover:bg-muted/50 transition-colors",
+              isOpen && "border-b"
+            )}
+          >
+            {/* Icon + Title */}
+            <div className="flex items-center gap-3">
+              <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
+                <ScrollText className="h-4 w-4" />
+              </div>
+              <span className="text-sm font-semibold">{tCollapsible("title")}</span>
+            </div>
+
+            {/* Header Summary */}
+            <div className="flex items-center gap-3">
+              {/* Desktop Summary */}
+              <div className="hidden sm:flex items-center gap-2 text-sm">
+                {/* Last Status */}
+                {lastLog ? (
+                  <span className={cn("font-mono", lastStatusColor)}>
+                    {tCollapsible("lastStatus", {
+                      code: lastLog.statusCode ?? "-",
+                      time: lastStatusText ?? "-",
+                    })}
+                  </span>
+                ) : (
+                  <span className="text-muted-foreground">{tCollapsible("noData")}</span>
+                )}
+
+                <span className="text-muted-foreground">|</span>
+
+                {/* Success Rate */}
+                {successRate !== null ? (
+                  <span
+                    className={cn(
+                      "flex items-center gap-1",
+                      successRate >= 80
+                        ? "text-green-600 dark:text-green-400"
+                        : "text-red-600 dark:text-red-400"
+                    )}
+                  >
+                    {successRate >= 80 ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
+                    {tCollapsible("successRate", { rate: successRate })}
+                  </span>
+                ) : null}
+
+                {/* Active Filters Badge */}
+                {activeFiltersCount > 0 && (
+                  <>
+                    <span className="text-muted-foreground">|</span>
+                    <Badge variant="secondary" className="h-5 px-1.5 text-xs">
+                      <Filter className="h-3 w-3 mr-1" />
+                      {activeFiltersCount}
+                    </Badge>
+                  </>
+                )}
+
+                {/* Auto-refresh */}
+                {autoRefreshSeconds && (
+                  <>
+                    <span className="text-muted-foreground">|</span>
+                    <RefreshCw className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")} />
+                    <span className="text-xs text-muted-foreground">{autoRefreshSeconds}s</span>
+                  </>
+                )}
+              </div>
+
+              {/* Mobile Summary */}
+              <div className="flex items-center gap-1.5 text-xs sm:hidden">
+                {/* Last Status - compact */}
+                {lastLog ? (
+                  <span className={cn("font-mono", lastStatusColor)}>
+                    {lastLog.statusCode ?? "-"} ({lastStatusText ?? "-"})
+                  </span>
+                ) : (
+                  <span className="text-muted-foreground">{tCollapsible("noData")}</span>
+                )}
+
+                <span className="text-muted-foreground">|</span>
+
+                {/* Success Rate - compact */}
+                {successRate !== null ? (
+                  <span
+                    className={cn(
+                      "flex items-center gap-0.5",
+                      successRate >= 80 ? "text-green-600" : "text-red-600"
+                    )}
+                  >
+                    {successRate >= 80 ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
+                    {successRate}%
+                  </span>
+                ) : null}
+
+                {/* Filters + Refresh */}
+                {activeFiltersCount > 0 && (
+                  <>
+                    <span className="text-muted-foreground">|</span>
+                    <Badge variant="secondary" className="h-4 px-1 text-[10px]">
+                      {activeFiltersCount}
+                    </Badge>
+                  </>
+                )}
+                {autoRefreshSeconds && (
+                  <>
+                    <span className="text-muted-foreground">|</span>
+                    <RefreshCw className={cn("h-3 w-3", isRefreshing && "animate-spin")} />
+                  </>
+                )}
+              </div>
+
+              {/* Chevron */}
+              <ChevronDown
+                className={cn(
+                  "h-4 w-4 text-muted-foreground transition-transform duration-200",
+                  isOpen && "rotate-180"
+                )}
+              />
+            </div>
+          </button>
+        </CollapsibleTrigger>
+
+        <CollapsibleContent>
+          <div className="p-4 space-y-4">
+            <div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-12">
+              <div className="space-y-1.5 lg:col-span-4">
+                <Label>
+                  {t("filters.startDate")} / {t("filters.endDate")}
+                </Label>
+                <LogsDateRangePicker
+                  startDate={draftFilters.startDate}
+                  endDate={draftFilters.endDate}
+                  onDateRangeChange={handleDateRangeChange}
                 />
-              </SelectTrigger>
-              <SelectContent>
-                <SelectItem value="__all__">{t("filters.allModels")}</SelectItem>
-                {models.map((model) => (
-                  <SelectItem key={model} value={model}>
-                    {model}
-                  </SelectItem>
-                ))}
-              </SelectContent>
-            </Select>
-          </div>
-          <div className="space-y-1.5 lg:col-span-4">
-            <Label>{tDashboard("logs.filters.endpoint")}</Label>
-            <Select
-              value={draftFilters.endpoint ?? "__all__"}
-              onValueChange={(value) =>
-                handleFilterChange({
-                  endpoint: value === "__all__" ? undefined : value,
-                })
-              }
-              disabled={isEndpointsLoading}
-            >
-              <SelectTrigger>
-                <SelectValue
-                  placeholder={
-                    isEndpointsLoading
-                      ? tCommon("loading")
-                      : tDashboard("logs.filters.allEndpoints")
+              </div>
+              <div className="space-y-1.5 lg:col-span-4">
+                <Label>{t("filters.model")}</Label>
+                <Select
+                  value={draftFilters.model ?? "__all__"}
+                  onValueChange={(value) =>
+                    handleFilterChange({
+                      model: value === "__all__" ? undefined : value,
+                    })
+                  }
+                  disabled={isModelsLoading}
+                >
+                  <SelectTrigger>
+                    <SelectValue
+                      placeholder={isModelsLoading ? tCommon("loading") : t("filters.allModels")}
+                    />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="__all__">{t("filters.allModels")}</SelectItem>
+                    {models.map((model) => (
+                      <SelectItem key={model} value={model}>
+                        {model}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+              <div className="space-y-1.5 lg:col-span-4">
+                <Label>{tDashboard("logs.filters.endpoint")}</Label>
+                <Select
+                  value={draftFilters.endpoint ?? "__all__"}
+                  onValueChange={(value) =>
+                    handleFilterChange({
+                      endpoint: value === "__all__" ? undefined : value,
+                    })
+                  }
+                  disabled={isEndpointsLoading}
+                >
+                  <SelectTrigger>
+                    <SelectValue
+                      placeholder={
+                        isEndpointsLoading
+                          ? tCommon("loading")
+                          : tDashboard("logs.filters.allEndpoints")
+                      }
+                    />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="__all__">
+                      {tDashboard("logs.filters.allEndpoints")}
+                    </SelectItem>
+                    {endpoints.map((endpoint) => (
+                      <SelectItem key={endpoint} value={endpoint}>
+                        {endpoint}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+              <div className="space-y-1.5 lg:col-span-4">
+                <Label>{t("filters.status")}</Label>
+                <Select
+                  value={
+                    draftFilters.excludeStatusCode200
+                      ? "!200"
+                      : (draftFilters.statusCode?.toString() ?? "__all__")
+                  }
+                  onValueChange={(value) =>
+                    handleFilterChange({
+                      statusCode:
+                        value === "__all__" || value === "!200" ? undefined : parseInt(value, 10),
+                      excludeStatusCode200: value === "!200",
+                    })
+                  }
+                >
+                  <SelectTrigger>
+                    <SelectValue placeholder={t("filters.allStatus")} />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="__all__">{t("filters.allStatus")}</SelectItem>
+                    <SelectItem value="!200">{tDashboard("logs.statusCodes.not200")}</SelectItem>
+                    <SelectItem value="200">200</SelectItem>
+                    <SelectItem value="400">400</SelectItem>
+                    <SelectItem value="401">401</SelectItem>
+                    <SelectItem value="429">429</SelectItem>
+                    <SelectItem value="500">500</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              <div className="space-y-1.5 lg:col-span-4">
+                <Label>{tDashboard("logs.filters.minRetryCount")}</Label>
+                <Input
+                  type="number"
+                  min={0}
+                  inputMode="numeric"
+                  value={draftFilters.minRetryCount?.toString() ?? ""}
+                  placeholder={tDashboard("logs.filters.minRetryCountPlaceholder")}
+                  onChange={(e) =>
+                    handleFilterChange({
+                      minRetryCount: e.target.value ? parseInt(e.target.value, 10) : undefined,
+                    })
                   }
                 />
-              </SelectTrigger>
-              <SelectContent>
-                <SelectItem value="__all__">{tDashboard("logs.filters.allEndpoints")}</SelectItem>
-                {endpoints.map((endpoint) => (
-                  <SelectItem key={endpoint} value={endpoint}>
-                    {endpoint}
-                  </SelectItem>
-                ))}
-              </SelectContent>
-            </Select>
-          </div>
-          <div className="space-y-1.5 lg:col-span-4">
-            <Label>{t("filters.status")}</Label>
-            <Select
-              value={
-                draftFilters.excludeStatusCode200
-                  ? "!200"
-                  : (draftFilters.statusCode?.toString() ?? "__all__")
-              }
-              onValueChange={(value) =>
-                handleFilterChange({
-                  statusCode:
-                    value === "__all__" || value === "!200" ? undefined : parseInt(value, 10),
-                  excludeStatusCode200: value === "!200",
-                })
-              }
-            >
-              <SelectTrigger>
-                <SelectValue placeholder={t("filters.allStatus")} />
-              </SelectTrigger>
-              <SelectContent>
-                <SelectItem value="__all__">{t("filters.allStatus")}</SelectItem>
-                <SelectItem value="!200">{tDashboard("logs.statusCodes.not200")}</SelectItem>
-                <SelectItem value="200">200</SelectItem>
-                <SelectItem value="400">400</SelectItem>
-                <SelectItem value="401">401</SelectItem>
-                <SelectItem value="429">429</SelectItem>
-                <SelectItem value="500">500</SelectItem>
-              </SelectContent>
-            </Select>
-          </div>
-          <div className="space-y-1.5 lg:col-span-4">
-            <Label>{tDashboard("logs.filters.minRetryCount")}</Label>
-            <Input
-              type="number"
-              min={0}
-              inputMode="numeric"
-              value={draftFilters.minRetryCount?.toString() ?? ""}
-              placeholder={tDashboard("logs.filters.minRetryCountPlaceholder")}
-              onChange={(e) =>
-                handleFilterChange({
-                  minRetryCount: e.target.value ? parseInt(e.target.value, 10) : undefined,
-                })
-              }
+              </div>
+            </div>
+
+            <div className="flex flex-wrap items-center gap-2">
+              <Button size="sm" onClick={handleApply} disabled={isPending || loading}>
+                {t("filters.apply")}
+              </Button>
+              <Button
+                size="sm"
+                variant="outline"
+                onClick={handleReset}
+                disabled={isPending || loading}
+              >
+                {t("filters.reset")}
+              </Button>
+            </div>
+
+            {error ? <p className="text-sm text-destructive">{error}</p> : null}
+
+            {isRefreshing ? (
+              <div className="flex items-center gap-2 text-xs text-muted-foreground">
+                <Loader2 className="h-3 w-3 animate-spin" />
+                <span>{tCommon("loading")}</span>
+              </div>
+            ) : null}
+
+            <UsageLogsTable
+              logs={data?.logs ?? []}
+              total={data?.total ?? 0}
+              page={appliedFilters.page ?? 1}
+              pageSize={data?.pageSize ?? 20}
+              onPageChange={handlePageChange}
+              currencyCode={data?.currencyCode}
+              loading={isInitialLoading}
+              loadingLabel={tCommon("loading")}
             />
           </div>
-        </div>
-
-        <div className="flex flex-wrap items-center gap-2">
-          <Button size="sm" onClick={handleApply} disabled={isPending || loading}>
-            {t("filters.apply")}
-          </Button>
-          <Button size="sm" variant="outline" onClick={handleReset} disabled={isPending || loading}>
-            {t("filters.reset")}
-          </Button>
-        </div>
-
-        {error ? <p className="text-sm text-destructive">{error}</p> : null}
-
-        {isRefreshing ? (
-          <div className="flex items-center gap-2 text-xs text-muted-foreground">
-            <Loader2 className="h-3 w-3 animate-spin" />
-            <span>{tCommon("loading")}</span>
-          </div>
-        ) : null}
-
-        <UsageLogsTable
-          logs={data?.logs ?? []}
-          total={data?.total ?? 0}
-          page={appliedFilters.page ?? 1}
-          pageSize={data?.pageSize ?? 20}
-          onPageChange={handlePageChange}
-          currencyCode={data?.currencyCode}
-          loading={isInitialLoading}
-          loadingLabel={tCommon("loading")}
-        />
-      </CardContent>
-    </Card>
+        </CollapsibleContent>
+      </div>
+    </Collapsible>
   );
 }

+ 9 - 5
src/app/[locale]/my-usage/_components/usage-logs-table.tsx

@@ -13,7 +13,7 @@ import {
   TableRow,
 } from "@/components/ui/table";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import type { CurrencyCode } from "@/lib/utils";
+import { CURRENCY_CONFIG, type CurrencyCode } from "@/lib/utils";
 
 interface UsageLogsTableProps {
   logs: MyUsageLogEntry[];
@@ -55,9 +55,7 @@ export function UsageLogsTable({
               <TableHead className="text-right">{t("table.tokens")}</TableHead>
               <TableHead className="text-right">{t("table.cacheWrite")}</TableHead>
               <TableHead className="text-right">{t("table.cacheRead")}</TableHead>
-              <TableHead className="text-right">
-                {t("table.cost", { currency: currencyCode })}
-              </TableHead>
+              <TableHead className="text-right">{t("table.cost")}</TableHead>
               <TableHead>{t("table.status")}</TableHead>
             </TableRow>
           </TableHeader>
@@ -129,11 +127,17 @@ export function UsageLogsTable({
                     {formatTokenAmount(log.cacheReadInputTokens)}
                   </TableCell>
                   <TableCell className="text-right text-sm font-mono">
-                    {currencyCode} {Number(log.cost ?? 0).toFixed(4)}
+                    {CURRENCY_CONFIG[currencyCode]?.symbol ?? currencyCode}
+                    {Number(log.cost ?? 0).toFixed(4)}
                   </TableCell>
                   <TableCell>
                     <Badge
                       variant={log.statusCode && log.statusCode >= 400 ? "destructive" : "outline"}
+                      className={
+                        log.statusCode === 200
+                          ? "border-green-500 text-green-600 dark:text-green-400"
+                          : undefined
+                      }
                     >
                       {log.statusCode ?? "-"}
                     </Badge>

+ 18 - 80
src/app/[locale]/my-usage/page.tsx

@@ -1,38 +1,30 @@
 "use client";
 
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
 import {
   getMyQuota,
-  getMyTodayStats,
   getMyUsageLogs,
-  type MyTodayStats,
   type MyUsageLogsResult,
   type MyUsageQuota,
 } from "@/actions/my-usage";
 import { useRouter } from "@/i18n/routing";
+import { CollapsibleQuotaCard } from "./_components/collapsible-quota-card";
 import { ExpirationInfo } from "./_components/expiration-info";
 import { MyUsageHeader } from "./_components/my-usage-header";
 import { ProviderGroupInfo } from "./_components/provider-group-info";
-import { QuotaCards } from "./_components/quota-cards";
-import { TodayUsageCard } from "./_components/today-usage-card";
+import { StatisticsSummaryCard } from "./_components/statistics-summary-card";
 import { UsageLogsSection } from "./_components/usage-logs-section";
 
 export default function MyUsagePage() {
   const router = useRouter();
 
   const [quota, setQuota] = useState<MyUsageQuota | null>(null);
-  const [todayStats, setTodayStats] = useState<MyTodayStats | null>(null);
   const [logsData, setLogsData] = useState<MyUsageLogsResult | null>(null);
   const [isQuotaLoading, setIsQuotaLoading] = useState(true);
-  const [isStatsLoading, setIsStatsLoading] = useState(true);
   const [isLogsLoading, setIsLogsLoading] = useState(true);
-  const [isStatsRefreshing, setIsStatsRefreshing] = useState(false);
-
-  const intervalRef = useRef<NodeJS.Timeout | null>(null);
 
   const loadInitial = useCallback(() => {
     setIsQuotaLoading(true);
-    setIsStatsLoading(true);
     setIsLogsLoading(true);
 
     void getMyQuota()
@@ -41,12 +33,6 @@ export default function MyUsagePage() {
       })
       .finally(() => setIsQuotaLoading(false));
 
-    void getMyTodayStats()
-      .then((statsResult) => {
-        if (statsResult.ok) setTodayStats(statsResult.data);
-      })
-      .finally(() => setIsStatsLoading(false));
-
     void getMyUsageLogs({ page: 1 })
       .then((logsResult) => {
         if (logsResult.ok) setLogsData(logsResult.data ?? null);
@@ -54,57 +40,10 @@ export default function MyUsagePage() {
       .finally(() => setIsLogsLoading(false));
   }, []);
 
-  const refreshToday = useCallback(async () => {
-    setIsStatsRefreshing(true);
-    const stats = await getMyTodayStats();
-    if (stats.ok) setTodayStats(stats.data);
-    setIsStatsRefreshing(false);
-  }, []);
-
   useEffect(() => {
     loadInitial();
   }, [loadInitial]);
 
-  useEffect(() => {
-    const POLL_INTERVAL = 30000;
-
-    const startPolling = () => {
-      if (intervalRef.current) {
-        clearInterval(intervalRef.current);
-      }
-
-      intervalRef.current = setInterval(() => {
-        refreshToday();
-        // Note: logs polling is handled internally by UsageLogsSection
-        // to preserve pagination state
-      }, POLL_INTERVAL);
-    };
-
-    const stopPolling = () => {
-      if (intervalRef.current) {
-        clearInterval(intervalRef.current);
-        intervalRef.current = null;
-      }
-    };
-
-    const handleVisibilityChange = () => {
-      if (document.hidden) {
-        stopPolling();
-      } else {
-        refreshToday();
-        startPolling();
-      }
-    };
-
-    startPolling();
-    document.addEventListener("visibilitychange", handleVisibilityChange);
-
-    return () => {
-      stopPolling();
-      document.removeEventListener("visibilitychange", handleVisibilityChange);
-    };
-  }, [refreshToday]);
-
   const handleLogout = async () => {
     await fetch("/api/auth/logout", { method: "POST" });
     router.push("/login");
@@ -113,7 +52,6 @@ export default function MyUsagePage() {
 
   const keyExpiresAt = quota?.expiresAt ?? null;
   const userExpiresAt = quota?.userExpiresAt ?? null;
-  const currencyCode = todayStats?.currencyCode ?? "USD";
 
   return (
     <div className="space-y-6">
@@ -125,32 +63,32 @@ export default function MyUsagePage() {
         userExpiresAt={userExpiresAt}
       />
 
-      <QuotaCards
-        quota={quota}
-        loading={isQuotaLoading}
-        currencyCode={currencyCode}
-        keyExpiresAt={keyExpiresAt}
-        userExpiresAt={userExpiresAt}
-      />
-
+      {/* Provider Group and Expiration info */}
       {quota ? (
         <div className="space-y-3">
-          <ExpirationInfo keyExpiresAt={keyExpiresAt} userExpiresAt={userExpiresAt} />
           <ProviderGroupInfo
             keyProviderGroup={quota.keyProviderGroup}
             userProviderGroup={quota.userProviderGroup}
+            userAllowedModels={quota.userAllowedModels}
+            userAllowedClients={quota.userAllowedClients}
+          />
+          <ExpirationInfo
+            keyExpiresAt={keyExpiresAt}
+            userExpiresAt={userExpiresAt}
+            userRpmLimit={quota.userRpmLimit}
           />
         </div>
       ) : null}
 
-      <TodayUsageCard
-        stats={todayStats}
-        loading={isStatsLoading}
-        refreshing={isStatsRefreshing}
-        onRefresh={refreshToday}
-        autoRefreshSeconds={30}
+      <CollapsibleQuotaCard
+        quota={quota}
+        loading={isQuotaLoading}
+        keyExpiresAt={keyExpiresAt}
+        userExpiresAt={userExpiresAt}
       />
 
+      <StatisticsSummaryCard />
+
       <UsageLogsSection initialData={logsData} loading={isLogsLoading} autoRefreshSeconds={30} />
     </div>
   );

+ 48 - 0
src/app/api/actions/[...route]/route.ts

@@ -804,6 +804,54 @@ const { route: getMyAvailableEndpointsRoute, handler: getMyAvailableEndpointsHan
   });
 app.openapi(getMyAvailableEndpointsRoute, getMyAvailableEndpointsHandler);
 
+const { route: getMyStatsSummaryRoute, handler: getMyStatsSummaryHandler } = createActionRoute(
+  "my-usage",
+  "getMyStatsSummary",
+  myUsageActions.getMyStatsSummary,
+  {
+    requestSchema: z.object({
+      startDate: z.string().optional().describe("开始日期(YYYY-MM-DD,可为空)"),
+      endDate: z.string().optional().describe("结束日期(YYYY-MM-DD,可为空)"),
+    }),
+    responseSchema: z.object({
+      totalRequests: z.number().describe("总请求数"),
+      totalCost: z.number().describe("总费用"),
+      totalInputTokens: z.number().describe("总输入 Token"),
+      totalOutputTokens: z.number().describe("总输出 Token"),
+      totalCacheCreationTokens: z.number().describe("缓存创建 Token"),
+      totalCacheReadTokens: z.number().describe("缓存读取 Token"),
+      keyModelBreakdown: z
+        .array(
+          z.object({
+            model: z.string().nullable(),
+            requests: z.number(),
+            cost: z.number(),
+            inputTokens: z.number(),
+            outputTokens: z.number(),
+          })
+        )
+        .describe("当前 Key 的模型分布"),
+      userModelBreakdown: z
+        .array(
+          z.object({
+            model: z.string().nullable(),
+            requests: z.number(),
+            cost: z.number(),
+            inputTokens: z.number(),
+            outputTokens: z.number(),
+          })
+        )
+        .describe("用户所有 Key 的模型分布"),
+      currencyCode: z.string().describe("货币代码"),
+    }),
+    description: "获取指定日期范围内的聚合统计(仅返回自己的数据)",
+    summary: "获取我的统计摘要",
+    tags: ["统计分析"],
+    allowReadOnlyAccess: true,
+  }
+);
+app.openapi(getMyStatsSummaryRoute, getMyStatsSummaryHandler);
+
 // ==================== 概览数据 ====================
 
 const { route: getOverviewDataRoute, handler: getOverviewDataHandler } = createActionRoute(

+ 248 - 0
tests/api/my-usage-readonly.test.ts

@@ -433,4 +433,252 @@ describe("my-usage API:只读 Key 自助查询", () => {
       expect.arrayContaining(["/v1/messages", "/v1/chat/completions"])
     );
   });
+
+  test("getMyStatsSummary:未认证返回 401", async () => {
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/my-usage/getMyStatsSummary",
+      body: {},
+    });
+
+    expect(response.status).toBe(401);
+    expect(json).toMatchObject({ ok: false });
+  });
+
+  test("getMyStatsSummary:基础聚合统计,排除 warmup,区分 key/user breakdown", async () => {
+    const unique = `stats-summary-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+
+    // 创建两个用户,每个用户一个 key
+    const userA = await createTestUser(`Test ${unique}-A`);
+    createdUserIds.push(userA.id);
+    const keyA = await createTestKey({
+      userId: userA.id,
+      key: `test-stats-key-A-${unique}`,
+      name: `stats-A-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(keyA.id);
+
+    // 用户 A 的第二个 key(用于测试 user breakdown 聚合多个 key)
+    const keyA2 = await createTestKey({
+      userId: userA.id,
+      key: `test-stats-key-A2-${unique}`,
+      name: `stats-A2-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(keyA2.id);
+
+    const userB = await createTestUser(`Test ${unique}-B`);
+    createdUserIds.push(userB.id);
+    const keyB = await createTestKey({
+      userId: userB.id,
+      key: `test-stats-key-B-${unique}`,
+      name: `stats-B-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(keyB.id);
+
+    const now = new Date();
+    const today = now.toISOString().split("T")[0];
+    const t0 = new Date(now.getTime() - 60 * 1000);
+
+    // Key A 的请求
+    const a1 = await createMessage({
+      userId: userA.id,
+      key: keyA.key,
+      model: "claude-3-opus",
+      endpoint: "/v1/messages",
+      costUsd: "0.1000",
+      inputTokens: 500,
+      outputTokens: 200,
+      createdAt: t0,
+    });
+    const a2 = await createMessage({
+      userId: userA.id,
+      key: keyA.key,
+      model: "claude-3-sonnet",
+      endpoint: "/v1/messages",
+      costUsd: "0.0500",
+      inputTokens: 300,
+      outputTokens: 100,
+      createdAt: t0,
+    });
+
+    // Key A 的 warmup(应被排除)
+    const warmupA = await createMessage({
+      userId: userA.id,
+      key: keyA.key,
+      model: "claude-3-opus",
+      endpoint: "/v1/messages",
+      costUsd: "0.9999",
+      inputTokens: 9999,
+      outputTokens: 9999,
+      blockedBy: "warmup",
+      createdAt: t0,
+    });
+
+    // Key A2 的请求(同一用户的不同 key,应在 userBreakdown 中聚合)
+    const a2_1 = await createMessage({
+      userId: userA.id,
+      key: keyA2.key,
+      model: "claude-3-opus",
+      endpoint: "/v1/messages",
+      costUsd: "0.0800",
+      inputTokens: 400,
+      outputTokens: 150,
+      createdAt: t0,
+    });
+
+    // Key B 的请求(不应泄漏给 A)
+    const b1 = await createMessage({
+      userId: userB.id,
+      key: keyB.key,
+      model: "gpt-4",
+      endpoint: "/v1/chat/completions",
+      costUsd: "0.5000",
+      inputTokens: 2000,
+      outputTokens: 1000,
+      createdAt: t0,
+    });
+
+    createdMessageIds.push(a1, a2, warmupA, a2_1, b1);
+
+    currentAuthToken = keyA.key;
+
+    // 调用 getMyStatsSummary
+    const { response, json } = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/my-usage/getMyStatsSummary",
+      authToken: keyA.key,
+      body: { startDate: today, endDate: today },
+    });
+
+    expect(response.status).toBe(200);
+    expect(json).toMatchObject({ ok: true });
+
+    const data = (json as any).data as {
+      totalRequests: number;
+      totalCost: number;
+      totalInputTokens: number;
+      totalOutputTokens: number;
+      keyModelBreakdown: Array<{
+        model: string | null;
+        requests: number;
+        cost: number;
+        inputTokens: number;
+        outputTokens: number;
+      }>;
+      userModelBreakdown: Array<{
+        model: string | null;
+        requests: number;
+        cost: number;
+        inputTokens: number;
+        outputTokens: number;
+      }>;
+      currencyCode: string;
+    };
+
+    // 验证总计(仅 key A,排除 warmup)
+    expect(data.totalRequests).toBe(2); // a1, a2
+    expect(data.totalInputTokens).toBe(800); // 500 + 300
+    expect(data.totalOutputTokens).toBe(300); // 200 + 100
+    expect(data.totalCost).toBeCloseTo(0.15, 4); // 0.1 + 0.05
+
+    // 验证 keyModelBreakdown(仅当前 key A 的数据)
+    const keyBreakdownMap = new Map(data.keyModelBreakdown.map((r) => [r.model, r]));
+    expect(keyBreakdownMap.get("claude-3-opus")?.requests).toBe(1);
+    expect(keyBreakdownMap.get("claude-3-opus")?.cost).toBeCloseTo(0.1, 4);
+    expect(keyBreakdownMap.get("claude-3-sonnet")?.requests).toBe(1);
+    expect(keyBreakdownMap.get("claude-3-sonnet")?.cost).toBeCloseTo(0.05, 4);
+    // warmup 不应出现(blockedBy = 'warmup')
+    // 其他用户的模型不应出现
+    expect(keyBreakdownMap.has("gpt-4")).toBe(false);
+
+    // 验证 userModelBreakdown(用户 A 的所有 key,包括 keyA2)
+    const userBreakdownMap = new Map(data.userModelBreakdown.map((r) => [r.model, r]));
+    // claude-3-opus: a1 (0.1) + a2_1 (0.08) = 0.18, requests = 2
+    expect(userBreakdownMap.get("claude-3-opus")?.requests).toBe(2);
+    expect(userBreakdownMap.get("claude-3-opus")?.cost).toBeCloseTo(0.18, 4);
+    // claude-3-sonnet: a2 only
+    expect(userBreakdownMap.get("claude-3-sonnet")?.requests).toBe(1);
+    // 其他用户的模型不应出现
+    expect(userBreakdownMap.has("gpt-4")).toBe(false);
+
+    // 验证 currencyCode 存在
+    expect(data.currencyCode).toBeDefined();
+  });
+
+  test("getMyStatsSummary:日期范围过滤", async () => {
+    const unique = `stats-date-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+
+    const user = await createTestUser(`Test ${unique}`);
+    createdUserIds.push(user.id);
+    const key = await createTestKey({
+      userId: user.id,
+      key: `test-stats-date-key-${unique}`,
+      name: `stats-date-${unique}`,
+      canLoginWebUi: false,
+    });
+    createdKeyIds.push(key.id);
+
+    const today = new Date();
+    const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
+    const todayStr = today.toISOString().split("T")[0];
+    const yesterdayStr = yesterday.toISOString().split("T")[0];
+
+    // 昨天的请求
+    const m1 = await createMessage({
+      userId: user.id,
+      key: key.key,
+      model: "old-model",
+      endpoint: "/v1/messages",
+      costUsd: "0.0100",
+      inputTokens: 100,
+      outputTokens: 50,
+      createdAt: yesterday,
+    });
+
+    // 今天的请求
+    const m2 = await createMessage({
+      userId: user.id,
+      key: key.key,
+      model: "new-model",
+      endpoint: "/v1/messages",
+      costUsd: "0.0200",
+      inputTokens: 200,
+      outputTokens: 100,
+      createdAt: today,
+    });
+
+    createdMessageIds.push(m1, m2);
+
+    currentAuthToken = key.key;
+
+    // 仅查询今天
+    const todayOnly = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/my-usage/getMyStatsSummary",
+      authToken: key.key,
+      body: { startDate: todayStr, endDate: todayStr },
+    });
+
+    expect(todayOnly.response.status).toBe(200);
+    const todayData = (todayOnly.json as any).data;
+    expect(todayData.totalRequests).toBe(1);
+    expect(todayData.keyModelBreakdown.length).toBe(1);
+    expect(todayData.keyModelBreakdown[0].model).toBe("new-model");
+
+    // 查询昨天到今天
+    const bothDays = await callActionsRoute({
+      method: "POST",
+      pathname: "/api/actions/my-usage/getMyStatsSummary",
+      authToken: key.key,
+      body: { startDate: yesterdayStr, endDate: todayStr },
+    });
+
+    expect(bothDays.response.status).toBe(200);
+    const bothData = (bothDays.json as any).data;
+    expect(bothData.totalRequests).toBe(2);
+    expect(bothData.keyModelBreakdown.length).toBe(2);
+  });
 });