diff --git a/.gitignore b/.gitignore index eb4139039..e927f4fd6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ /coverage-my-usage /coverage-proxy-guard-pipeline /coverage-thinking-signature-rectifier +/coverage-logs-sessionid-time-filter +/coverage-usage-logs-sessionid-search # next.js /.next/ @@ -84,3 +86,6 @@ TRANSLATIONS_CHECKLIST.md # docs-site submodule build artifacts docs-site/.next/ docs-site/node_modules/ + +# local scratch +tmp/ diff --git a/README.en.md b/README.en.md index 7b4039cea..25899b3d2 100644 --- a/README.en.md +++ b/README.en.md @@ -44,8 +44,7 @@ Cubence offers special discount coupons for users of CCH: when purchasing with t -💎 Special Offer: Privnode is an affordable AI API aggregation platform providing one-stop relay services for mainstream models like Claude and Codex, serving developers and teams with reliable stability and competitive pricing.
-Use code WITHCCH for 15% offVisit Now +Privnode is an affordable AI API aggregation platform providing one-stop relay services for mainstream models like Claude and Codex, serving developers and teams with reliable stability and competitive pricing. → Visit Now diff --git a/README.md b/README.md index 370acad13..70a9d9048 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,7 @@ Cubence 为 CCH 的使用用户提供了特别的优惠折扣:在购买时使 -💎 特别优惠Privnode 是一家平价的 AI API 聚合平台,为 Claude、Codex 等主流模型提供一站式中转服务,以良好的稳定性和较高的性价比,服务于开发者与团队的实际需求。
-使用优惠码 WITHCCH 可获得 15% 折扣立即访问 +Privnode 是一家平价的 AI API 聚合平台,为 Claude、Codex 等主流模型提供一站式中转服务,以良好的稳定性和较高的性价比,服务于开发者与团队的实际需求。 → 立即访问 diff --git a/deploy/Dockerfile b/deploy/Dockerfile index d067fad6f..2c6596280 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -32,6 +32,7 @@ FROM node:trixie-slim AS runner ENV NODE_ENV=production ENV PORT=3000 ENV HOST=0.0.0.0 +ENV HOSTNAME=0.0.0.0 WORKDIR /app # 安装 PostgreSQL 18 客户端工具(用于数据库备份/恢复功能)和 curl(用于健康检查) diff --git a/docker-compose.yaml b/docker-compose.yaml index 5f71ae816..ba4e98241 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,8 @@ +name: ${COMPOSE_PROJECT_NAME:-claude-code-hub} + services: postgres: image: postgres:18 - container_name: claude-code-hub-db restart: unless-stopped # 不对外暴露数据库端口,仅允许容器内部网络访问 # 如需调试,可取消注释下行(仅绑定本机): @@ -31,7 +32,6 @@ services: redis: image: redis:7-alpine - container_name: claude-code-hub-redis restart: unless-stopped volumes: # 持久化 Redis 数据到本地 ./data/redis 目录 @@ -47,7 +47,6 @@ services: app: image: ghcr.io/ding113/claude-code-hub:latest - container_name: claude-code-hub-app depends_on: postgres: condition: service_healthy diff --git a/docs/dashboard-logs-callchain.md b/docs/dashboard-logs-callchain.md new file mode 100644 index 000000000..da37381b7 --- /dev/null +++ b/docs/dashboard-logs-callchain.md @@ -0,0 +1,119 @@ +# Dashboard Logs(Usage Logs)入口与调用链盘点 + +本文用于锁定 `/dashboard/logs` 的真实入口与关键调用链边界,避免后续需求实现与验收口径跑偏。 + +## 1) 路由入口(Server) + +- 路由:`/dashboard/logs` +- 入口页面:`src/app/[locale]/dashboard/logs/page.tsx` + - 登录态校验:`getSession()`(未登录重定向到 `/login`) + - 数据区块入口:`UsageLogsDataSection`(`src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx`) + +## 2) 真实渲染链路(Client) + +当前页面实际使用“虚拟列表”链路: + +- 虚拟列表入口:`UsageLogsViewVirtualized`(`src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx`) + - URL -> filters 解析:`parseLogsUrlFilters()`(`src/app/[locale]/dashboard/logs/_utils/logs-query.ts`) + - filters -> URL 回填:`buildLogsUrlQuery()`(`src/app/[locale]/dashboard/logs/_utils/logs-query.ts`) + - Filters 面板:`UsageLogsFilters` + - 列表:`VirtualizedLogsTable` + - 统计面板:`UsageLogsStatsPanel` + +仓库内仍存在“非虚拟表格”实现(目前不被路由引用,属于历史/备用路径): + +- `UsageLogsView`(`src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx`) +- `UsageLogsTable`(`src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx`) + +## 3) 过滤器 / URL / 时间语义 + +- URL 参数解析/构建(统一入口):`src/app/[locale]/dashboard/logs/_utils/logs-query.ts` + - `sessionId`:字符串(trim 后空值不落盘) + - `startTime/endTime`:毫秒时间戳 +- 秒级时间工具:`src/app/[locale]/dashboard/logs/_utils/time-range.ts` + - UI endTime 为“包含式”秒;对后端转换为“排他上界”(`endExclusive = endInclusive + 1s`) + - 后端查询语义保持:`created_at >= startTime` 且 `created_at < endTime` + +## 4) 数据获取链路(Actions -> Repository) + +### 列表(无限滚动) + +- Action:`src/actions/usage-logs.ts#getUsageLogsBatch` +- Repo:`src/repository/usage-logs.ts#findUsageLogsBatch` + +### 统计(折叠面板按需加载) + +- Action:`src/actions/usage-logs.ts#getUsageLogsStats` +- Repo:`src/repository/usage-logs.ts#findUsageLogsStats` + +### 导出 CSV + +- Action:`src/actions/usage-logs.ts#exportUsageLogs` +- Repo:`src/repository/usage-logs.ts#findUsageLogsWithDetails` +- CSV 生成:`src/actions/usage-logs.ts#generateCsv` + +### Session ID 联想(候选查询) + +- Action:`src/actions/usage-logs.ts#getUsageLogSessionIdSuggestions` +- Repo:`src/repository/usage-logs.ts#findUsageLogSessionIdSuggestions` + +#### 匹配语义与边界(2026-01-15 更新) + +- **前端约束**: + - 最小长度:`SESSION_ID_SUGGESTION_MIN_LEN`(`src/lib/constants/usage-logs.constants.ts`) + - 最大长度截断:`SESSION_ID_SUGGESTION_MAX_LEN`(`src/actions/usage-logs.ts` 内对输入 trim 后截断) + - 每次返回数量:`SESSION_ID_SUGGESTION_LIMIT` +- **后端匹配**: + - 语义:仅支持「字面量前缀匹配」(`term%`),不再支持包含匹配(`%term%`) + - 安全:输入中的 `%` / `_` / `\\` 会被统一转义,避免被当作 LIKE 通配符 + - SQL(核心条件):`session_id LIKE '%' ESCAPE '\\'` + - 转义实现:`src/repository/_shared/like.ts#escapeLike` +- **行为变更示例**: + - 之前:输入 `abc` 可能命中 `xxxabcxxx`(包含匹配) + - 之后:仅命中 `abc...`(前缀匹配) + - 之前:输入 `%` / `_` 可主动触发通配 + - 之后:`%` / `_` 按字面量处理(例如输入 `%a` 只匹配以 `%a` 开头的 session_id) + +#### 索引与迁移(前缀匹配性能) + +- 已有索引:`idx_message_request_session_id`(`message_request.session_id`,partial: `deleted_at IS NULL`) +- 新增索引(前缀匹配):`idx_message_request_session_id_prefix` + - opclass:`varchar_pattern_ops` + - partial:`deleted_at IS NULL AND (blocked_by IS NULL OR blocked_by <> 'warmup')` + - 迁移文件:`drizzle/0055_neat_stepford_cuckoos.sql` + +## 5) 本需求相关影响面(文件/符号清单) + +**前端(logs 页面内聚)**: + +- URL/过滤器:`src/app/[locale]/dashboard/logs/_utils/logs-query.ts` +- 秒级时间:`src/app/[locale]/dashboard/logs/_utils/time-range.ts` +- 过滤器 UI:`src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx` +- 虚拟列表:`src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx` +- 非虚拟表格:`src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx` +- 统计面板:`src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx` + +**后端(Actions/Repo)**: + +- Actions:`src/actions/usage-logs.ts` + - `getUsageLogsBatch/getUsageLogsStats/exportUsageLogs/getUsageLogSessionIdSuggestions` +- Repo:`src/repository/usage-logs.ts` + - `findUsageLogsBatch/findUsageLogsWithDetails/findUsageLogsStats/findUsageLogSessionIdSuggestions` + +**i18n(用户可见文案)**: + +- `messages/*/dashboard.json`(`dashboard.logs.filters.*` / `dashboard.logs.columns.*`) + +## 6) 边界说明(在范围内 / 不在范围内) + +在范围内(本次需求直接相关): + +- `sessionId` 精确筛选 + URL 回填 + UI 展示(列/复制/tooltip) +- 秒级时间输入与 `endExclusive` 语义对齐(`< endTime`) +- Session ID 联想(最小成本:minLen + debounce + limit) + +不在范围内(需另开 issue/评审确认后再做): + +- 针对联想查询的索引/物化/离线表(优化类工程) +- 大规模改动数据库 schema 或重建索引策略(例如 CONCURRENTLY/离线重建) +- Logs 页面其它过滤项语义调整(非本需求验收口径) diff --git a/docs/error-session-id-guide.md b/docs/error-session-id-guide.md new file mode 100644 index 000000000..97f1c9ce8 --- /dev/null +++ b/docs/error-session-id-guide.md @@ -0,0 +1,26 @@ +# Error Session ID Guide + +When reporting an API error, include the CCH session id so maintainers can locate the exact request. + +## Where to find it + +1. **Preferred**: response header `x-cch-session-id` +2. **Fallback**: `error.message` suffix `cch_session_id: ` + +If the response does not include a session id, the server could not determine it for that request. + +## Example (curl) + +```bash +curl -i -sS \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"model":"gpt-4.1-mini","messages":[{"role":"user","content":"hi"}]}' \\ + http://localhost:13500/v1/chat/completions +``` + +In the response: + +- Check header: `x-cch-session-id: ...` +- If missing, check JSON: `{"error":{"message":"... (cch_session_id: ...)"} }` + diff --git a/drizzle/0055_neat_stepford_cuckoos.sql b/drizzle/0055_neat_stepford_cuckoos.sql new file mode 100644 index 000000000..c91fed32a --- /dev/null +++ b/drizzle/0055_neat_stepford_cuckoos.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS "idx_message_request_session_id_prefix" ON "message_request" USING btree ("session_id" varchar_pattern_ops) WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup'); diff --git a/drizzle/meta/0055_snapshot.json b/drizzle/meta/0055_snapshot.json new file mode 100644 index 000000000..939e558ff --- /dev/null +++ b/drizzle/meta/0055_snapshot.json @@ -0,0 +1,2404 @@ +{ + "id": "b40c930a-4001-4403-90b9-652a5878893c", + "prevId": "36887729-08df-4af3-98fe-d4fa87c7c5c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'Asia/Shanghai'" + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 106e43116..a4148b04e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1768240715707, "tag": "0054_tidy_winter_soldier", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1768443427816, + "tag": "0055_neat_stepford_cuckoos", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/auth.json b/messages/en/auth.json index cd9ea7f96..460d311d0 100644 --- a/messages/en/auth.json +++ b/messages/en/auth.json @@ -30,7 +30,7 @@ "solutionTitle": "Solutions:", "useHttps": "Use HTTPS to access the system (recommended)", "disableSecureCookies": "Set ENABLE_SECURE_COOKIES=false in .env (reduces security)", - "privacyNote": "Please use your API Key to log in to the Claude Code Hub admin panel" + "privacyNote": "Please use your API Key to log in to the admin panel" }, "errors": { "loginFailed": "Login failed", @@ -38,6 +38,9 @@ "invalidToken": "Invalid authentication token", "tokenRequired": "Authentication token is required", "sessionExpired": "Your session has expired, please log in again", - "unauthorized": "Unauthorized, please log in first" + "unauthorized": "Unauthorized, please log in first", + "apiKeyRequired": "Please enter API Key", + "apiKeyInvalidOrExpired": "API Key is invalid or expired", + "serverError": "Login failed, please try again later" } } diff --git a/messages/en/bigScreen.json b/messages/en/bigScreen.json index a83b29fd8..a6eeadd56 100644 --- a/messages/en/bigScreen.json +++ b/messages/en/bigScreen.json @@ -1,4 +1,6 @@ { + "pageTitle": "Realtime big screen - Claude Code Hub", + "pageDescription": "Claude Code Hub realtime monitoring big screen", "title": "CLAUDE CODE HUB", "subtitle": "REALTIME DATA MONITOR", "metrics": { diff --git a/messages/en/common.json b/messages/en/common.json index cb72d523b..c12dd0b66 100644 --- a/messages/en/common.json +++ b/messages/en/common.json @@ -48,5 +48,15 @@ "theme": "Theme", "light": "Light", "dark": "Dark", - "system": "System" + "system": "System", + "relativeTimeShort": { + "now": "now", + "secondsAgo": "{count}s ago", + "minutesAgo": "{count}m ago", + "hoursAgo": "{count}h ago", + "daysAgo": "{count}d ago", + "weeksAgo": "{count}w ago", + "monthsAgo": "{count}mo ago", + "yearsAgo": "{count}y ago" + } } diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 91ef7163a..34d5fc29b 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "User", "provider": "Provider", + "sessionId": "Session ID", "searchUser": "Search users...", "searchProvider": "Search providers...", + "searchSessionId": "Search session IDs...", "noUserFound": "No matching users found", "noProviderFound": "No matching providers found", + "noSessionFound": "No matching session IDs found", "model": "Model", "endpoint": "Endpoint", "status": "Status", @@ -96,6 +99,7 @@ "time": "Time", "user": "User", "key": "Key", + "sessionId": "Session ID", "provider": "Provider", "model": "Billing Model", "endpoint": "Endpoint", @@ -138,14 +142,18 @@ "loadedCount": "Loaded {count} records", "loadingMore": "Loading more...", "noMoreData": "All records loaded", - "scrollToTop": "Back to top" + "scrollToTop": "Back to top", + "hideProviderColumn": "Hide Provider Column", + "showProviderColumn": "Show Provider Column" }, "actions": { "refresh": "Refresh", "refreshing": "Refreshing...", "stopAutoRefresh": "Stop Auto-Refresh", "startAutoRefresh": "Start Auto-Refresh", - "view": "View" + "view": "View", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit Fullscreen" }, "error": { "loadFailed": "Load Failed", @@ -322,6 +330,10 @@ "adminAction": "Enable this permission.", "userAction": "Please contact an administrator to enable this permission.", "systemSettings": "System Settings" + }, + "filters": { + "userTagsPlaceholder": "Filter by user tags...", + "userGroupsPlaceholder": "Filter by user groups..." } }, "sessions": { @@ -781,6 +793,15 @@ "defaultDescription": "default includes providers without groupTag.", "descriptionWithUserGroup": "Provider groups for this key (user groups: {group}; default: default)." }, + "cacheTtl": { + "label": "Cache TTL Override", + "description": "Force Anthropic prompt cache TTL for requests containing cache_control.", + "options": { + "inherit": "No override (follow provider/client)", + "5m": "5m", + "1h": "1h" + } + }, "successTitle": "Key Created Successfully", "successDescription": "Your API key has been created successfully.", "generatedKey": { @@ -1144,18 +1165,21 @@ "name": "Key name", "key": "Key", "group": "Group", - "todayUsage": "Today's usage", + "todayUsage": "Requests today", "todayCost": "Today's cost", + "todayTokens": "Tokens today", "lastUsed": "Last used", "actions": "Actions", "quotaButton": "View Quota Usage", "fields": { - "callsLabel": "Calls", + "callsLabel": "Requests", + "tokensLabel": "Tokens", "costLabel": "Cost" } }, "expand": "Expand", "collapse": "Collapse", + "refresh": "Refresh", "noKeys": "No keys", "defaultGroup": "default", "userStatus": { @@ -1246,7 +1270,18 @@ "userEnabled": "User has been enabled", "deleteFailed": "Failed to delete user", "userDeleted": "User has been deleted", - "saving": "Saving..." + "saving": "Saving...", + "resetData": { + "title": "Reset Statistics", + "description": "Delete all request logs and usage data for this user. This action is irreversible.", + "error": "Failed to reset data", + "button": "Reset Statistics", + "confirmTitle": "Reset All Statistics?", + "confirmDescription": "This will permanently delete all request logs and usage statistics for this user. This action cannot be undone.", + "confirm": "Yes, Reset All", + "loading": "Resetting...", + "success": "All statistics have been reset" + } }, "batchEdit": { "enterMode": "Batch Edit", @@ -1347,6 +1382,41 @@ }, "limitRules": { "addRule": "Add limit rule", + "title": "Add Limit Rule", + "description": "Select limit type and set value", + "cancel": "Cancel", + "confirm": "Save", + "fields": { + "type": { + "label": "Limit Type", + "placeholder": "Select" + }, + "value": { + "label": "Value", + "placeholder": "Enter" + } + }, + "daily": { + "mode": { + "label": "Daily Reset Mode", + "fixed": "Fixed time reset", + "rolling": "Rolling window (24h)", + "helperRolling": "Rolling 24-hour window from first request" + }, + "time": { + "label": "Reset Time", + "placeholder": "HH:mm" + } + }, + "limitTypes": { + "limitRpm": "RPM Limit", + "limit5h": "5-Hour Limit", + "limitDaily": "Daily Limit", + "limitWeekly": "Weekly Limit", + "limitMonthly": "Monthly Limit", + "limitTotal": "Total Limit", + "limitSessions": "Concurrent Sessions" + }, "ruleTypes": { "limitRpm": "RPM limit", "limit5h": "5-hour limit", @@ -1356,6 +1426,12 @@ "limitTotal": "Total limit", "limitSessions": "Concurrent sessions" }, + "errors": { + "missingType": "Please select a limit type", + "invalidValue": "Please enter a valid value", + "invalidTime": "Please enter a valid time (HH:mm)" + }, + "overwriteHint": "This type already exists, saving will overwrite the existing value", "dailyMode": { "fixed": "Fixed reset time", "rolling": "Rolling window (24h)" @@ -1368,8 +1444,7 @@ "500": "$500" }, "alreadySet": "Configured", - "confirmAdd": "Add", - "cancel": "Cancel" + "confirmAdd": "Add" }, "quickExpire": { "oneWeek": "In 1 week", @@ -1592,6 +1667,13 @@ } }, "overwriteHint": "This type already exists, saving will overwrite the existing value" + }, + "accessRestrictions": { + "title": "Access Restrictions", + "models": "Allowed Models", + "clients": "Allowed Clients", + "noRestrictions": "No restrictions", + "inheritedFromUser": "Inherited from user settings" } } }, diff --git a/messages/en/myUsage.json b/messages/en/myUsage.json index 03e01c831..2d6aaab68 100644 --- a/messages/en/myUsage.json +++ b/messages/en/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "Key", "userStats": "User", "noData": "No data for selected period", - "unknownModel": "Unknown" + "unknownModel": "Unknown", + "modal": { + "requests": "Requests", + "tokens": "tokens", + "totalTokens": "Total Tokens", + "cost": "Cost", + "inputTokens": "Input Tokens", + "outputTokens": "Output Tokens", + "cacheWrite": "Cache Write", + "cacheRead": "Cache Read", + "cacheHitRate": "Cache Hit Rate", + "cacheTokens": "Cache Tokens", + "performanceHigh": "High", + "performanceMedium": "Medium", + "performanceLow": "Low" + } }, "accessRestrictions": { "title": "Access Restrictions", diff --git a/messages/en/quota.json b/messages/en/quota.json index 78332d6ca..d50b2b534 100644 --- a/messages/en/quota.json +++ b/messages/en/quota.json @@ -288,7 +288,8 @@ "limit5hUsd": { "label": "5-Hour Cost Limit (USD)", "placeholder": "Leave blank for unlimited", - "description": "Maximum cost within 5 hours" + "description": "Maximum cost within 5 hours", + "descriptionWithUserLimit": "Cannot exceed user 5-hour limit ({limit})" }, "limitDailyUsd": { "label": "Daily Cost Limit (USD)", @@ -314,12 +315,14 @@ "limitWeeklyUsd": { "label": "Weekly Cost Limit (USD)", "placeholder": "Leave blank for unlimited", - "description": "Maximum cost per week" + "description": "Maximum cost per week", + "descriptionWithUserLimit": "Cannot exceed user weekly limit ({limit})" }, "limitMonthlyUsd": { "label": "Monthly Cost Limit (USD)", "placeholder": "Leave blank for unlimited", - "description": "Maximum cost per month" + "description": "Maximum cost per month", + "descriptionWithUserLimit": "Cannot exceed user monthly limit ({limit})" }, "limitTotalUsd": { "label": "Total Cost Limit (USD)", @@ -330,7 +333,8 @@ "limitConcurrentSessions": { "label": "Concurrent Session Limit", "placeholder": "0 means unlimited", - "description": "Number of simultaneous conversations" + "description": "Number of simultaneous conversations", + "descriptionWithUserLimit": "Cannot exceed user session limit ({limit})" }, "providerGroup": { "label": "Provider Group", diff --git a/messages/en/settings/providers/autoSort.json b/messages/en/settings/providers/autoSort.json index f3aae3bd7..c3097a3e9 100644 --- a/messages/en/settings/providers/autoSort.json +++ b/messages/en/settings/providers/autoSort.json @@ -1,5 +1,5 @@ { - "button": "Auto Sort Priority", + "button": "Auto Sort", "changeCount": "{count} providers will be updated", "changesTitle": "Change Details", "confirm": "Apply Changes", diff --git a/messages/en/usage.json b/messages/en/usage.json index 3eeae3206..c022cc62c 100644 --- a/messages/en/usage.json +++ b/messages/en/usage.json @@ -19,7 +19,7 @@ }, "codeExamples": { - "label": "Code example - ${language}", + "label": "Code example - {language}", "description": "Click the code block to copy to clipboard" }, @@ -206,7 +206,7 @@ "unix": { "temporary": "Temporary setting (current session):", "permanent": "Permanent setting:", - "permanentNote": "Add to your shell configuration file (${shellConfig}):" + "permanentNote": "Add to your shell configuration file ({shellConfig}):" } }, @@ -698,6 +698,35 @@ "linux": "Linux" }, + "placeholders": { + "windowsUserName": "your-username", + "shellConfig": { + "linux": "~/.bashrc", + "macos": "~/.zshrc" + }, + "codexVsCodeConfigFiles": "config.toml and auth.json" + }, + + "snippets": { + "comments": { + "updateHomebrew": "# Update Homebrew", + "installNodeJs": "# Install Node.js", + "usingChocolatey": "# Using Chocolatey", + "orUsingScoop": "# Or using Scoop", + "addNodeSourceRepo": "# Add NodeSource repository", + "ubuntuDebian": "# Ubuntu/Debian", + "centosRhelFedora": "# CentOS/RHEL/Fedora", + "addToPathIfMissing": "# Add to PATH (if missing)", + "checkEnvVar": "# Check environment variable", + "testNetworkConnection": "# Test network connection" + } + }, + + "layout": { + "headerTitle": "Usage Docs", + "loginConsole": "Log in to console" + }, + "ui": { "mainContent": "Documentation content", "main": "main", diff --git a/messages/ja/auth.json b/messages/ja/auth.json index ef0f33d34..113aa9193 100644 --- a/messages/ja/auth.json +++ b/messages/ja/auth.json @@ -30,7 +30,7 @@ "solutionTitle": "解決策:", "useHttps": "HTTPS を使用してアクセスしてください (推奨)", "disableSecureCookies": ".env ファイルで ENABLE_SECURE_COOKIES=false を設定 (セキュリティが低下します)", - "privacyNote": "API Keyを使用してClaude Code Hub管理画面にログインしてください" + "privacyNote": "API Keyを使用して管理画面にログインしてください" }, "errors": { "loginFailed": "ログインに失敗しました", @@ -38,6 +38,9 @@ "invalidToken": "無効な認証トークン", "tokenRequired": "認証トークンが必要です", "sessionExpired": "セッションの有効期限が切れています。もう一度ログインしてください", - "unauthorized": "認可されていません。先にログインしてください" + "unauthorized": "認可されていません。先にログインしてください", + "apiKeyRequired": "API Keyを入力してください", + "apiKeyInvalidOrExpired": "API Keyが無効または期限切れです", + "serverError": "ログインに失敗しました。しばらく後に再度お試しください" } } diff --git a/messages/ja/bigScreen.json b/messages/ja/bigScreen.json index 7973014f7..6ecfd9d74 100644 --- a/messages/ja/bigScreen.json +++ b/messages/ja/bigScreen.json @@ -1,4 +1,6 @@ { + "pageTitle": "リアルタイム大画面 - Claude Code Hub", + "pageDescription": "Claude Code Hub のリアルタイム監視ダッシュボード", "title": "CLAUDE CODE HUB", "subtitle": "リアルタイムデータモニター", "metrics": { diff --git a/messages/ja/common.json b/messages/ja/common.json index f6a762258..c3442e2db 100644 --- a/messages/ja/common.json +++ b/messages/ja/common.json @@ -48,5 +48,15 @@ "theme": "テーマ", "light": "ライト", "dark": "ダーク", - "system": "システム設定" + "system": "システム設定", + "relativeTimeShort": { + "now": "たった今", + "secondsAgo": "{count}秒前", + "minutesAgo": "{count}分前", + "hoursAgo": "{count}時間前", + "daysAgo": "{count}日前", + "weeksAgo": "{count}週間前", + "monthsAgo": "{count}ヶ月前", + "yearsAgo": "{count}年前" + } } diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index bc0467dc5..c25c08880 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "ユーザー", "provider": "プロバイダー", + "sessionId": "セッションID", "searchUser": "ユーザーを検索...", "searchProvider": "プロバイダーを検索...", + "searchSessionId": "セッションIDを検索...", "noUserFound": "一致するユーザーが見つかりません", "noProviderFound": "一致するプロバイダーが見つかりません", + "noSessionFound": "一致するセッションIDが見つかりません", "model": "モデル", "endpoint": "エンドポイント", "status": "ステータス", @@ -96,6 +99,7 @@ "time": "時間", "user": "ユーザー", "key": "キー", + "sessionId": "セッションID", "provider": "プロバイダー", "model": "課金モデル", "endpoint": "エンドポイント", @@ -138,14 +142,18 @@ "loadedCount": "{count} 件のレコードを読み込みました", "loadingMore": "読み込み中...", "noMoreData": "すべてのレコードを読み込みました", - "scrollToTop": "トップへ戻る" + "scrollToTop": "トップへ戻る", + "hideProviderColumn": "プロバイダー列を非表示", + "showProviderColumn": "プロバイダー列を表示" }, "actions": { "refresh": "更新", "refreshing": "更新中...", "stopAutoRefresh": "自動更新を停止", "startAutoRefresh": "自動更新を開始", - "view": "表示" + "view": "表示", + "fullscreen": "全画面表示", + "exitFullscreen": "全画面を終了" }, "error": { "loadFailed": "読み込み失敗", @@ -321,6 +329,10 @@ "adminAction": "この権限を有効にします。", "userAction": "この権限を有効にするには、管理者に連絡してください。", "systemSettings": "システム設定" + }, + "filters": { + "userTagsPlaceholder": "ユーザータグでフィルタ...", + "userGroupsPlaceholder": "ユーザーグループでフィルタ..." } }, "sessions": { @@ -717,7 +729,8 @@ "limit5hUsd": { "label": "5時間消費上限 (USD)", "placeholder": "空白の場合は無制限", - "description": "5時間以内の最大消費金額" + "description": "5時間以内の最大消費金額", + "descriptionWithUserLimit": "5時間以内の最大消費金額 (ユーザー上限: {limit})" }, "limitDailyUsd": { "label": "1日の消費上限 (USD)", @@ -743,17 +756,26 @@ "limitWeeklyUsd": { "label": "週間消費上限 (USD)", "placeholder": "空白の場合は無制限", - "description": "1週間あたりの最大消費金額" + "description": "1週間あたりの最大消費金額", + "descriptionWithUserLimit": "1週間あたりの最大消費金額 (ユーザー上限: {limit})" }, "limitMonthlyUsd": { "label": "月間消費上限 (USD)", "placeholder": "空白の場合は無制限", - "description": "1ヶ月あたりの最大消費金額" + "description": "1ヶ月あたりの最大消費金額", + "descriptionWithUserLimit": "1ヶ月あたりの最大消費金額 (ユーザー上限: {limit})" + }, + "limitTotalUsd": { + "label": "総消費上限 (USD)", + "placeholder": "空白の場合は無制限", + "description": "累計消費上限(リセットなし)", + "descriptionWithUserLimit": "ユーザーの総上限を超えることはできません ({limit})" }, "limitConcurrentSessions": { "label": "同時セッション上限", "placeholder": "0は無制限を意味します", - "description": "同時に実行される会話の数" + "description": "同時に実行される会話の数", + "descriptionWithUserLimit": "最大セッション数 (ユーザー上限: {limit})" }, "providerGroup": { "label": "プロバイダーグループ", @@ -762,6 +784,15 @@ "defaultDescription": "default は groupTag 未設定のプロバイダーを含みます", "descriptionWithUserGroup": "このキーのプロバイダーグループ(ユーザーのグループ: {group}、既定: default)" }, + "cacheTtl": { + "label": "Cache TTL上書き", + "description": "cache_controlを含むリクエストに対してAnthropic prompt cache TTLを強制します。", + "options": { + "inherit": "上書きしない(プロバイダー/クライアントに従う)", + "5m": "5m", + "1h": "1h" + } + }, "successTitle": "キーが正常に作成されました", "successDescription": "APIキーが正常に作成されました。", "generatedKey": { @@ -1115,18 +1146,21 @@ "name": "キー名", "key": "キー", "group": "グループ", - "todayUsage": "本日の使用量", + "todayUsage": "本日のリクエスト", "todayCost": "本日の消費", + "todayTokens": "本日のトークン", "lastUsed": "最終使用", "actions": "アクション", "quotaButton": "クォータ使用状況を表示", "fields": { - "callsLabel": "呼び出し", + "callsLabel": "リクエスト", + "tokensLabel": "トークン", "costLabel": "消費" } }, "expand": "展開", "collapse": "折りたたむ", + "refresh": "更新", "noKeys": "キーなし", "defaultGroup": "default", "userStatus": { @@ -1178,6 +1212,10 @@ "currentExpiry": "現在の有効期限", "neverExpires": "無期限", "expired": "期限切れ", + "quickExtensionLabel": "クイック延長", + "quickExtensionHint": "現在の有効期限から延長(期限切れの場合は現在から)", + "customDateLabel": "有効期限を設定", + "customDateHint": "有効期限を直接指定", "quickOptions": { "7days": "7 日", "30days": "30 日", @@ -1186,6 +1224,7 @@ }, "customDate": "カスタム日付", "enableOnRenew": "同時にユーザーを有効化", + "enableKeyOnRenew": "同時にキーを有効化", "cancel": "キャンセル", "confirm": "更新を確認", "confirming": "更新中...", @@ -1208,7 +1247,18 @@ "userEnabled": "ユーザーが有効化されました", "deleteFailed": "ユーザーの削除に失敗しました", "userDeleted": "ユーザーが削除されました", - "saving": "保存しています..." + "saving": "保存しています...", + "resetData": { + "title": "統計リセット", + "description": "このユーザーのすべてのリクエストログと使用データを削除します。この操作は元に戻せません。", + "error": "データのリセットに失敗しました", + "button": "統計をリセット", + "confirmTitle": "すべての統計をリセットしますか?", + "confirmDescription": "このユーザーのすべてのリクエストログと使用統計を完全に削除します。この操作は取り消せません。", + "confirm": "はい、すべてリセット", + "loading": "リセット中...", + "success": "すべての統計がリセットされました" + } }, "batchEdit": { "enterMode": "一括編集", diff --git a/messages/ja/myUsage.json b/messages/ja/myUsage.json index 916f9ac39..66091789a 100644 --- a/messages/ja/myUsage.json +++ b/messages/ja/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "キー", "userStats": "ユーザー", "noData": "選択期間のデータがありません", - "unknownModel": "不明" + "unknownModel": "不明", + "modal": { + "requests": "リクエスト", + "tokens": "トークン", + "totalTokens": "トークン合計", + "cost": "コスト", + "inputTokens": "入力トークン", + "outputTokens": "出力トークン", + "cacheWrite": "キャッシュ書込", + "cacheRead": "キャッシュ読取", + "cacheHitRate": "キャッシュヒット率", + "cacheTokens": "キャッシュトークン", + "performanceHigh": "高", + "performanceMedium": "中", + "performanceLow": "低" + } }, "accessRestrictions": { "title": "アクセス制限", diff --git a/messages/ja/quota.json b/messages/ja/quota.json index 4994ea9d6..874c033bf 100644 --- a/messages/ja/quota.json +++ b/messages/ja/quota.json @@ -265,7 +265,8 @@ "limit5hUsd": { "label": "5時間消費上限 (USD)", "placeholder": "空欄の場合は無制限", - "description": "5時間以内の最大消費金額" + "description": "5時間以内の最大消費金額", + "descriptionWithUserLimit": "ユーザーの5時間制限を超えることはできません ({limit})" }, "limitDailyUsd": { "label": "日次消費上限 (USD)", @@ -291,17 +292,26 @@ "limitWeeklyUsd": { "label": "週間消費上限 (USD)", "placeholder": "空欄の場合は無制限", - "description": "毎週の最大消費金額" + "description": "毎週の最大消費金額", + "descriptionWithUserLimit": "ユーザーの週間制限を超えることはできません ({limit})" }, "limitMonthlyUsd": { "label": "月間消費上限 (USD)", "placeholder": "空欄の場合は無制限", - "description": "毎月の最大消費金額" + "description": "毎月の最大消費金額", + "descriptionWithUserLimit": "ユーザーの月間制限を超えることはできません ({limit})" + }, + "limitTotalUsd": { + "label": "総消費上限 (USD)", + "placeholder": "空欄の場合は無制限", + "description": "累計消費上限(リセットなし)", + "descriptionWithUserLimit": "ユーザーの総制限を超えることはできません ({limit})" }, "limitConcurrentSessions": { "label": "同時セッション上限", "placeholder": "0 = 無制限", - "description": "同時実行可能な会話数" + "description": "同時実行可能な会話数", + "descriptionWithUserLimit": "ユーザーのセッション制限を超えることはできません ({limit})" }, "providerGroup": { "label": "プロバイダーグループ", diff --git a/messages/ja/usage.json b/messages/ja/usage.json index a83e9ed36..e72c8dffb 100644 --- a/messages/ja/usage.json +++ b/messages/ja/usage.json @@ -19,7 +19,7 @@ }, "codeExamples": { - "label": "コード例 - ${language}", + "label": "コード例 - {language}", "description": "コードブロックをクリックしてクリップボードにコピー" }, @@ -206,7 +206,7 @@ "unix": { "temporary": "一時設定 (現在のセッション):", "permanent": "永続設定:", - "permanentNote": "シェル設定ファイル (${shellConfig}) に追加:" + "permanentNote": "シェル設定ファイル ({shellConfig}) に追加:" } }, @@ -698,6 +698,35 @@ "linux": "Linux" }, + "placeholders": { + "windowsUserName": "your-username", + "shellConfig": { + "linux": "~/.bashrc", + "macos": "~/.zshrc" + }, + "codexVsCodeConfigFiles": "config.toml と auth.json" + }, + + "snippets": { + "comments": { + "updateHomebrew": "# Homebrew を更新", + "installNodeJs": "# Node.js をインストール", + "usingChocolatey": "# Chocolatey を使用", + "orUsingScoop": "# または Scoop を使用", + "addNodeSourceRepo": "# NodeSource リポジトリを追加", + "ubuntuDebian": "# Ubuntu/Debian", + "centosRhelFedora": "# CentOS/RHEL/Fedora", + "addToPathIfMissing": "# PATH に追加(未設定の場合)", + "checkEnvVar": "# 環境変数を確認", + "testNetworkConnection": "# ネットワーク接続を確認" + } + }, + + "layout": { + "headerTitle": "利用ドキュメント", + "loginConsole": "コンソールにログイン" + }, + "ui": { "mainContent": "ドキュメントコンテンツ", "main": "main", diff --git a/messages/ru/auth.json b/messages/ru/auth.json index 6e18bdc15..4e6f42542 100644 --- a/messages/ru/auth.json +++ b/messages/ru/auth.json @@ -1,7 +1,7 @@ { "form": { "title": "Панель входа", - "description": "Получите доступ к унифицированной консоли администратора с помощью вашего API ключа" + "description": "Введите ваш API ключ для доступа к данным" }, "login": { "title": "Вход", @@ -30,7 +30,7 @@ "solutionTitle": "Решения:", "useHttps": "Используйте HTTPS для доступа к системе (рекомендуется)", "disableSecureCookies": "Установите ENABLE_SECURE_COOKIES=false в .env (снижает безопасность)", - "privacyNote": "Пожалуйста, используйте свой API Key для входа в панель администрирования Claude Code Hub" + "privacyNote": "Если вы забыли свой API ключ, обратитесь к администратору" }, "errors": { "loginFailed": "Ошибка входа", @@ -38,6 +38,9 @@ "invalidToken": "Неверный токен аутентификации", "tokenRequired": "Требуется токен аутентификации", "sessionExpired": "Ваша сессия истекла, пожалуйста, войдите снова", - "unauthorized": "Не авторизовано, пожалуйста, сначала войдите" + "unauthorized": "Не авторизовано, пожалуйста, сначала войдите", + "apiKeyRequired": "Пожалуйста, введите API ключ", + "apiKeyInvalidOrExpired": "API ключ недействителен или истёк", + "serverError": "Ошибка входа, попробуйте позже" } } diff --git a/messages/ru/bigScreen.json b/messages/ru/bigScreen.json index f6be0fbba..33b2a9090 100644 --- a/messages/ru/bigScreen.json +++ b/messages/ru/bigScreen.json @@ -1,4 +1,6 @@ { + "pageTitle": "Панель реального времени - Claude Code Hub", + "pageDescription": "Большой экран мониторинга в реальном времени Claude Code Hub", "title": "CLAUDE CODE HUB", "subtitle": "МОНИТОР ДАННЫХ В РЕАЛЬНОМ ВРЕМЕНИ", "metrics": { diff --git a/messages/ru/common.json b/messages/ru/common.json index 55deada2f..86c097d5c 100644 --- a/messages/ru/common.json +++ b/messages/ru/common.json @@ -48,5 +48,15 @@ "theme": "Тема", "light": "Светлая", "dark": "Тёмная", - "system": "Системная" + "system": "Системная", + "relativeTimeShort": { + "now": "сейчас", + "secondsAgo": "{count}с назад", + "minutesAgo": "{count}м назад", + "hoursAgo": "{count}ч назад", + "daysAgo": "{count}д назад", + "weeksAgo": "{count}н назад", + "monthsAgo": "{count}мес назад", + "yearsAgo": "{count}г назад" + } } diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 1b332bc7e..7a1caeec4 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "Пользователь", "provider": "Поставщик", + "sessionId": "ID сессии", "searchUser": "Поиск пользователей...", "searchProvider": "Поиск провайдеров...", + "searchSessionId": "Поиск ID сессии...", "noUserFound": "Пользователи не найдены", "noProviderFound": "Провайдеры не найдены", + "noSessionFound": "ID сессии не найдены", "model": "Модель", "endpoint": "Эндпоинт", "status": "Статус", @@ -96,6 +99,7 @@ "time": "Время", "user": "Пользователь", "key": "Ключ", + "sessionId": "ID сессии", "provider": "Поставщик", "model": "Модель тарификации", "endpoint": "Эндпоинт", @@ -138,14 +142,18 @@ "loadedCount": "Загружено {count} записей", "loadingMore": "Загрузка...", "noMoreData": "Все записи загружены", - "scrollToTop": "Наверх" + "scrollToTop": "Наверх", + "hideProviderColumn": "Скрыть столбец провайдера", + "showProviderColumn": "Показать столбец провайдера" }, "actions": { "refresh": "Обновить", "refreshing": "Обновление...", "stopAutoRefresh": "Остановить автообновление", "startAutoRefresh": "Начать автообновление", - "view": "Просмотр" + "view": "Просмотр", + "fullscreen": "Полный экран", + "exitFullscreen": "Выйти из полного экрана" }, "error": { "loadFailed": "Ошибка загрузки", @@ -321,6 +329,10 @@ "adminAction": "Включить это разрешение.", "userAction": "Пожалуйста, свяжитесь с администратором, чтобы включить это разрешение.", "systemSettings": "Настройки системы" + }, + "filters": { + "userTagsPlaceholder": "Фильтр по тегам пользователей...", + "userGroupsPlaceholder": "Фильтр по группам пользователей..." } }, "sessions": { @@ -532,11 +544,11 @@ "dashboard": "Панель", "usageLogs": "Журналы", "leaderboard": "Лидеры", - "availability": "Доступность", + "availability": "Мониторинг", "myQuota": "Моя квота", "quotasManagement": "Квоты", "userManagement": "Пользователи", - "providers": "Управление поставщиками", + "providers": "Поставщики", "documentation": "Доки", "systemSettings": "Настройки", "feedback": "Обратная связь", @@ -544,7 +556,7 @@ "logout": "Выход" }, "statistics": { - "title": "Статистика использования", + "title": "Статистика", "cost": "Сумма расходов", "calls": "Количество вызовов API", "totalCost": "Общая сумма расходов", @@ -552,18 +564,18 @@ "timeRange": { "today": "Сегодня", "todayDescription": "Использование за сегодня", - "7days": "Последние 7 дней", + "7days": "7д", "7daysDescription": "Использование за последние 7 дней", - "30days": "Последние 30 дней", + "30days": "30д", "30daysDescription": "Использование за последние 30 дней", "thisMonth": "Этот месяц", "thisMonthDescription": "Использование за этот месяц", "default": "Использование" }, "mode": { - "keys": "Показать статистику использования только для ваших ключей", + "keys": "Только ваши ключи", "mixed": "Показать детали ваших ключей и сводку других пользователей", - "users": "Показать статистику использования всех пользователей" + "users": "Показать для всех" }, "legend": { "selectAll": "Выбрать все", @@ -719,7 +731,8 @@ "limit5hUsd": { "label": "Лимит расходов за 5 часов (USD)", "placeholder": "Оставьте пустым для неограниченного", - "description": "Максимальный расход в течение 5 часов" + "description": "Максимальный расход в течение 5 часов", + "descriptionWithUserLimit": "Максимальный расход за 5 часов (Лимит пользователя: {limit})" }, "limitDailyUsd": { "label": "Дневной лимит расходов (USD)", @@ -745,17 +758,26 @@ "limitWeeklyUsd": { "label": "Недельный лимит расходов (USD)", "placeholder": "Оставьте пустым для неограниченного", - "description": "Максимальный расход в неделю" + "description": "Максимальный расход в неделю", + "descriptionWithUserLimit": "Максимальный расход в неделю (Лимит пользователя: {limit})" }, "limitMonthlyUsd": { "label": "Месячный лимит расходов (USD)", "placeholder": "Оставьте пустым для неограниченного", - "description": "Максимальный расход в месяц" + "description": "Максимальный расход в месяц", + "descriptionWithUserLimit": "Максимальный расход в месяц (Лимит пользователя: {limit})" + }, + "limitTotalUsd": { + "label": "Общий лимит расходов (USD)", + "placeholder": "Оставьте пустым для неограниченного", + "description": "Максимальная сумма расходов (без сброса)", + "descriptionWithUserLimit": "Не может превышать общий лимит пользователя ({limit})" }, "limitConcurrentSessions": { "label": "Лимит параллельных сеансов", "placeholder": "0 означает неограниченно", - "description": "Количество одновременных разговоров" + "description": "Количество одновременных разговоров", + "descriptionWithUserLimit": "Максимум сеансов (Лимит пользователя: {limit})" }, "providerGroup": { "label": "Группа провайдеров", @@ -764,6 +786,15 @@ "defaultDescription": "default включает провайдеров без groupTag.", "descriptionWithUserGroup": "Группы провайдеров для этого ключа (группы пользователя: {group}; по умолчанию: default)." }, + "cacheTtl": { + "label": "Переопределение Cache TTL", + "description": "Принудительно установить Anthropic prompt cache TTL для запросов с cache_control.", + "options": { + "inherit": "Не переопределять (следовать провайдеру/клиенту)", + "5m": "5m", + "1h": "1h" + } + }, "successTitle": "Ключ успешно создан", "successDescription": "Ваш API-ключ был успешно создан.", "generatedKey": { @@ -918,7 +949,7 @@ "last1h": "Последний час", "last6h": "Последние 6 часов", "last24h": "Последние 24 часа", - "last7d": "Последние 7 дней", + "last7d": "7д", "custom": "Настраиваемый" }, "filters": { @@ -1122,18 +1153,21 @@ "name": "Название ключа", "key": "Ключ", "group": "Группа", - "todayUsage": "Использование сегодня", + "todayUsage": "Запросы сегодня", "todayCost": "Расход сегодня", + "todayTokens": "Токены сегодня", "lastUsed": "Последнее использование", "actions": "Действия", "quotaButton": "Просмотр использования квоты", "fields": { - "callsLabel": "Вызовы", + "callsLabel": "Запросы", + "tokensLabel": "Токены", "costLabel": "Расход" } }, "expand": "Развернуть", "collapse": "Свернуть", + "refresh": "Обновить", "noKeys": "Нет ключей", "defaultGroup": "default", "userStatus": { @@ -1185,6 +1219,10 @@ "currentExpiry": "Текущий срок", "neverExpires": "Бессрочно", "expired": "Истёк", + "quickExtensionLabel": "Быстрое продление", + "quickExtensionHint": "Продлить от текущего срока (или от сейчас, если истёк)", + "customDateLabel": "Указать дату", + "customDateHint": "Напрямую указать дату истечения", "quickOptions": { "7days": "7 дней", "30days": "30 дней", @@ -1193,6 +1231,7 @@ }, "customDate": "Произвольная дата", "enableOnRenew": "Также включить пользователя", + "enableKeyOnRenew": "Также включить ключ", "cancel": "Отмена", "confirm": "Подтвердить продление", "confirming": "Продление...", @@ -1219,7 +1258,18 @@ "userEnabled": "Пользователь активирован", "deleteFailed": "Не удалось удалить пользователя", "userDeleted": "Пользователь удален", - "saving": "Сохранение..." + "saving": "Сохранение...", + "resetData": { + "title": "Сброс статистики", + "description": "Удалить все логи запросов и данные использования для этого пользователя. Это действие необратимо.", + "error": "Не удалось сбросить данные", + "button": "Сбросить статистику", + "confirmTitle": "Сбросить всю статистику?", + "confirmDescription": "Это навсегда удалит все логи запросов и статистику использования для этого пользователя. Это действие нельзя отменить.", + "confirm": "Да, сбросить все", + "loading": "Сброс...", + "success": "Вся статистика сброшена" + } }, "batchEdit": { "enterMode": "Массовое редактирование", @@ -1320,6 +1370,41 @@ }, "limitRules": { "addRule": "Добавить правило лимита", + "title": "Добавить правило лимита", + "description": "Выберите тип лимита и установите значение", + "cancel": "Отмена", + "confirm": "Сохранить", + "fields": { + "type": { + "label": "Тип лимита", + "placeholder": "Выберите" + }, + "value": { + "label": "Значение", + "placeholder": "Введите" + } + }, + "daily": { + "mode": { + "label": "Режим дневного сброса", + "fixed": "Сброс в фиксированное время", + "rolling": "Скользящее окно (24ч)", + "helperRolling": "Скользящее окно 24 часа от первого запроса" + }, + "time": { + "label": "Время сброса", + "placeholder": "ЧЧ:мм" + } + }, + "limitTypes": { + "limitRpm": "Лимит RPM", + "limit5h": "Лимит за 5 часов", + "limitDaily": "Дневной лимит", + "limitWeekly": "Недельный лимит", + "limitMonthly": "Месячный лимит", + "limitTotal": "Общий лимит", + "limitSessions": "Одновременные сессии" + }, "ruleTypes": { "limitRpm": "Лимит RPM", "limit5h": "Лимит за 5 часов", @@ -1329,6 +1414,12 @@ "limitTotal": "Общий лимит", "limitSessions": "Одновременные сессии" }, + "errors": { + "missingType": "Пожалуйста, выберите тип лимита", + "invalidValue": "Пожалуйста, введите корректное значение", + "invalidTime": "Пожалуйста, введите корректное время (ЧЧ:мм)" + }, + "overwriteHint": "Этот тип уже существует, сохранение перезапишет существующее значение", "dailyMode": { "fixed": "Сброс по фиксированному времени", "rolling": "Скользящее окно (24ч)" @@ -1341,8 +1432,7 @@ "500": "$500" }, "alreadySet": "Уже настроено", - "confirmAdd": "Добавить", - "cancel": "Отмена" + "confirmAdd": "Добавить" }, "quickExpire": { "oneWeek": "Через неделю", @@ -1531,7 +1621,9 @@ }, "balanceQueryPage": { "label": "Независимая страница использования", - "description": "При включении этот ключ может использовать независимую страницу личного использования" + "description": "При включении этот ключ может использовать независимую страницу личного использования", + "descriptionEnabled": "При включении этот ключ будет использовать независимую страницу личного использования при входе. Однако он не может изменять группу провайдеров собственного ключа.", + "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Вместо этого будет использоваться ограниченный Web UI." }, "providerGroup": { "label": "Группа провайдеров", @@ -1564,6 +1656,13 @@ } }, "overwriteHint": "Этот тип уже существует, сохранение перезапишет существующее значение" + }, + "accessRestrictions": { + "title": "Ограничения доступа", + "models": "Разрешённые модели", + "clients": "Разрешённые клиенты", + "noRestrictions": "Без ограничений", + "inheritedFromUser": "Унаследовано от настроек пользователя" } } }, diff --git a/messages/ru/myUsage.json b/messages/ru/myUsage.json index 5f744bb21..886d1eeb6 100644 --- a/messages/ru/myUsage.json +++ b/messages/ru/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "Ключ", "userStats": "Пользователь", "noData": "Нет данных за выбранный период", - "unknownModel": "Неизвестно" + "unknownModel": "Неизвестно", + "modal": { + "requests": "Запросов", + "tokens": "токенов", + "totalTokens": "Всего токенов", + "cost": "Стоимость", + "inputTokens": "Входные токены", + "outputTokens": "Выходные токены", + "cacheWrite": "Запись кэша", + "cacheRead": "Чтение кэша", + "cacheHitRate": "Попадание кэша", + "cacheTokens": "Токены кэша", + "performanceHigh": "Высокий", + "performanceMedium": "Средний", + "performanceLow": "Низкий" + } }, "accessRestrictions": { "title": "Ограничения доступа", diff --git a/messages/ru/quota.json b/messages/ru/quota.json index 3b66d416c..293e2d2fb 100644 --- a/messages/ru/quota.json +++ b/messages/ru/quota.json @@ -64,6 +64,8 @@ "users": { "title": "Статистика квот пользователей", "totalCount": "Всего пользователей: {count}", + "manageNotice": "Для управления пользователями и ключами перейдите в", + "manageLink": "Управление пользователями", "noNote": "Без заметок", "rpm": { "label": "RPM квота", @@ -85,7 +87,30 @@ "warning": "Приближение к лимиту (>60%)", "exceeded": "Превышено (≥100%)" }, - "expiresAtLabel": "Срок действия" + "withQuotas": "С квотами", + "unlimited": "Без ограничений", + "totalCost": "Общие расходы", + "totalCostAllTime": "Всего за все время", + "todayCost": "Расходы за сегодня", + "expiresAtLabel": "Срок действия", + "keys": "Ключи", + "more": "ещё", + "noLimitSet": "-", + "noUnlimited": "Нет пользователей без ограничений", + "noKeys": "Нет ключей", + "limit5h": "Лимит 5 часов", + "limitWeekly": "Недельный лимит", + "limitMonthly": "Месячный лимит", + "limitTotal": "Общий лимит", + "limitConcurrent": "Параллельные сессии", + "role": { + "admin": "Администратор", + "user": "Пользователь" + }, + "keyStatus": { + "enabled": "Включен", + "disabled": "Отключен" + } }, "providers": { "title": "Статистика квот провайдеров", @@ -263,7 +288,8 @@ "limit5hUsd": { "label": "Лимит расходов за 5 часов (USD)", "placeholder": "Оставьте пустым для отсутствия ограничений", - "description": "Максимальная сумма расходов за 5 часов" + "description": "Максимальная сумма расходов за 5 часов", + "descriptionWithUserLimit": "Не может превышать лимит пользователя ({limit})" }, "limitDailyUsd": { "label": "Дневной лимит расходов (USD)", @@ -289,17 +315,26 @@ "limitWeeklyUsd": { "label": "Еженедельный лимит расходов (USD)", "placeholder": "Оставьте пустым для отсутствия ограничений", - "description": "Максимальная сумма расходов в неделю" + "description": "Максимальная сумма расходов в неделю", + "descriptionWithUserLimit": "Не может превышать недельный лимит пользователя ({limit})" }, "limitMonthlyUsd": { "label": "Ежемесячный лимит расходов (USD)", "placeholder": "Оставьте пустым для отсутствия ограничений", - "description": "Максимальная сумма расходов в месяц" + "description": "Максимальная сумма расходов в месяц", + "descriptionWithUserLimit": "Не может превышать месячный лимит пользователя ({limit})" + }, + "limitTotalUsd": { + "label": "Общий лимит расходов (USD)", + "placeholder": "Оставьте пустым для отсутствия ограничений", + "description": "Максимальная сумма расходов (без сброса)", + "descriptionWithUserLimit": "Не может превышать общий лимит пользователя ({limit})" }, "limitConcurrentSessions": { "label": "Лимит параллельных сессий", "placeholder": "0 = без ограничений", - "description": "Количество одновременных диалогов" + "description": "Количество одновременных диалогов", + "descriptionWithUserLimit": "Не может превышать лимит пользователя ({limit})" }, "providerGroup": { "label": "Группа провайдеров", diff --git a/messages/ru/settings/providers/autoSort.json b/messages/ru/settings/providers/autoSort.json index cecb10d7f..b0f852ad8 100644 --- a/messages/ru/settings/providers/autoSort.json +++ b/messages/ru/settings/providers/autoSort.json @@ -1,5 +1,5 @@ { - "button": "Авто сортировка приоритета", + "button": "Автосорт", "changeCount": "{count} поставщиков будет обновлено", "changesTitle": "Детали изменений", "confirm": "Применить изменения", diff --git a/messages/ru/settings/providers/form/title.json b/messages/ru/settings/providers/form/title.json index 44ef32746..9f710acbb 100644 --- a/messages/ru/settings/providers/form/title.json +++ b/messages/ru/settings/providers/form/title.json @@ -1,4 +1,4 @@ { - "create": "Добавить провайдера", - "edit": "Редактировать провайдера" + "create": "Добавить поставщика", + "edit": "Редактировать поставщика" } diff --git a/messages/ru/settings/providers/strings.json b/messages/ru/settings/providers/strings.json index c9374633a..2ddcf758e 100644 --- a/messages/ru/settings/providers/strings.json +++ b/messages/ru/settings/providers/strings.json @@ -1,7 +1,7 @@ { "add": "Добавить поставщика", "addFailed": "Ошибка добавления поставщика", - "addProvider": "Добавить провайдера", + "addProvider": "Добавить поставщика", "addSuccess": "Поставщик добавлен успешно", "circuitBroken": "Цепь разомкнута", "clone": "Дублировать поставщика", @@ -10,7 +10,7 @@ "confirmDeleteDesc": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие не может быть отменено.", "confirmDeleteProvider": "Подтвердить удаление провайдера?", "confirmDeleteProviderDesc": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие не может быть восстановлено.", - "createProvider": "Добавить провайдера", + "createProvider": "Добавить поставщика", "delete": "Удалить поставщика", "deleteFailed": "Ошибка удаления поставщика", "deleteSuccess": "Успешно удалено", diff --git a/messages/ru/usage.json b/messages/ru/usage.json index 067ee5d2c..babe42cc5 100644 --- a/messages/ru/usage.json +++ b/messages/ru/usage.json @@ -19,7 +19,7 @@ }, "codeExamples": { - "label": "Пример кода - ${language}", + "label": "Пример кода - {language}", "description": "Нажмите на блок кода для копирования в буфер обмена" }, @@ -206,7 +206,7 @@ "unix": { "temporary": "Временная настройка (текущий сеанс):", "permanent": "Постоянная настройка:", - "permanentNote": "Добавьте в файл конфигурации оболочки (${shellConfig}):" + "permanentNote": "Добавьте в файл конфигурации оболочки ({shellConfig}):" } }, @@ -698,6 +698,35 @@ "linux": "Linux" }, + "placeholders": { + "windowsUserName": "your-username", + "shellConfig": { + "linux": "~/.bashrc", + "macos": "~/.zshrc" + }, + "codexVsCodeConfigFiles": "config.toml и auth.json" + }, + + "snippets": { + "comments": { + "updateHomebrew": "# Обновить Homebrew", + "installNodeJs": "# Установить Node.js", + "usingChocolatey": "# Используя Chocolatey", + "orUsingScoop": "# Или через Scoop", + "addNodeSourceRepo": "# Добавить репозиторий NodeSource", + "ubuntuDebian": "# Ubuntu/Debian", + "centosRhelFedora": "# CentOS/RHEL/Fedora", + "addToPathIfMissing": "# Добавить в PATH (если отсутствует)", + "checkEnvVar": "# Проверить переменную окружения", + "testNetworkConnection": "# Проверить сетевое подключение" + } + }, + + "layout": { + "headerTitle": "Документация", + "loginConsole": "Войти в консоль" + }, + "ui": { "mainContent": "Содержимое документации", "main": "main", diff --git a/messages/zh-CN/auth.json b/messages/zh-CN/auth.json index 032c1976b..9ffb12e4f 100644 --- a/messages/zh-CN/auth.json +++ b/messages/zh-CN/auth.json @@ -19,7 +19,10 @@ "invalidToken": "无效的认证令牌", "tokenRequired": "需要提供认证令牌", "sessionExpired": "会话已过期,请重新登录", - "unauthorized": "未授权,请先登录" + "unauthorized": "未授权,请先登录", + "apiKeyRequired": "请输入 API Key", + "apiKeyInvalidOrExpired": "API Key 无效或已过期", + "serverError": "登录失败,请稍后重试" }, "placeholders": { "apiKeyExample": "例如 sk-xxxxxxxx" @@ -34,7 +37,7 @@ "solutionTitle": "解决方案:", "useHttps": "使用 HTTPS 访问(推荐)", "disableSecureCookies": "在 .env 中设置 ENABLE_SECURE_COOKIES=false(会降低安全性)", - "privacyNote": "请使用您的 API Key 登录 Claude Code Hub 后台" + "privacyNote": "请使用您的 API Key 登录后台" }, "form": { "title": "登录面板", diff --git a/messages/zh-CN/bigScreen.json b/messages/zh-CN/bigScreen.json index 16a75d358..c750dce83 100644 --- a/messages/zh-CN/bigScreen.json +++ b/messages/zh-CN/bigScreen.json @@ -1,4 +1,6 @@ { + "pageTitle": "实时数据大屏 - Claude Code Hub", + "pageDescription": "Claude Code Hub 实时监控数据大屏", "title": "CLAUDE CODE HUB", "subtitle": "实时数据监控中台", "metrics": { diff --git a/messages/zh-CN/common.json b/messages/zh-CN/common.json index 43b4c78eb..75c7c9abd 100644 --- a/messages/zh-CN/common.json +++ b/messages/zh-CN/common.json @@ -48,5 +48,15 @@ "theme": "主题", "light": "浅色", "dark": "深色", - "system": "跟随系统" + "system": "跟随系统", + "relativeTimeShort": { + "now": "刚刚", + "secondsAgo": "{count}秒前", + "minutesAgo": "{count}分前", + "hoursAgo": "{count}时前", + "daysAgo": "{count}天前", + "weeksAgo": "{count}周前", + "monthsAgo": "{count}月前", + "yearsAgo": "{count}年前" + } } diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 40b1be77a..ac6723668 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "用户", "provider": "供应商", + "sessionId": "Session ID", "searchUser": "搜索用户...", "searchProvider": "搜索供应商...", + "searchSessionId": "搜索 Session ID...", "noUserFound": "未找到匹配的用户", "noProviderFound": "未找到匹配的供应商", + "noSessionFound": "未找到匹配的 Session ID", "model": "模型", "endpoint": "端点", "status": "状态", @@ -96,6 +99,7 @@ "time": "时间", "user": "用户", "key": "密钥", + "sessionId": "Session ID", "provider": "供应商", "model": "计费模型", "endpoint": "端点", @@ -138,14 +142,18 @@ "loadedCount": "已加载 {count} 条记录", "loadingMore": "加载更多中...", "noMoreData": "已加载全部记录", - "scrollToTop": "回到顶部" + "scrollToTop": "回到顶部", + "hideProviderColumn": "隐藏供应商列", + "showProviderColumn": "显示供应商列" }, "actions": { "refresh": "刷新", "refreshing": "刷新中...", "stopAutoRefresh": "停止自动刷新", "startAutoRefresh": "开启自动刷新", - "view": "查看" + "view": "查看", + "fullscreen": "全屏显示", + "exitFullscreen": "退出全屏" }, "error": { "loadFailed": "加载失败", @@ -322,6 +330,10 @@ "adminAction": "开启此权限。", "userAction": "请联系管理员开启此权限。", "systemSettings": "系统设置" + }, + "filters": { + "userTagsPlaceholder": "按用户标签筛选...", + "userGroupsPlaceholder": "按用户分组筛选..." } }, "sessions": { @@ -782,6 +794,15 @@ "defaultDescription": "default 分组包含所有未设置 groupTag 的供应商", "descriptionWithUserGroup": "供应商分组(默认:default;用户分组:{group})" }, + "cacheTtl": { + "label": "Cache TTL 覆写", + "description": "强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。", + "options": { + "inherit": "不覆写(跟随供应商/客户端)", + "5m": "5m", + "1h": "1h" + } + }, "successTitle": "密钥创建成功", "successDescription": "您的 API 密钥已成功创建。", "generatedKey": { @@ -1145,18 +1166,21 @@ "name": "密钥名称", "key": "密钥", "group": "分组", - "todayUsage": "今日用量", + "todayUsage": "今日请求", "todayCost": "今日消耗", + "todayTokens": "今日Token", "lastUsed": "最后使用", "actions": "操作", "quotaButton": "查看限额用量", "fields": { - "callsLabel": "调用", + "callsLabel": "请求", + "tokensLabel": "Token", "costLabel": "消耗" } }, "expand": "展开", "collapse": "收起", + "refresh": "刷新", "noKeys": "无密钥", "defaultGroup": "default", "userStatus": { @@ -1247,7 +1271,18 @@ "userEnabled": "用户已启用", "deleteFailed": "删除用户失败", "userDeleted": "用户已删除", - "saving": "保存中..." + "saving": "保存中...", + "resetData": { + "title": "重置统计", + "description": "删除该用户的所有请求日志和使用数据。此操作不可逆。", + "error": "重置数据失败", + "button": "重置统计", + "confirmTitle": "重置所有统计?", + "confirmDescription": "这将永久删除该用户的所有请求日志和使用统计。此操作无法撤销。", + "confirm": "是的,重置全部", + "loading": "重置中...", + "success": "所有统计已重置" + } }, "batchEdit": { "enterMode": "批量编辑", diff --git a/messages/zh-CN/myUsage.json b/messages/zh-CN/myUsage.json index 643048ea4..40740dbe1 100644 --- a/messages/zh-CN/myUsage.json +++ b/messages/zh-CN/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "密钥", "userStats": "用户", "noData": "所选时段无数据", - "unknownModel": "未知" + "unknownModel": "未知", + "modal": { + "requests": "请求", + "tokens": "个token", + "totalTokens": "总Token", + "cost": "费用", + "inputTokens": "输入Token", + "outputTokens": "输出Token", + "cacheWrite": "缓存写入", + "cacheRead": "缓存读取", + "cacheHitRate": "缓存命中率", + "cacheTokens": "缓存Token", + "performanceHigh": "高", + "performanceMedium": "中", + "performanceLow": "低" + } }, "accessRestrictions": { "title": "访问限制", diff --git a/messages/zh-CN/usage.json b/messages/zh-CN/usage.json index b25fc042c..4bd2c86ff 100644 --- a/messages/zh-CN/usage.json +++ b/messages/zh-CN/usage.json @@ -19,7 +19,7 @@ }, "codeExamples": { - "label": "代码示例 - ${language}", + "label": "代码示例 - {language}", "description": "点击代码块可复制到剪贴板" }, @@ -202,7 +202,7 @@ "unix": { "temporary": "临时设置(当前会话):", "permanent": "永久设置:", - "permanentNote": "添加到您的 shell 配置文件(${shellConfig}):" + "permanentNote": "添加到您的 shell 配置文件({shellConfig}):" } }, @@ -694,6 +694,35 @@ "linux": "Linux" }, + "placeholders": { + "windowsUserName": "你的用户名", + "shellConfig": { + "linux": "~/.bashrc", + "macos": "~/.zshrc" + }, + "codexVsCodeConfigFiles": "config.toml 和 auth.json" + }, + + "snippets": { + "comments": { + "updateHomebrew": "# 更新 Homebrew", + "installNodeJs": "# 安装 Node.js", + "usingChocolatey": "# 使用 Chocolatey", + "orUsingScoop": "# 或使用 Scoop", + "addNodeSourceRepo": "# 添加 NodeSource 仓库", + "ubuntuDebian": "# Ubuntu/Debian", + "centosRhelFedora": "# CentOS/RHEL/Fedora", + "addToPathIfMissing": "# 添加到 PATH(如果不在)", + "checkEnvVar": "# 检查环境变量", + "testNetworkConnection": "# 测试网络连接" + } + }, + + "layout": { + "headerTitle": "使用文档", + "loginConsole": "登录控制台" + }, + "ui": { "mainContent": "文档内容", "main": "main", diff --git a/messages/zh-TW/auth.json b/messages/zh-TW/auth.json index f48160f9b..58da807c1 100644 --- a/messages/zh-TW/auth.json +++ b/messages/zh-TW/auth.json @@ -30,7 +30,7 @@ "solutionTitle": "解決方案:", "useHttps": "使用 HTTPS 存取(推薦)", "disableSecureCookies": "在 .env 中設定 ENABLE_SECURE_COOKIES=false(會降低安全性)", - "privacyNote": "請使用您的 API Key 登入 Claude Code Hub 後台" + "privacyNote": "請使用您的 API Key 登入後台" }, "errors": { "loginFailed": "登錄失敗", @@ -38,6 +38,9 @@ "invalidToken": "無效的認證令牌", "tokenRequired": "需要提供認證令牌", "sessionExpired": "會話已過期,請重新登錄", - "unauthorized": "未授權,請先登錄" + "unauthorized": "未授權,請先登錄", + "apiKeyRequired": "請輸入 API Key", + "apiKeyInvalidOrExpired": "API Key 無效或已過期", + "serverError": "登錄失敗,請稍後重試" } } diff --git a/messages/zh-TW/bigScreen.json b/messages/zh-TW/bigScreen.json index 6d92fbd4b..9f256e205 100644 --- a/messages/zh-TW/bigScreen.json +++ b/messages/zh-TW/bigScreen.json @@ -1,4 +1,6 @@ { + "pageTitle": "即時資料大屏 - Claude Code Hub", + "pageDescription": "Claude Code Hub 即時監控資料大屏", "title": "CLAUDE CODE HUB", "subtitle": "即時資料監控中台", "metrics": { diff --git a/messages/zh-TW/common.json b/messages/zh-TW/common.json index f8fbf0173..63f549c18 100644 --- a/messages/zh-TW/common.json +++ b/messages/zh-TW/common.json @@ -48,5 +48,15 @@ "theme": "主題", "light": "淺色", "dark": "深色", - "system": "跟隨系統" + "system": "跟隨系統", + "relativeTimeShort": { + "now": "剛剛", + "secondsAgo": "{count}秒前", + "minutesAgo": "{count}分前", + "hoursAgo": "{count}時前", + "daysAgo": "{count}天前", + "weeksAgo": "{count}週前", + "monthsAgo": "{count}月前", + "yearsAgo": "{count}年前" + } } diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 7fb6ace8b..f451f5884 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "使用者", "provider": "供應商", + "sessionId": "Session ID", "searchUser": "搜尋使用者...", "searchProvider": "搜尋供應商...", + "searchSessionId": "搜尋 Session ID...", "noUserFound": "未找到匹配的使用者", "noProviderFound": "未找到匹配的供應商", + "noSessionFound": "未找到匹配的 Session ID", "model": "Model", "endpoint": "端點", "status": "狀態", @@ -96,6 +99,7 @@ "time": "時間", "user": "使用者", "key": "金鑰", + "sessionId": "Session ID", "provider": "供應商", "model": "計費模型", "endpoint": "端點", @@ -138,14 +142,18 @@ "loadedCount": "已載入 {count} 筆記錄", "loadingMore": "載入更多中...", "noMoreData": "已載入全部記錄", - "scrollToTop": "回到頂端" + "scrollToTop": "回到頂端", + "hideProviderColumn": "隱藏供應商欄", + "showProviderColumn": "顯示供應商欄" }, "actions": { "refresh": "重新整理", "refreshing": "重新整理中...", "stopAutoRefresh": "停止自動重新整理", "startAutoRefresh": "啟用自動重新整理", - "view": "檢視" + "view": "檢視", + "fullscreen": "全螢幕顯示", + "exitFullscreen": "退出全螢幕" }, "error": { "loadFailed": "載入失敗", @@ -322,6 +330,10 @@ "adminAction": "開啟此權限。", "userAction": "請聯繫管理員開啟此權限。", "systemSettings": "系統設定" + }, + "filters": { + "userTagsPlaceholder": "按使用者標籤篩選...", + "userGroupsPlaceholder": "按使用者群組篩選..." } }, "sessions": { @@ -717,7 +729,8 @@ "limit5hUsd": { "label": "5小時消費上限(USD)", "placeholder": "留空表示無限制", - "description": "5小時內最大消費金額" + "description": "5小時內最大消費金額", + "descriptionWithUserLimit": "5小時內最大消費金額(使用者上限:{limit})" }, "limitDailyUsd": { "label": "每日消費上限(USD)", @@ -743,17 +756,26 @@ "limitWeeklyUsd": { "label": "週消費上限(USD)", "placeholder": "留空表示無限制", - "description": "每週最大消費金額" + "description": "每週最大消費金額", + "descriptionWithUserLimit": "每週最大消費金額(使用者上限:{limit})" }, "limitMonthlyUsd": { "label": "月消費上限(USD)", "placeholder": "留空表示無限制", - "description": "每月最大消費金額" + "description": "每月最大消費金額", + "descriptionWithUserLimit": "每月最大消費金額(使用者上限:{limit})" + }, + "limitTotalUsd": { + "label": "總消費上限(USD)", + "placeholder": "留空表示無限制", + "description": "累計消費上限(不重置)", + "descriptionWithUserLimit": "不能超過使用者總限額({limit})" }, "limitConcurrentSessions": { "label": "並發 Session 上限", "placeholder": "0 表示無限制", - "description": "同時執行的對話數量" + "description": "同時執行的對話數量", + "descriptionWithUserLimit": "最大 Session 數(使用者上限:{limit})" }, "providerGroup": { "label": "供應商分組", @@ -762,6 +784,15 @@ "defaultDescription": "default 分組包含所有未設定 groupTag 的供應商", "descriptionWithUserGroup": "供應商分組(預設:default;使用者分組:{group})" }, + "cacheTtl": { + "label": "Cache TTL 覆寫", + "description": "強制為包含 cache_control 的請求設定 Anthropic prompt cache TTL。", + "options": { + "inherit": "不覆寫(跟隨供應商/客戶端)", + "5m": "5m", + "1h": "1h" + } + }, "successTitle": "金鑰建立成功", "successDescription": "您的 API 金鑰已成功建立。", "generatedKey": { @@ -1120,18 +1151,21 @@ "name": "金鑰名稱", "key": "金鑰", "group": "分組", - "todayUsage": "今日使用量", + "todayUsage": "今日請求", "todayCost": "今日花費", + "todayTokens": "今日Token", "lastUsed": "最後使用", "actions": "動作", "quotaButton": "查看限額用量", "fields": { - "callsLabel": "今日呼叫", + "callsLabel": "請求", + "tokensLabel": "Token", "costLabel": "今日消耗" } }, "expand": "展開", "collapse": "摺疊", + "refresh": "重新整理", "noKeys": "無金鑰", "defaultGroup": "default", "userStatus": { @@ -1183,6 +1217,10 @@ "currentExpiry": "目前到期時間", "neverExpires": "永不過期", "expired": "已過期", + "quickExtensionLabel": "快速延期", + "quickExtensionHint": "從目前到期日延長(若已過期則從現在開始)", + "customDateLabel": "設定到期日", + "customDateHint": "直接指定到期日期", "quickOptions": { "7days": "7天", "30days": "30天", @@ -1191,6 +1229,7 @@ }, "customDate": "自訂日期", "enableOnRenew": "同時啟用使用者", + "enableKeyOnRenew": "同時啟用金鑰", "cancel": "取消續期", "confirm": "確認續期", "confirming": "續期中...", @@ -1217,7 +1256,18 @@ "userEnabled": "使用者已啟用", "deleteFailed": "刪除使用者失敗", "userDeleted": "使用者已刪除", - "saving": "儲存中..." + "saving": "儲存中...", + "resetData": { + "title": "重置統計", + "description": "刪除該使用者的所有請求日誌和使用資料。此操作不可逆。", + "error": "重置資料失敗", + "button": "重置統計", + "confirmTitle": "重置所有統計?", + "confirmDescription": "這將永久刪除該使用者的所有請求日誌和使用統計。此操作無法撤銷。", + "confirm": "是的,重置全部", + "loading": "重置中...", + "success": "所有統計已重置" + } }, "batchEdit": { "enterMode": "批量編輯", diff --git a/messages/zh-TW/myUsage.json b/messages/zh-TW/myUsage.json index b72b9ffc3..4c473efa0 100644 --- a/messages/zh-TW/myUsage.json +++ b/messages/zh-TW/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "金鑰", "userStats": "使用者", "noData": "所選時段無資料", - "unknownModel": "不明" + "unknownModel": "不明", + "modal": { + "requests": "請求", + "tokens": "個token", + "totalTokens": "總Token", + "cost": "費用", + "inputTokens": "輸入Token", + "outputTokens": "輸出Token", + "cacheWrite": "快取寫入", + "cacheRead": "快取讀取", + "cacheHitRate": "快取命中率", + "cacheTokens": "快取Token", + "performanceHigh": "高", + "performanceMedium": "中", + "performanceLow": "低" + } }, "accessRestrictions": { "title": "存取限制", diff --git a/messages/zh-TW/quota.json b/messages/zh-TW/quota.json index 501d1a6d5..8d2eb86c9 100644 --- a/messages/zh-TW/quota.json +++ b/messages/zh-TW/quota.json @@ -263,7 +263,8 @@ "limit5hUsd": { "label": "5小時消費上限 (USD)", "placeholder": "留空表示無限制", - "description": "5小時內最大消費金額" + "description": "5小時內最大消費金額", + "descriptionWithUserLimit": "不能超過使用者5小時限額 ({limit})" }, "limitDailyUsd": { "label": "每日消費上限 (USD)", @@ -289,17 +290,26 @@ "limitWeeklyUsd": { "label": "週消費上限 (USD)", "placeholder": "留空表示無限制", - "description": "每週最大消費金額" + "description": "每週最大消費金額", + "descriptionWithUserLimit": "不能超過使用者週限額 ({limit})" }, "limitMonthlyUsd": { "label": "月消費上限 (USD)", "placeholder": "留空表示無限制", - "description": "每月最大消費金額" + "description": "每月最大消費金額", + "descriptionWithUserLimit": "不能超過使用者月限額 ({limit})" + }, + "limitTotalUsd": { + "label": "總消費上限 (USD)", + "placeholder": "留空表示無限制", + "description": "累計消費上限(不重置)", + "descriptionWithUserLimit": "不能超過使用者總限額 ({limit})" }, "limitConcurrentSessions": { "label": "並發 Session 上限", "placeholder": "0 表示無限制", - "description": "同時運行的對話數量" + "description": "同時運行的對話數量", + "descriptionWithUserLimit": "不能超過使用者並發限額 ({limit})" }, "providerGroup": { "label": "供應商分組", diff --git a/messages/zh-TW/usage.json b/messages/zh-TW/usage.json index b12c046fb..ec5c9afc5 100644 --- a/messages/zh-TW/usage.json +++ b/messages/zh-TW/usage.json @@ -19,7 +19,7 @@ }, "codeExamples": { - "label": "代碼示例 - ${language}", + "label": "代碼示例 - {language}", "description": "點擊代碼塊可複製到剪貼板" }, @@ -202,7 +202,7 @@ "unix": { "temporary": "臨時設置(當前會話):", "permanent": "永久設置:", - "permanentNote": "添加到您的 shell 配置文件(${shellConfig}):" + "permanentNote": "添加到您的 shell 配置文件({shellConfig}):" } }, @@ -694,6 +694,35 @@ "linux": "Linux" }, + "placeholders": { + "windowsUserName": "你的用戶名", + "shellConfig": { + "linux": "~/.bashrc", + "macos": "~/.zshrc" + }, + "codexVsCodeConfigFiles": "config.toml 和 auth.json" + }, + + "snippets": { + "comments": { + "updateHomebrew": "# 更新 Homebrew", + "installNodeJs": "# 安裝 Node.js", + "usingChocolatey": "# 使用 Chocolatey", + "orUsingScoop": "# 或使用 Scoop", + "addNodeSourceRepo": "# 添加 NodeSource 倉庫", + "ubuntuDebian": "# Ubuntu/Debian", + "centosRhelFedora": "# CentOS/RHEL/Fedora", + "addToPathIfMissing": "# 添加到 PATH(如果不在)", + "checkEnvVar": "# 檢查環境變數", + "testNetworkConnection": "# 測試網路連線" + } + }, + + "layout": { + "headerTitle": "使用文檔", + "loginConsole": "登入控制台" + }, + "ui": { "mainContent": "文檔內容", "main": "main", diff --git a/package.json b/package.json index 8e6f5aacb..c6eae1017 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "test:e2e": "vitest run --config vitest.e2e.config.ts --reporter=verbose", "test:integration": "vitest run --config vitest.integration.config.ts --reporter=verbose", "test:coverage": "vitest run --coverage", + "test:coverage:logs-sessionid-time-filter": "vitest run --config vitest.logs-sessionid-time-filter.config.ts --coverage", "test:coverage:codex-session-id-completer": "vitest run --config vitest.codex-session-id-completer.config.ts --coverage", "test:coverage:thinking-signature-rectifier": "vitest run --config vitest.thinking-signature-rectifier.config.ts --coverage", "test:coverage:quota": "vitest run --config vitest.quota.config.ts --coverage", "test:coverage:my-usage": "vitest run --config vitest.my-usage.config.ts --coverage", "test:coverage:proxy-guard-pipeline": "vitest run --config vitest.proxy-guard-pipeline.config.ts --coverage", + "test:coverage:include-session-id-in-errors": "vitest run --config vitest.include-session-id-in-errors.config.ts --coverage", "test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=reports/vitest-junit.xml", "cui": "npx cui-server --host 0.0.0.0 --port 30000 --token a7564bc8882aa9a2d25d8b4ea6ea1e2e", "db:generate": "drizzle-kit generate && node scripts/validate-migrations.js", diff --git a/scripts/cleanup-test-users.sh b/scripts/cleanup-test-users.sh index b08015576..e46d50b84 100644 --- a/scripts/cleanup-test-users.sh +++ b/scripts/cleanup-test-users.sh @@ -1,10 +1,10 @@ #!/bin/bash # 清理测试用户脚本 -echo "🔍 检查测试用户数量..." +echo "检查测试用户数量..." # 统计测试用户 -docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " +docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " SELECT COUNT(*) as 测试用户数量 FROM users @@ -13,8 +13,8 @@ WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%') " echo "" -echo "📋 预览将要删除的用户(前 10 个)..." -docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " +echo "预览将要删除的用户(前 10 个)..." +docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " SELECT id, name, created_at FROM users WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%') @@ -24,13 +24,13 @@ LIMIT 10; " echo "" -read -p "⚠️ 确认删除这些测试用户吗?(y/N): " confirm +read -p "确认删除这些测试用户吗?(y/N): " confirm if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then - echo "🗑️ 开始清理..." + echo "开始清理..." # 软删除关联的 keys - docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " + docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " UPDATE keys SET deleted_at = NOW(), updated_at = NOW() WHERE user_id IN ( @@ -42,19 +42,19 @@ if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then " # 软删除测试用户 - docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " + docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%') AND deleted_at IS NULL; " - echo "✅ 清理完成!" + echo "清理完成!" echo "" - echo "📊 剩余用户统计:" - docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " + echo "剩余用户统计:" + docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " SELECT COUNT(*) as 总用户数 FROM users WHERE deleted_at IS NULL; " else - echo "❌ 取消清理" + echo "取消清理" fi diff --git a/scripts/run-e2e-tests.sh b/scripts/run-e2e-tests.sh index 891155a25..d0bbc2b7d 100644 --- a/scripts/run-e2e-tests.sh +++ b/scripts/run-e2e-tests.sh @@ -12,19 +12,21 @@ set -e # 遇到错误立即退出 -echo "🚀 E2E 测试运行脚本" +echo "E2E 测试运行脚本" echo "====================" echo "" # ==================== 1. 检查数据库连接 ==================== -echo "🔍 检查数据库连接..." -if docker ps | grep -q claude-code-hub-db-dev; then - echo "✅ PostgreSQL 已运行" +COMPOSE_FILE="docker-compose.dev.yaml" + +echo "检查数据库连接..." +if [ -n "$(docker compose -f "$COMPOSE_FILE" ps -q --status running postgres 2>/dev/null)" ]; then + echo "PostgreSQL 已运行" else - echo "❌ PostgreSQL 未运行,正在启动..." - docker compose up -d postgres redis - echo "⏳ 等待数据库启动..." + echo "PostgreSQL 未运行,正在启动..." + docker compose -f "$COMPOSE_FILE" up -d postgres redis + echo "等待数据库启动..." sleep 5 fi @@ -32,14 +34,14 @@ echo "" # ==================== 2. 启动开发服务器 ==================== -echo "🚀 启动 Next.js 开发服务器..." +echo "启动 Next.js 开发服务器..." # 后台启动服务器 PORT=13500 bun run dev > /tmp/nextjs-dev.log 2>&1 & SERVER_PID=$! -echo " 服务器 PID: $SERVER_PID" -echo "⏳ 等待服务器就绪..." +echo "服务器 PID: $SERVER_PID" +echo "等待服务器就绪..." # 等待服务器启动(最多等待 60 秒) TIMEOUT=60 @@ -47,7 +49,7 @@ COUNTER=0 while [ $COUNTER -lt $TIMEOUT ]; do if curl -s http://localhost:13500/api/actions/health > /dev/null 2>&1; then - echo "✅ 服务器已就绪" + echo "服务器已就绪" break fi @@ -58,7 +60,7 @@ done if [ $COUNTER -eq $TIMEOUT ]; then echo "" - echo "❌ 服务器启动超时" + echo "服务器启动超时" kill $SERVER_PID 2>/dev/null || true exit 1 fi @@ -67,7 +69,7 @@ echo "" # ==================== 3. 运行 E2E 测试 ==================== -echo "🧪 运行 E2E 测试..." +echo "运行 E2E 测试..." echo "" # 设置环境变量 @@ -83,19 +85,19 @@ echo "" # ==================== 4. 清理并停止服务器 ==================== -echo "🧹 停止开发服务器..." +echo "停止开发服务器..." kill $SERVER_PID 2>/dev/null || true wait $SERVER_PID 2>/dev/null || true -echo "✅ 服务器已停止" +echo "服务器已停止" echo "" # ==================== 5. 输出测试结果 ==================== if [ $TEST_EXIT_CODE -eq 0 ]; then - echo "✅ E2E 测试全部通过" + echo "E2E 测试全部通过" exit 0 else - echo "❌ E2E 测试失败" + echo "E2E 测试失败" exit $TEST_EXIT_CODE fi diff --git a/src/actions/key-quota.ts b/src/actions/key-quota.ts index 38e186979..39e5ec604 100644 --- a/src/actions/key-quota.ts +++ b/src/actions/key-quota.ts @@ -1,6 +1,7 @@ "use server"; import { and, eq, isNull } from "drizzle-orm"; +import { getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; import { keys as keysTable } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; @@ -8,6 +9,7 @@ import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit/service"; import { SessionTracker } from "@/lib/session-tracker"; import type { CurrencyCode } from "@/lib/utils"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; import { getSystemSettings } from "@/repository/system-config"; import { getTotalUsageForKey } from "@/repository/usage-logs"; import type { ActionResult } from "./types"; @@ -27,11 +29,23 @@ export interface KeyQuotaUsageResult { } export async function getKeyQuotaUsage(keyId: number): Promise> { + let tError: ((key: string, params?: Record) => string) | null = null; try { - const session = await getSession(); - if (!session) return { ok: false, error: "Unauthorized" }; - if (session.user.role !== "admin") { - return { ok: false, error: "Admin access required" }; + tError = await getTranslations("errors"); + } catch (error) { + logger.warn("[key-quota] failed to load errors translations", { + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + const session = await getSession({ allowReadOnlyAccess: true }); + if (!session) { + return { + ok: false, + error: tError?.("UNAUTHORIZED") ?? "", + errorCode: ERROR_CODES.UNAUTHORIZED, + }; } const [keyRow] = await db @@ -41,7 +55,20 @@ export async function getKeyQuotaUsage(keyId: number): Promise { + return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) ? { ok: true, value: dateStr } : { ok: false }; + }; + + const addIsoDays = (dateStr: string, days: number): string => { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr); + if (!match) { + return dateStr; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + + const next = new Date(Date.UTC(year, month - 1, day)); + next.setUTCDate(next.getUTCDate() + days); + return next.toISOString().slice(0, 10); + }; + + const startIso = startDate ? toIsoDate(startDate) : { ok: false as const }; + const endIso = endDate ? toIsoDate(endDate) : { ok: false as const }; + + const parsedStart = startIso.ok + ? fromZonedTime(`${startIso.value}T00:00:00`, timezone).getTime() + : Number.NaN; + + const endExclusiveDate = endIso.ok ? addIsoDays(endIso.value, 1) : null; + const parsedEndExclusive = endExclusiveDate + ? fromZonedTime(`${endExclusiveDate}T00:00:00`, timezone).getTime() + : Number.NaN; + + return { + startTime: Number.isFinite(parsedStart) ? parsedStart : undefined, + endTime: Number.isFinite(parsedEndExclusive) ? parsedEndExclusive : undefined, + }; +} + export interface MyUsageMetadata { keyName: string; keyProviderGroup: string | null; @@ -395,16 +444,10 @@ export async function getMyUsageLogs( const pageSize = Math.min(rawPageSize, 100); const page = filters.page && filters.page > 0 ? filters.page : 1; - const parsedStart = filters.startDate - ? new Date(`${filters.startDate}T00:00:00`).getTime() - : Number.NaN; - const parsedEnd = filters.endDate - ? new Date(`${filters.endDate}T00:00:00`).getTime() - : Number.NaN; - - const startTime = Number.isFinite(parsedStart) ? parsedStart : undefined; - // endTime 使用“次日零点”作为排他上界(created_at < endTime),避免 23:59:59.999 的边界问题 - const endTime = Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined; + const { startTime, endTime } = parseDateRangeInServerTimezone( + filters.startDate, + filters.endDate + ); const usageFilters: UsageLogFilters = { keyId: session.key.id, @@ -519,6 +562,8 @@ export interface ModelBreakdownItem { cost: number; inputTokens: number; outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; } export interface MyStatsSummary extends UsageLogSummary { @@ -541,16 +586,10 @@ export async function getMyStatsSummary( const settings = await getSystemSettings(); const currencyCode = settings.currencyDisplay; - // 日期字符串来自前端的 YYYY-MM-DD(目前使用 toISOString().split("T")[0] 生成),因此按 UTC 解析更一致。 - // 注意:new Date("YYYY-MM-DDT00:00:00") 会按本地时区解析,可能导致跨时区边界偏移。 - const parsedStart = filters.startDate - ? Date.parse(`${filters.startDate}T00:00:00.000Z`) - : Number.NaN; - const parsedEnd = filters.endDate ? Date.parse(`${filters.endDate}T00:00:00.000Z`) : Number.NaN; - - const startTime = Number.isFinite(parsedStart) ? parsedStart : undefined; - // endTime 使用“次日零点”作为排他上界(created_at < endTime),避免 23:59:59.999 的边界问题 - const endTime = Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined; + const { startTime, endTime } = parseDateRangeInServerTimezone( + filters.startDate, + filters.endDate + ); // Get aggregated stats using existing repository function const stats = await findUsageLogsStats({ @@ -567,6 +606,8 @@ export async function getMyStatsSummary( cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, + cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`, + cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`, }) .from(messageRequest) .where( @@ -589,6 +630,8 @@ export async function getMyStatsSummary( cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, + cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`, + cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`, }) .from(messageRequest) .where( @@ -611,6 +654,8 @@ export async function getMyStatsSummary( cost: Number(row.cost ?? 0), inputTokens: row.inputTokens, outputTokens: row.outputTokens, + cacheCreationTokens: row.cacheCreationTokens, + cacheReadTokens: row.cacheReadTokens, })), userModelBreakdown: userBreakdown.map((row) => ({ model: row.model, @@ -618,6 +663,8 @@ export async function getMyStatsSummary( cost: Number(row.cost ?? 0), inputTokens: row.inputTokens, outputTokens: row.outputTokens, + cacheCreationTokens: row.cacheCreationTokens, + cacheReadTokens: row.cacheReadTokens, })), currencyCode, }; diff --git a/src/actions/usage-logs.ts b/src/actions/usage-logs.ts index 0c8a9e079..b0d612b0a 100644 --- a/src/actions/usage-logs.ts +++ b/src/actions/usage-logs.ts @@ -1,8 +1,14 @@ "use server"; import { getSession } from "@/lib/auth"; +import { + SESSION_ID_SUGGESTION_LIMIT, + SESSION_ID_SUGGESTION_MAX_LEN, + SESSION_ID_SUGGESTION_MIN_LEN, +} from "@/lib/constants/usage-logs.constants"; import { logger } from "@/lib/logger"; import { + findUsageLogSessionIdSuggestions, findUsageLogsBatch, findUsageLogsStats, findUsageLogsWithDetails, @@ -279,6 +285,53 @@ export async function getFilterOptions(): Promise> { } } +export interface UsageLogSessionIdSuggestionInput { + term: string; + userId?: number; + keyId?: number; + providerId?: number; +} + +export async function getUsageLogSessionIdSuggestions( + input: UsageLogSessionIdSuggestionInput +): Promise> { + try { + const session = await getSession(); + if (!session) { + return { ok: false, error: "未登录" }; + } + + const trimmedTerm = input.term.trim().slice(0, SESSION_ID_SUGGESTION_MAX_LEN); + if (trimmedTerm.length < SESSION_ID_SUGGESTION_MIN_LEN) { + return { ok: true, data: [] }; + } + + const finalFilters = + session.user.role === "admin" + ? { + term: trimmedTerm, + userId: input.userId, + keyId: input.keyId, + providerId: input.providerId, + limit: SESSION_ID_SUGGESTION_LIMIT, + } + : { + term: trimmedTerm, + userId: session.user.id, + keyId: input.keyId, + providerId: input.providerId, + limit: SESSION_ID_SUGGESTION_LIMIT, + }; + + const sessionIds = await findUsageLogSessionIdSuggestions(finalFilters); + return { ok: true, data: sessionIds }; + } catch (error) { + logger.error("获取 sessionId 联想失败:", error); + const message = error instanceof Error ? error.message : "获取 sessionId 联想失败"; + return { ok: false, error: message }; + } +} + /** * 获取使用日志聚合统计(独立接口,用于可折叠面板按需加载) * diff --git a/src/actions/users.ts b/src/actions/users.ts index 3d97a9374..988b37c51 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -1,11 +1,11 @@ "use server"; import { randomBytes } from "node:crypto"; -import { and, inArray, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getLocale, getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; -import { users as usersTable } from "@/drizzle/schema"; +import { messageRequest, users as usersTable } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; @@ -210,7 +210,12 @@ export async function getUsers(): Promise { const usageRecords = usageMap.get(user.id) || []; const keyStatistics = statisticsMap.get(user.id) || []; - const usageLookup = new Map(usageRecords.map((item) => [item.keyId, item.totalCost ?? 0])); + const usageLookup = new Map( + usageRecords.map((item) => [ + item.keyId, + { totalCost: item.totalCost ?? 0, totalTokens: item.totalTokens ?? 0 }, + ]) + ); const statisticsLookup = new Map(keyStatistics.map((stat) => [stat.keyId, stat])); return { @@ -256,7 +261,8 @@ export async function getUsers(): Promise { minute: "2-digit", second: "2-digit", }), - todayUsage: usageLookup.get(key.id) ?? 0, + todayUsage: usageLookup.get(key.id)?.totalCost ?? 0, + todayTokens: usageLookup.get(key.id)?.totalTokens ?? 0, todayCallCount: stats?.todayCallCount ?? 0, lastUsedAt: stats?.lastUsedAt ?? null, lastProviderName: stats?.lastProviderName ?? null, @@ -473,7 +479,12 @@ export async function getUsersBatch( const usageRecords = usageMap.get(user.id) || []; const keyStatistics = statisticsMap.get(user.id) || []; - const usageLookup = new Map(usageRecords.map((item) => [item.keyId, item.totalCost ?? 0])); + const usageLookup = new Map( + usageRecords.map((item) => [ + item.keyId, + { totalCost: item.totalCost ?? 0, totalTokens: item.totalTokens ?? 0 }, + ]) + ); const statisticsLookup = new Map(keyStatistics.map((stat) => [stat.keyId, stat])); return { @@ -517,7 +528,8 @@ export async function getUsersBatch( minute: "2-digit", second: "2-digit", }), - todayUsage: usageLookup.get(key.id) ?? 0, + todayUsage: usageLookup.get(key.id)?.totalCost ?? 0, + todayTokens: usageLookup.get(key.id)?.totalTokens ?? 0, todayCallCount: stats?.todayCallCount ?? 0, lastUsedAt: stats?.lastUsedAt ?? null, lastProviderName: stats?.lastProviderName ?? null, @@ -1496,3 +1508,115 @@ export async function getUserAllLimitUsage(userId: number): Promise< return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED }; } } + +/** + * Reset ALL user statistics (logs + Redis cache + sessions) + * This is IRREVERSIBLE - deletes all messageRequest logs for the user + * + * Admin only. + */ +export async function resetUserAllStatistics(userId: number): Promise { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const user = await findUserById(userId); + if (!user) { + return { ok: false, error: tError("USER_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND }; + } + + // Get user's keys + const keys = await findKeyList(userId); + const keyIds = keys.map((k) => k.id); + + // 1. Delete all messageRequest logs for this user + await db.delete(messageRequest).where(eq(messageRequest.userId, userId)); + + // 2. Clear Redis cache + const { getRedisClient } = await import("@/lib/redis"); + const { scanPattern } = await import("@/lib/redis/scan-helper"); + const redis = getRedisClient(); + + if (redis && redis.status === "ready") { + try { + const startTime = Date.now(); + + // Scan all patterns in parallel + const scanResults = await Promise.all([ + ...keyIds.map((keyId) => + scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { + logger.warn("Failed to scan key cost pattern", { keyId, error: err }); + return []; + }) + ), + scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { + logger.warn("Failed to scan user cost pattern", { userId, error: err }); + return []; + }), + ]); + + const allCostKeys = scanResults.flat(); + + // Batch delete via pipeline + const pipeline = redis.pipeline(); + + // Active sessions + for (const keyId of keyIds) { + pipeline.del(`key:${keyId}:active_sessions`); + } + + // Cost keys + for (const key of allCostKeys) { + pipeline.del(key); + } + + const results = await pipeline.exec(); + + // Check for errors + const errors = results?.filter(([err]) => err); + if (errors && errors.length > 0) { + logger.warn("Some Redis deletes failed during user statistics reset", { + errorCount: errors.length, + userId, + }); + } + + const duration = Date.now() - startTime; + logger.info("Reset user statistics - Redis cache cleared", { + userId, + keyCount: keyIds.length, + costKeysDeleted: allCostKeys.length, + activeSessionsDeleted: keyIds.length, + durationMs: duration, + }); + } catch (error) { + logger.error("Failed to clear Redis cache during user statistics reset", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + // Continue execution - DB logs already deleted + } + } + + logger.info("Reset all user statistics", { userId, keyCount: keyIds.length }); + revalidatePath("/dashboard/users"); + + return { ok: true }; + } catch (error) { + logger.error("Failed to reset all user statistics:", error); + const tError = await getTranslations("errors"); + return { + ok: false, + error: tError("OPERATION_FAILED"), + errorCode: ERROR_CODES.OPERATION_FAILED, + }; + } +} diff --git a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx index ab5f45392..f1afa32db 100644 --- a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx @@ -70,14 +70,14 @@ export function AddKeyDialog({ return ( - + {generatedKey ? ( <> - + {t("successTitle")} {t("successDescription")} -
+
@@ -106,11 +106,11 @@ export function AddKeyDialog({

{t("generatedKey.hint")}

-
- -
+
+
+
) : ( diff --git a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx index 8bc5a3e5d..be23ff43f 100644 --- a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx @@ -376,6 +376,8 @@ function BatchEditDialogInner({ if (anySuccess) { await queryClient.invalidateQueries({ queryKey: ["users"] }); + await queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + await queryClient.invalidateQueries({ queryKey: ["userTags"] }); } // Only close dialog and clear selection when fully successful diff --git a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx index 884745ab8..ea543338f 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx @@ -52,7 +52,7 @@ export function EditKeyDialog({ return ( - + {t("title")} {t("description")} diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index c3e2a4686..5c963c72c 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -1,14 +1,25 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, UserCog } from "lucide-react"; +import { Loader2, Trash2, UserCog } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useMemo, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; -import { editUser, removeUser, toggleUserEnabled } from "@/actions/users"; -import { Button } from "@/components/ui/button"; +import { editUser, removeUser, resetUserAllStatistics, toggleUserEnabled } from "@/actions/users"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -18,6 +29,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useZodForm } from "@/lib/hooks/use-zod-form"; +import { cn } from "@/lib/utils"; import { UpdateUserSchema } from "@/lib/validation/schemas"; import type { UserDisplay } from "@/types/user"; import { DangerZone } from "./forms/danger-zone"; @@ -71,6 +83,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const t = useTranslations("dashboard.userManagement"); const tCommon = useTranslations("common"); const [isPending, startTransition] = useTransition(); + const [isResettingAll, setIsResettingAll] = useState(false); + const [resetAllDialogOpen, setResetAllDialogOpen] = useState(false); // Always show providerGroup field in edit mode const userEditTranslations = useUserTranslations({ showProviderGroup: true }); @@ -110,6 +124,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr onSuccess?.(); onOpenChange(false); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); } catch (error) { console.error("[EditUserDialog] submit failed", error); @@ -161,6 +177,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr toast.success(t("editDialog.userDisabled")); onSuccess?.(); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); } catch (error) { console.error("[EditUserDialog] disable user failed", error); @@ -178,6 +196,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr toast.success(t("editDialog.userEnabled")); onSuccess?.(); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); } catch (error) { console.error("[EditUserDialog] enable user failed", error); @@ -194,9 +214,32 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr onSuccess?.(); onOpenChange(false); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); }; + const handleResetAllStatistics = async () => { + setIsResettingAll(true); + try { + const res = await resetUserAllStatistics(user.id); + if (!res.ok) { + toast.error(res.error || t("editDialog.resetData.error")); + return; + } + toast.success(t("editDialog.resetData.success")); + setResetAllDialogOpen(false); + + // Full page reload to ensure all cached data is refreshed + window.location.reload(); + } catch (error) { + console.error("[EditUserDialog] reset all statistics failed", error); + toast.error(t("editDialog.resetData.error")); + } finally { + setIsResettingAll(false); + } + }; + return (
@@ -243,6 +286,59 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr modelSuggestions={modelSuggestions} /> + {/* Reset Data Section - Admin Only */} +
+
+
+

+ {t("editDialog.resetData.title")} +

+

+ {t("editDialog.resetData.description")} +

+
+ + + + + + + + {t("editDialog.resetData.confirmTitle")} + + {t("editDialog.resetData.confirmDescription")} + + + + + {tCommon("cancel")} + + { + e.preventDefault(); + handleResetAllStatistics(); + }} + disabled={isResettingAll} + className={cn(buttonVariants({ variant: "destructive" }))} + > + {isResettingAll ? ( + <> + + {t("editDialog.resetData.loading")} + + ) : ( + t("editDialog.resetData.confirm") + )} + + + + +
+
+
- + -

- 强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。 -

+

{t("cacheTtl.description")}

diff --git a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx index ac409840a..ba6bb2ab7 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx @@ -1,4 +1,5 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState, useTransition } from "react"; @@ -49,6 +50,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK const [isPending, startTransition] = useTransition(); const [providerGroupSuggestions, setProviderGroupSuggestions] = useState([]); const router = useRouter(); + const queryClient = useQueryClient(); const t = useTranslations("quota.keys.editKeyForm"); const tKeyEdit = useTranslations("dashboard.userManagement.keyEditSection.fields"); const tBalancePage = useTranslations( @@ -128,6 +130,8 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK return; } toast.success(t("success")); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); onSuccess?.(); router.refresh(); } catch (err) { diff --git a/src/app/[locale]/dashboard/_components/user/key-list.tsx b/src/app/[locale]/dashboard/_components/user/key-list.tsx index ff2939920..089acb827 100644 --- a/src/app/[locale]/dashboard/_components/user/key-list.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-list.tsx @@ -236,7 +236,7 @@ export function KeyList({ {record.lastUsedAt ? ( <>
- +
{record.lastProviderName && (
diff --git a/src/app/[locale]/dashboard/_components/user/key-row-item.tsx b/src/app/[locale]/dashboard/_components/user/key-row-item.tsx index e2ed9e18b..24f752bc8 100644 --- a/src/app/[locale]/dashboard/_components/user/key-row-item.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-row-item.tsx @@ -1,6 +1,16 @@ "use client"; -import { BarChart3, Copy, Eye, FileText, Info, Pencil, Trash2 } from "lucide-react"; +import { + Activity, + BarChart3, + Coins, + Copy, + Eye, + FileText, + Info, + Pencil, + Trash2, +} from "lucide-react"; import { useRouter } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState } from "react"; @@ -25,6 +35,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { cn } from "@/lib/utils"; import { CURRENCY_CONFIG, type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; import { formatDate } from "@/lib/utils/date-format"; +import { formatTokenAmount } from "@/lib/utils/token"; import { type QuickRenewKey, QuickRenewKeyDialog } from "./forms/quick-renew-key-dialog"; import { KeyFullDisplayDialog } from "./key-full-display-dialog"; import { KeyQuotaUsageDialog } from "./key-quota-usage-dialog"; @@ -40,6 +51,7 @@ export interface KeyRowItemProps { providerGroup?: string | null; todayUsage: number; todayCallCount: number; + todayTokens: number; lastUsedAt: Date | null; expiresAt: string; status: "enabled" | "disabled"; @@ -67,9 +79,11 @@ export interface KeyRowItemProps { group: string; todayUsage: string; todayCost: string; + todayTokens: string; lastUsed: string; actions: string; callsLabel: string; + tokensLabel: string; costLabel: string; }; actions: { @@ -180,7 +194,6 @@ export function KeyRowItem({ // 计算 key 过期状态 const keyExpiryStatus = getKeyExpiryStatus(localStatus, localExpiresAt); const remainingGroups = Math.max(0, effectiveGroups.length - visibleGroups.length); - const effectiveGroupText = effectiveGroups.join(", "); const canReveal = Boolean(keyData.fullKey); const canCopy = Boolean(keyData.canCopy && keyData.fullKey); @@ -302,8 +315,8 @@ export function KeyRowItem({ className={cn( "grid items-center gap-3 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/40 transition-colors", isMultiSelectMode - ? "grid-cols-[24px_2fr_3fr_3fr_1fr_2fr_1.5fr_1.5fr_1.5fr]" - : "grid-cols-[2fr_3fr_2.5fr_1fr_2fr_1.5fr_1.5fr_1.5fr]", + ? "grid-cols-[24px_2fr_3fr_2.5fr_1.2fr_1.2fr_1.2fr_1.2fr_1.2fr_1.5fr]" + : "grid-cols-[2fr_3fr_2.5fr_1.2fr_1.2fr_1.2fr_1.2fr_1.2fr_1.5fr]", highlight && "bg-primary/10 ring-1 ring-primary/30" )} > @@ -398,17 +411,12 @@ export function KeyRowItem({ key={group} variant="outline" className="text-xs font-mono max-w-[120px] truncate" - title={group} > {group} ))} {remainingGroups > 0 ? ( - + +{remainingGroups} ) : null} @@ -421,9 +429,11 @@ export function KeyRowItem({
-

- {effectiveGroupText} -

+
    + {effectiveGroups.map((group) => ( +
  • {group}
  • + ))} +
@@ -434,23 +444,28 @@ export function KeyRowItem({ className="text-right tabular-nums flex items-center justify-end gap-1" title={translations.fields.todayUsage} > - {translations.fields.callsLabel}: + {Number(keyData.todayCallCount || 0).toLocaleString()} - {/* 今日消耗(成本) */} + {/* 今日Token数 */}
- {translations.fields.costLabel}: - {formatCurrency(keyData.todayUsage || 0, resolvedCurrencyCode)} + + {formatTokenAmount(keyData.todayTokens || 0)} +
+ + {/* 今日消耗(成本) */} +
+ {formatCurrency(keyData.todayUsage || 0, resolvedCurrencyCode)}
{/* 最后使用 */}
{keyData.lastUsedAt ? ( - + ) : ( - )} diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx index 5be783d78..34330864b 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx @@ -1,7 +1,16 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, ChevronRight, Plus, SquarePen } from "lucide-react"; +import { + CheckCircle2, + ChevronDown, + ChevronRight, + CircleOff, + Clock, + Plus, + SquarePen, + XCircle, +} from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -209,6 +218,8 @@ export function UserKeyTableRow({ return; } toast.success(checked ? tUserStatus("userEnabled") : tUserStatus("userDisabled")); + // 使 React Query 缓存失效,确保数据刷新 + queryClient.invalidateQueries({ queryKey: ["users"] }); // 刷新服务端数据 router.refresh(); } catch (error) { @@ -267,30 +278,68 @@ export function UserKeyTableRow({ {isExpanded ? translations.collapse : translations.expand} + + + + {expiryStatus.label === "active" && ( + + )} + {expiryStatus.label === "disabled" && ( + + )} + {expiryStatus.label === "expiringSoon" && ( + + )} + {expiryStatus.label === "expired" && ( + + )} + + + {tUserStatus(expiryStatus.label)} + {user.name} - - {tUserStatus(expiryStatus.label)} - - {visibleGroups.map((group) => { - const bgColor = getGroupColor(group); - return ( - - {group} - - ); - })} - {remainingGroupsCount > 0 && ( - - +{remainingGroupsCount} - - )} + {userGroups.length > 0 ? ( + + +
+ {visibleGroups.map((group) => { + if (group.toLowerCase() === "default") { + return ( + + {group} + + ); + } + const bgColor = getGroupColor(group); + return ( + + {group} + + ); + })} + {remainingGroupsCount > 0 && ( + + +{remainingGroupsCount} + + )} +
+
+ +
    + {userGroups.map((group) => ( +
  • {group}
  • + ))} +
+
+
+ ) : null} {user.tags && user.tags.length > 0 && ( [{user.tags.join(", ")}] @@ -453,6 +502,7 @@ export function UserKeyTableRow({ providerGroup: key.providerGroup, todayUsage: key.todayUsage, todayCallCount: key.todayCallCount, + todayTokens: key.todayTokens, lastUsedAt: key.lastUsedAt, expiresAt: key.expiresAt, status: key.status, diff --git a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx index 948f92349..0c6601941 100644 --- a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx @@ -1,7 +1,7 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Users } from "lucide-react"; +import { Loader2, RefreshCw, Users } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -91,6 +91,8 @@ export interface UserManagementTableProps { failed: string; }; }; + onRefresh?: () => void; + isRefreshing?: boolean; } const USER_ROW_HEIGHT = 52; @@ -124,6 +126,8 @@ export function UserManagementTable({ onSelectKey, onOpenBatchEdit, translations, + onRefresh, + isRefreshing, }: UserManagementTableProps) { const router = useRouter(); const queryClient = useQueryClient(); @@ -422,6 +426,20 @@ export function UserManagementTable({ /> ) : null}
+ + {onRefresh ? ( + + ) : null}
diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 89314fd40..6fd4121b6 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -3,10 +3,12 @@ import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; +import { getAllUserKeyGroups, getAllUserTags } from "@/actions/users"; import { ProviderTypeFilter } from "@/app/[locale]/settings/providers/_components/provider-type-filter"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { TagInput } from "@/components/ui/tag-input"; import { formatTokenAmount } from "@/lib/utils"; import type { DateRangeParams, @@ -51,10 +53,29 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const [period, setPeriod] = useState(initialPeriod); const [dateRange, setDateRange] = useState(undefined); const [providerTypeFilter, setProviderTypeFilter] = useState("all"); + const [userTagFilters, setUserTagFilters] = useState([]); + const [userGroupFilters, setUserGroupFilters] = useState([]); + const [tagSuggestions, setTagSuggestions] = useState([]); + const [groupSuggestions, setGroupSuggestions] = useState([]); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + useEffect(() => { + if (!isAdmin) return; + + const fetchSuggestions = async () => { + const [tagsResult, groupsResult] = await Promise.all([ + getAllUserTags(), + getAllUserKeyGroups(), + ]); + if (tagsResult.ok) setTagSuggestions(tagsResult.data); + if (groupsResult.ok) setGroupSuggestions(groupsResult.data); + }; + + fetchSuggestions(); + }, [isAdmin]); + // 与 URL 查询参数保持同步,支持外部携带 scope/period 直达特定榜单 // biome-ignore lint/correctness/useExhaustiveDependencies: period 和 scope 仅用于比较,不应触发 effect 重新执行 useEffect(() => { @@ -96,6 +117,14 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ) { url += `&providerType=${encodeURIComponent(providerTypeFilter)}`; } + if (scope === "user") { + if (userTagFilters.length > 0) { + url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`; + } + if (userGroupFilters.length > 0) { + url += `&userGroups=${encodeURIComponent(userGroupFilters.join(","))}`; + } + } const res = await fetch(url); if (!res.ok) { @@ -120,7 +149,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { return () => { cancelled = true; }; - }, [scope, period, dateRange, providerTypeFilter, t]); + }, [scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters, t]); const handlePeriodChange = useCallback( (newPeriod: LeaderboardPeriod, newDateRange?: DateRangeParams) => { @@ -270,9 +299,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { { header: t("columns.totalTokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).totalTokens), - sortKey: "totalTokens", - getValue: (row) => (row as ProviderCacheHitRateEntry).totalTokens, + cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).totalInputTokens), + sortKey: "totalInputTokens", + getValue: (row) => (row as ProviderCacheHitRateEntry).totalInputTokens, }, ]; @@ -369,6 +398,37 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ) : null}
+ {scope === "user" && isAdmin && ( +
+
+ tagSuggestions.length === 0 || tagSuggestions.includes(tag)} + /> +
+
+ groupSuggestions.length === 0 || groupSuggestions.includes(tag)} + /> +
+
+ )} + {/* Date range picker with quick period buttons */}
([]); + const debouncedSessionIdSearchTerm = useDebounce(localFilters.sessionId ?? "", 300); + const sessionIdSearchRequestIdRef = useRef(0); + const lastLoadedSessionIdSuggestionsKeyRef = useRef(undefined); useEffect(() => { isMountedRef.current = true; @@ -181,6 +194,84 @@ export function UsageLogsFilters({ } }, [isAdmin, userPopoverOpen]); + const loadSessionIdsForFilter = useCallback( + async (term: string) => { + const requestId = ++sessionIdSearchRequestIdRef.current; + setIsSessionIdsLoading(true); + const requestKey = [ + term, + isAdmin ? (localFilters.userId ?? "").toString() : "", + (localFilters.keyId ?? "").toString(), + (localFilters.providerId ?? "").toString(), + isAdmin ? "1" : "0", + ].join("|"); + lastLoadedSessionIdSuggestionsKeyRef.current = requestKey; + + try { + const result = await getUsageLogSessionIdSuggestions({ + term, + userId: isAdmin ? localFilters.userId : undefined, + keyId: localFilters.keyId, + providerId: localFilters.providerId, + }); + + if (!isMountedRef.current || requestId !== sessionIdSearchRequestIdRef.current) return; + + if (result.ok) { + setAvailableSessionIds(result.data); + } else { + console.error("Failed to load sessionId suggestions:", result.error); + setAvailableSessionIds([]); + } + } catch (error) { + if (!isMountedRef.current || requestId !== sessionIdSearchRequestIdRef.current) return; + console.error("Failed to load sessionId suggestions:", error); + setAvailableSessionIds([]); + } finally { + if (isMountedRef.current && requestId === sessionIdSearchRequestIdRef.current) { + setIsSessionIdsLoading(false); + } + } + }, + [isAdmin, localFilters.keyId, localFilters.providerId, localFilters.userId] + ); + + useEffect(() => { + if (!sessionIdPopoverOpen) return; + + const term = debouncedSessionIdSearchTerm.trim(); + if (term.length < SESSION_ID_SUGGESTION_MIN_LEN) { + setAvailableSessionIds([]); + lastLoadedSessionIdSuggestionsKeyRef.current = undefined; + return; + } + + const requestKey = [ + term, + isAdmin ? (localFilters.userId ?? "").toString() : "", + (localFilters.keyId ?? "").toString(), + (localFilters.providerId ?? "").toString(), + isAdmin ? "1" : "0", + ].join("|"); + if (requestKey === lastLoadedSessionIdSuggestionsKeyRef.current) return; + void loadSessionIdsForFilter(term); + }, [ + sessionIdPopoverOpen, + debouncedSessionIdSearchTerm, + isAdmin, + localFilters.userId, + localFilters.keyId, + localFilters.providerId, + loadSessionIdsForFilter, + ]); + + useEffect(() => { + if (!sessionIdPopoverOpen) { + setAvailableSessionIds([]); + lastLoadedSessionIdSuggestionsKeyRef.current = undefined; + } + }, [sessionIdPopoverOpen]); + useEffect(() => { if (initialKeys.length > 0) { setKeys(initialKeys); @@ -228,7 +319,33 @@ export function UsageLogsFilters({ }; const handleApply = () => { - onChange(localFilters); + const { + userId, + keyId, + providerId, + sessionId, + startTime, + endTime, + statusCode, + excludeStatusCode200, + model, + endpoint, + minRetryCount, + } = localFilters; + + onChange({ + userId, + keyId, + providerId, + sessionId, + startTime, + endTime, + statusCode, + excludeStatusCode200, + model, + endpoint, + minRetryCount, + }); }; const handleReset = () => { @@ -272,24 +389,28 @@ export function UsageLogsFilters({ return format(date, "yyyy-MM-dd"); }, []); - // Helper: parse date string to timestamp (start of day in browser timezone) - const dateStringToTimestamp = useCallback((dateStr: string): number => { - const [year, month, day] = dateStr.split("-").map(Number); - return new Date(year, month - 1, day, 0, 0, 0, 0).getTime(); - }, []); - // Memoized startDate for display (from timestamp) const displayStartDate = useMemo(() => { if (!localFilters.startTime) return undefined; return timestampToDateString(localFilters.startTime); }, [localFilters.startTime, timestampToDateString]); - // Memoized endDate calculation: endTime is next day 00:00, subtract 1 day to show correct end date + const displayStartClock = useMemo(() => { + if (!localFilters.startTime) return undefined; + return formatClockFromTimestamp(localFilters.startTime); + }, [localFilters.startTime]); + + // Memoized endDate calculation: endTime is exclusive, use endTime-1s to infer inclusive display end date const displayEndDate = useMemo(() => { if (!localFilters.endTime) return undefined; - // endTime is next day 00:00, so subtract 1 day to get actual end date - const actualEndDate = new Date(localFilters.endTime - 24 * 60 * 60 * 1000); - return format(actualEndDate, "yyyy-MM-dd"); + const inclusiveEndTime = inclusiveEndTimestampFromExclusive(localFilters.endTime); + return format(new Date(inclusiveEndTime), "yyyy-MM-dd"); + }, [localFilters.endTime]); + + const displayEndClock = useMemo(() => { + if (!localFilters.endTime) return undefined; + const inclusiveEndTime = inclusiveEndTimestampFromExclusive(localFilters.endTime); + return formatClockFromTimestamp(inclusiveEndTime); }, [localFilters.endTime]); // Memoized callback for date range changes @@ -297,20 +418,21 @@ export function UsageLogsFilters({ (range: { startDate?: string; endDate?: string }) => { if (range.startDate && range.endDate) { // Convert to millisecond timestamps: - // startTime: start of selected start date (00:00:00.000 in browser timezone) - // endTime: start of day AFTER selected end date (for < comparison) - const startTimestamp = dateStringToTimestamp(range.startDate); - const endDate = parse(range.endDate, "yyyy-MM-dd", new Date()); - const nextDay = addDays(endDate, 1); - const endTimestamp = new Date( - nextDay.getFullYear(), - nextDay.getMonth(), - nextDay.getDate(), - 0, - 0, - 0, - 0 - ).getTime(); + // startTime: startDate + startClock (default 00:00:00) + // endTime: endDate + endClock as exclusive upper bound (endClock default 23:59:59) + const startClock = displayStartClock ?? "00:00:00"; + const endClock = displayEndClock ?? "23:59:59"; + const startTimestamp = dateStringWithClockToTimestamp(range.startDate, startClock); + const endInclusiveTimestamp = dateStringWithClockToTimestamp(range.endDate, endClock); + if (startTimestamp === undefined || endInclusiveTimestamp === undefined) { + setLocalFilters((prev) => ({ + ...prev, + startTime: undefined, + endTime: undefined, + })); + return; + } + const endTimestamp = endInclusiveTimestamp + 1000; setLocalFilters((prev) => ({ ...prev, startTime: startTimestamp, @@ -324,7 +446,7 @@ export function UsageLogsFilters({ })); } }, - [dateStringToTimestamp] + [displayEndClock, displayStartClock] ); return ( @@ -338,6 +460,56 @@ export function UsageLogsFilters({ endDate={displayEndDate} onDateRangeChange={handleDateRangeChange} /> +
+
+ + { + const nextClock = e.target.value || "00:00:00"; + setLocalFilters((prev) => { + if (!prev.startTime) return prev; + const dateStr = timestampToDateString(prev.startTime); + const startTime = dateStringWithClockToTimestamp(dateStr, nextClock); + if (startTime === undefined) return prev; + return { + ...prev, + startTime, + }; + }); + }} + /> +
+
+ + { + const nextClock = e.target.value || "23:59:59"; + setLocalFilters((prev) => { + if (!prev.endTime) return prev; + const inclusiveEndTime = inclusiveEndTimestampFromExclusive(prev.endTime); + const endDateStr = timestampToDateString(inclusiveEndTime); + const endInclusiveTimestamp = dateStringWithClockToTimestamp( + endDateStr, + nextClock + ); + if (endInclusiveTimestamp === undefined) return prev; + return { + ...prev, + endTime: endInclusiveTimestamp + 1000, + }; + }); + }} + /> +
+
{/* 用户选择(仅 Admin) */} @@ -419,11 +591,11 @@ export function UsageLogsFilters({
{ + const term = (localFilters.sessionId ?? "").trim(); + setSessionIdPopoverOpen(term.length >= SESSION_ID_SUGGESTION_MIN_LEN); + }} + onChange={(e) => { + const next = e.target.value.trim(); + setLocalFilters((prev) => ({ ...prev, sessionId: next || undefined })); + setSessionIdPopoverOpen(next.length >= SESSION_ID_SUGGESTION_MIN_LEN); + }} + /> + + e.preventDefault()} + onWheel={(e) => e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > + + + + {isSessionIdsLoading + ? t("logs.stats.loading") + : t("logs.filters.noSessionFound")} + + + {availableSessionIds.map((sessionId) => ( + { + setLocalFilters((prev) => ({ ...prev, sessionId })); + setSessionIdPopoverOpen(false); + }} + className="cursor-pointer" + > + {sessionId} + {localFilters.sessionId === sessionId && ( + + )} + + ))} + + + + + +
+ {/* 模型选择 */}
@@ -602,12 +832,17 @@ export function UsageLogsFilters({