From f6b37652fdc9d6b526c6fff20910d9aae8f342f1 Mon Sep 17 00:00:00 2001 From: Kuroome Date: Wed, 1 Jul 2026 17:56:56 +0800 Subject: [PATCH 1/3] feat:redesign-model-cards - add model-id-copy-button - redo model-cards - complete some model provider's logos and sections --- apps/web/src/assets/icons/bytedance.svg | 1 + apps/web/src/assets/icons/glm-dark.svg | 3 + apps/web/src/assets/icons/glm-light.svg | 3 + apps/web/src/assets/icons/glm.svg | 1 - apps/web/src/assets/icons/hunyuan.svg | 1 + apps/web/src/assets/icons/longcat.svg | 1 + apps/web/src/assets/icons/stepfun.svg | 1 + apps/web/src/assets/icons/volcengine.svg | 1 + apps/web/src/assets/icons/wenxin.svg | 1 + apps/web/src/assets/icons/xiaomimimo-dark.svg | 1 + .../web/src/assets/icons/xiaomimimo-light.svg | 1 + .../features/system/SystemPage.module.scss | 99 ++++++++++++++--- apps/web/src/features/system/SystemPage.tsx | 105 ++++++++++++++---- apps/web/src/utils/models.ts | 18 ++- 14 files changed, 197 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/assets/icons/bytedance.svg create mode 100644 apps/web/src/assets/icons/glm-dark.svg create mode 100644 apps/web/src/assets/icons/glm-light.svg delete mode 100644 apps/web/src/assets/icons/glm.svg create mode 100644 apps/web/src/assets/icons/hunyuan.svg create mode 100644 apps/web/src/assets/icons/longcat.svg create mode 100644 apps/web/src/assets/icons/stepfun.svg create mode 100644 apps/web/src/assets/icons/volcengine.svg create mode 100644 apps/web/src/assets/icons/wenxin.svg create mode 100644 apps/web/src/assets/icons/xiaomimimo-dark.svg create mode 100644 apps/web/src/assets/icons/xiaomimimo-light.svg diff --git a/apps/web/src/assets/icons/bytedance.svg b/apps/web/src/assets/icons/bytedance.svg new file mode 100644 index 000000000..6921097da --- /dev/null +++ b/apps/web/src/assets/icons/bytedance.svg @@ -0,0 +1 @@ +ByteDance \ No newline at end of file diff --git a/apps/web/src/assets/icons/glm-dark.svg b/apps/web/src/assets/icons/glm-dark.svg new file mode 100644 index 000000000..84b66599e --- /dev/null +++ b/apps/web/src/assets/icons/glm-dark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/web/src/assets/icons/glm-light.svg b/apps/web/src/assets/icons/glm-light.svg new file mode 100644 index 000000000..f819e163e --- /dev/null +++ b/apps/web/src/assets/icons/glm-light.svg @@ -0,0 +1,3 @@ + diff --git a/apps/web/src/assets/icons/glm.svg b/apps/web/src/assets/icons/glm.svg deleted file mode 100644 index 0c6e61cea..000000000 --- a/apps/web/src/assets/icons/glm.svg +++ /dev/null @@ -1 +0,0 @@ -Zhipu \ No newline at end of file diff --git a/apps/web/src/assets/icons/hunyuan.svg b/apps/web/src/assets/icons/hunyuan.svg new file mode 100644 index 000000000..42edd6cd9 --- /dev/null +++ b/apps/web/src/assets/icons/hunyuan.svg @@ -0,0 +1 @@ +Hunyuan \ No newline at end of file diff --git a/apps/web/src/assets/icons/longcat.svg b/apps/web/src/assets/icons/longcat.svg new file mode 100644 index 000000000..fde6c8a36 --- /dev/null +++ b/apps/web/src/assets/icons/longcat.svg @@ -0,0 +1 @@ +LongCat \ No newline at end of file diff --git a/apps/web/src/assets/icons/stepfun.svg b/apps/web/src/assets/icons/stepfun.svg new file mode 100644 index 000000000..920e8a607 --- /dev/null +++ b/apps/web/src/assets/icons/stepfun.svg @@ -0,0 +1 @@ +Stepfun \ No newline at end of file diff --git a/apps/web/src/assets/icons/volcengine.svg b/apps/web/src/assets/icons/volcengine.svg new file mode 100644 index 000000000..ecf6d75e0 --- /dev/null +++ b/apps/web/src/assets/icons/volcengine.svg @@ -0,0 +1 @@ +Volcengine \ No newline at end of file diff --git a/apps/web/src/assets/icons/wenxin.svg b/apps/web/src/assets/icons/wenxin.svg new file mode 100644 index 000000000..d4a877920 --- /dev/null +++ b/apps/web/src/assets/icons/wenxin.svg @@ -0,0 +1 @@ +Wenxin \ No newline at end of file diff --git a/apps/web/src/assets/icons/xiaomimimo-dark.svg b/apps/web/src/assets/icons/xiaomimimo-dark.svg new file mode 100644 index 000000000..9785e2de2 --- /dev/null +++ b/apps/web/src/assets/icons/xiaomimimo-dark.svg @@ -0,0 +1 @@ +XiaomiMiMo \ No newline at end of file diff --git a/apps/web/src/assets/icons/xiaomimimo-light.svg b/apps/web/src/assets/icons/xiaomimimo-light.svg new file mode 100644 index 000000000..50193d3cf --- /dev/null +++ b/apps/web/src/assets/icons/xiaomimimo-light.svg @@ -0,0 +1 @@ +XiaomiMiMo \ No newline at end of file diff --git a/apps/web/src/features/system/SystemPage.module.scss b/apps/web/src/features/system/SystemPage.module.scss index c005a570d..2f2f7363b 100644 --- a/apps/web/src/features/system/SystemPage.module.scss +++ b/apps/web/src/features/system/SystemPage.module.scss @@ -76,41 +76,98 @@ } } -.modelTags { - display: flex; - flex-wrap: wrap; - flex: 0 0 100%; - gap: $spacing-sm; +.modelsGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: $spacing-md; +} + +.modelCategoryCard { + border: 1px solid var(--border-subtle); + border-radius: $radius-lg; + background-color: var(--surface-subtle); + overflow: hidden; + transition: border-color 0.2s ease; + + &:hover { + border-color: var(--primary-color); + } } -.groupTitle { +.modelCategoryHeader { display: flex; align-items: center; gap: $spacing-sm; + padding: $spacing-md $spacing-lg; + border-bottom: 1px solid var(--border-subtle); } -.groupIcon { - width: 18px; - height: 18px; +.modelCategoryIcon { + width: 28px; + height: 28px; flex-shrink: 0; } +.modelCategoryName { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.modelCategoryCount { + margin-left: auto; + font-size: 12px; + color: var(--text-secondary); + background-color: var(--surface-subtle-hover); + padding: 2px 8px; + border-radius: $radius-full; +} + +.modelTags { + display: flex; + flex-wrap: wrap; + gap: $spacing-xs; + padding: $spacing-md $spacing-lg; +} + .modelTag { display: inline-flex; align-items: center; - gap: $spacing-xs; - padding: 4px 10px; - border-radius: $radius-full; + gap: 6px; + padding: 5px 12px; + border-radius: $radius-md; border: 1px solid var(--border-subtle); - background-color: var(--surface-subtle); + background-color: var(--bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + color: inherit; + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; + + &:hover { + background-color: var(--surface-subtle-hover); + border-color: var(--primary-color); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + } + + &:active { + transform: translateY(1px); + } +} + +.modelTagCopied { + border-color: var(--success-color); + background-color: oklch(from var(--success-color) l c h / 0.08); } .modelName { color: var(--text-primary); font-weight: 600; + font-size: 13px; } .modelAlias { @@ -118,6 +175,22 @@ font-size: 12px; } +.modelCopyIcon { + display: inline-flex; + align-items: center; + color: var(--text-secondary); + transition: color 0.15s ease; + margin-left: 2px; +} + +.modelTag:hover .modelCopyIcon { + color: var(--text-primary); +} + +.modelTagCopied .modelCopyIcon { + color: var(--success-color); +} + .versionCheck { display: flex; flex-direction: column; diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 43e39784f..20cd64d04 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; -import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons'; +import { + IconGithub, + IconBookOpen, + IconExternalLink, + IconCode, + IconCopy, + IconCheck, +} from '@/components/ui/icons'; import { useAuthStore, useConfigStore, @@ -12,6 +19,7 @@ import { } from '@/stores'; import { apiKeysApi } from '@/services/api/apiKeys'; import { classifyModels } from '@/utils/models'; +import { copyToClipboard } from '@/utils/clipboard'; import { STORAGE_KEY_AUTH, STORAGE_KEY_QUOTA_CACHE } from '@/utils/constants'; import iconGemini from '@/assets/icons/gemini.svg'; import iconClaude from '@/assets/icons/claude.svg'; @@ -20,11 +28,21 @@ import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconQwen from '@/assets/icons/qwen.svg'; import iconKimiLight from '@/assets/icons/kimi-light.svg'; import iconKimiDark from '@/assets/icons/kimi-dark.svg'; -import iconGlm from '@/assets/icons/glm.svg'; +import iconGlmLight from '@/assets/icons/glm-light.svg'; +import iconGlmDark from '@/assets/icons/glm-dark.svg'; import iconGrok from '@/assets/icons/grok.svg'; import iconGrokDark from '@/assets/icons/grok-dark.svg'; import iconDeepseek from '@/assets/icons/deepseek.svg'; import iconMinimax from '@/assets/icons/minimax.svg'; +import iconMimoLight from '@/assets/icons/xiaomimimo-light.svg'; +import iconMimoDark from '@/assets/icons/xiaomimimo-dark.svg'; +import iconHunyuan from '@/assets/icons/hunyuan.svg'; +import iconBytedance from '@/assets/icons/bytedance.svg'; +import iconVolcengine from '@/assets/icons/volcengine.svg'; +import iconStepfun from '@/assets/icons/stepfun.svg'; +import iconWenxin from '@/assets/icons/wenxin.svg'; +import iconLongcat from '@/assets/icons/longcat.svg'; + import styles from './SystemPage.module.scss'; const MODEL_CATEGORY_ICONS: Record = { @@ -33,10 +51,17 @@ const MODEL_CATEGORY_ICONS: Record(); + const [copiedModelName, setCopiedModelName] = useState(null); + const copiedResetTimer = useRef | null>(null); + const apiKeysCache = useRef([]); const otherLabel = useMemo( @@ -71,6 +99,29 @@ export function SystemPage() { return resolvedTheme === 'dark' ? iconEntry.dark : iconEntry.light; }; + const handleCopyModelName = async (name: string) => { + const copied = await copyToClipboard(name); + showNotification( + t(copied ? 'notification.link_copied' : 'notification.copy_failed'), + copied ? 'success' : 'error' + ); + if (!copied) return; + if (copiedResetTimer.current) { + clearTimeout(copiedResetTimer.current); + } + setCopiedModelName(name); + copiedResetTimer.current = setTimeout(() => { + setCopiedModelName(null); + copiedResetTimer.current = null; + }, 1500); + }; + + useEffect(() => { + return () => { + if (copiedResetTimer.current) clearTimeout(copiedResetTimer.current); + }; + }, []); + const normalizeApiKeyList = (input: unknown): string[] => { if (!Array.isArray(input)) return []; const seen = new Set(); @@ -277,31 +328,41 @@ export function SystemPage() { ) : models.length === 0 ? (
{t('system_info.models_empty')}
) : ( -
+
{groupedModels.map((group) => { const iconSrc = getIconForCategory(group.id); return ( -
-
-
- {iconSrc && } - {group.label} -
-
+
+
+ {iconSrc && ( + + )} + {group.label} + {t('system_info.models_count', { count: group.items.length })} -
+
- {group.items.map((model) => ( - - {model.name} - {model.alias && {model.alias}} - - ))} + {group.items.map((model) => { + const isCopied = copiedModelName === model.name; + return ( + + ); + })}
); diff --git a/apps/web/src/utils/models.ts b/apps/web/src/utils/models.ts index 5049fa0e6..9bc3c976b 100644 --- a/apps/web/src/utils/models.ts +++ b/apps/web/src/utils/models.ts @@ -15,10 +15,17 @@ const MODEL_CATEGORIES = [ { id: 'gemini', label: 'Gemini', patterns: [/gemini/i, /\bgai\b/i] }, { id: 'kimi', label: 'Kimi', patterns: [/kimi/i] }, { id: 'qwen', label: 'Qwen', patterns: [/qwen/i] }, - { id: 'glm', label: 'GLM', patterns: [/glm/i, /chatglm/i] }, + { id: 'glm', label: 'ZHIPU', patterns: [/glm/i, /chatglm/i] }, { id: 'grok', label: 'Grok', patterns: [/grok/i] }, { id: 'deepseek', label: 'DeepSeek', patterns: [/deepseek/i] }, - { id: 'minimax', label: 'MiniMax', patterns: [/minimax/i, /abab/i] } + { id: 'minimax', label: 'MiniMax', patterns: [/minimax/i, /abab/i] }, + { id: 'mimo', label: 'Mimo', patterns: [/mimo/i] }, + { id: 'hunyuan', label: 'HunYuan', patterns: [/hunyuan/i] }, + { id: 'bytedance', label: 'ByteDance', patterns: [/seed/i, /doubao/i] }, + { id: 'volcengine', label: 'VolcEngine', patterns: [/ark/i] }, + { id: 'longcat', label: 'LongCat', patterns: [/longcat/i] }, + { id: 'stepfun', label: 'StepFun', patterns: [/stepfun/i] }, + { id: 'wenxin', label: 'WenXin', patterns: [/wenxin/i] }, ]; const matchCategory = (text: string) => { @@ -90,11 +97,14 @@ export interface ModelGroup { items: ModelInfo[]; } -export function classifyModels(models: ModelInfo[] = [], { otherLabel = 'Other' } = {}): ModelGroup[] { +export function classifyModels( + models: ModelInfo[] = [], + { otherLabel = 'Other' } = {} +): ModelGroup[] { const groups: ModelGroup[] = MODEL_CATEGORIES.map((category) => ({ id: category.id, label: category.label, - items: [] + items: [], })); const otherGroup: ModelGroup = { id: 'other', label: otherLabel, items: [] }; From c1200cf77cd9f02205b084c77fca6b151178a6bb Mon Sep 17 00:00:00 2001 From: Kuroome Date: Wed, 1 Jul 2026 18:35:10 +0800 Subject: [PATCH 2/3] fix(frontend): address model-cards pr review feedback - Tighten ByteDance/VolcEngine regex patterns with word boundaries - Use dedicated model_id_copied translation key (add to 4 locales) - Add aria-label to model copy button for accessibility - Replace oklch() with color-mix for codebase consistency --- apps/web/src/features/system/SystemPage.module.scss | 2 +- apps/web/src/features/system/SystemPage.tsx | 3 ++- apps/web/src/i18n/locales/en.json | 3 ++- apps/web/src/i18n/locales/ru.json | 3 ++- apps/web/src/i18n/locales/zh-CN.json | 3 ++- apps/web/src/i18n/locales/zh-TW.json | 3 ++- apps/web/src/utils/models.ts | 4 ++-- 7 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/web/src/features/system/SystemPage.module.scss b/apps/web/src/features/system/SystemPage.module.scss index 2f2f7363b..26a5ea718 100644 --- a/apps/web/src/features/system/SystemPage.module.scss +++ b/apps/web/src/features/system/SystemPage.module.scss @@ -161,7 +161,7 @@ .modelTagCopied { border-color: var(--success-color); - background-color: oklch(from var(--success-color) l c h / 0.08); + background: color-mix(in srgb, var(--success-color) 8%, var(--bg)); } .modelName { diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 20cd64d04..c418836f0 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -102,7 +102,7 @@ export function SystemPage() { const handleCopyModelName = async (name: string) => { const copied = await copyToClipboard(name); showNotification( - t(copied ? 'notification.link_copied' : 'notification.copy_failed'), + t(copied ? 'notification.model_id_copied' : 'notification.copy_failed'), copied ? 'success' : 'error' ); if (!copied) return; @@ -351,6 +351,7 @@ export function SystemPage() { type="button" className={`${styles.modelTag}${isCopied ? ` ${styles.modelTagCopied}` : ''}`} title={model.description || model.name} + aria-label={t('common.copy') + ' ' + model.name} onClick={() => handleCopyModelName(model.name)} > {model.name} diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index a49914666..93919b854 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -3297,7 +3297,8 @@ "claude_api_key": "Claude API key", "commercial_mode_restart_required": "Commercial mode setting changed. Please restart the service for it to take effect", "copy_failed": "Copy failed", - "link_copied": "Link copied to clipboard" + "link_copied": "Link copied to clipboard", + "model_id_copied": "Model ID copied" }, "language": { "switch": "Language", diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json index 90a39d9c6..4f34010c6 100644 --- a/apps/web/src/i18n/locales/ru.json +++ b/apps/web/src/i18n/locales/ru.json @@ -3293,7 +3293,8 @@ "claude_api_key": "API-ключ Claude", "commercial_mode_restart_required": "Режим коммерческого использования изменён. Перезапустите сервис, чтобы применить изменения", "copy_failed": "Не удалось скопировать", - "link_copied": "Ссылка скопирована в буфер обмена" + "link_copied": "Ссылка скопирована в буфер обмена", + "model_id_copied": "ID модели скопирован" }, "language": { "switch": "Язык", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index 3929f6f80..03f7fcc2a 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -3243,7 +3243,8 @@ "claude_api_key": "Claude API密钥", "commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效", "copy_failed": "复制失败", - "link_copied": "已复制" + "link_copied": "已复制", + "model_id_copied": "模型 ID 已复制" }, "language": { "switch": "语言", diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index 64cd6a90d..fb170d817 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -3268,7 +3268,8 @@ "claude_api_key": "Claude API 金鑰", "commercial_mode_restart_required": "商業模式開關已變更,請重啟服務後生效", "copy_failed": "複製失敗", - "link_copied": "已複製" + "link_copied": "已複製", + "model_id_copied": "模型 ID 已複製" }, "language": { "switch": "語言", diff --git a/apps/web/src/utils/models.ts b/apps/web/src/utils/models.ts index 9bc3c976b..c1b27c067 100644 --- a/apps/web/src/utils/models.ts +++ b/apps/web/src/utils/models.ts @@ -21,8 +21,8 @@ const MODEL_CATEGORIES = [ { id: 'minimax', label: 'MiniMax', patterns: [/minimax/i, /abab/i] }, { id: 'mimo', label: 'Mimo', patterns: [/mimo/i] }, { id: 'hunyuan', label: 'HunYuan', patterns: [/hunyuan/i] }, - { id: 'bytedance', label: 'ByteDance', patterns: [/seed/i, /doubao/i] }, - { id: 'volcengine', label: 'VolcEngine', patterns: [/ark/i] }, + { id: 'bytedance', label: 'ByteDance', patterns: [/\bseed\b/i, /doubao/i] }, + { id: 'volcengine', label: 'VolcEngine', patterns: [/\bark\b/i] }, { id: 'longcat', label: 'LongCat', patterns: [/longcat/i] }, { id: 'stepfun', label: 'StepFun', patterns: [/stepfun/i] }, { id: 'wenxin', label: 'WenXin', patterns: [/wenxin/i] }, From d6b27ae2db0c126cde61abdaaf04f02259d473b2 Mon Sep 17 00:00:00 2001 From: Kuroome Date: Wed, 8 Jul 2026 13:42:27 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore:=E5=90=88=E5=B9=B6=E4=B8=8A=E6=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-check.yml | 27 + README.md | 1 + README_CN.md | 5 + apps/docs/.vitepress/config.ts | 2 + .../deployment/native-background-control.md | 99 +++ apps/docs/deployment/native.md | 4 +- .../deployment/native-background-control.md | 99 +++ apps/docs/en/deployment/native.md | 4 +- .../cmd/cpa-manager-plus/main.go | 6 + .../http/controller/monitoring/handler.go | 38 + .../controller/monitoring/handler_test.go | 62 ++ .../internal/http/controller/panel/handler.go | 2 +- .../internal/httpapi/server_compat_test.go | 11 +- .../internal/repository/sqlite/migrate.go | 36 + .../repository/usagerollup/repository.go | 647 ++++++++++++++++ .../usagerollup/repository_benchmark_test.go | 171 +++++ .../repository/usagerollup/repository_test.go | 254 +++++++ .../internal/service/accountaction/service.go | 17 +- .../internal/service/cpaauthfiles/client.go | 263 ++++++- .../service/cpaauthfiles/client_test.go | 87 ++- .../internal/service/dashboard/service.go | 12 +- .../service/dashboard/service_test.go | 45 ++ .../account_history_benchmark_test.go | 177 +++++ .../internal/service/monitoring/service.go | 267 ++++++- .../service/monitoring/service_test.go | 154 ++++ .../internal/service/panel/service.go | 19 +- .../internal/service/pricing/cost.go | 19 + .../internal/service/pricing/cost_test.go | 35 + apps/manager-server/internal/store/store.go | 26 + .../worker/account_action_candidate.go | 22 +- .../worker/account_action_candidate_test.go | 46 ++ .../internal/worker/account_history_rollup.go | 98 +++ .../internal/worker/automation_runtime.go | 7 + .../worker/rate_limit_auto_disable.go | 21 +- apps/web/src/components/layout/MainLayout.tsx | 4 +- .../ProviderTable/ProviderTable.module.scss | 187 ++++- .../ProviderTable/ProviderTable.test.tsx | 208 +++++- .../providers/ProviderTable/ProviderTable.tsx | 149 +++- .../components/quota/QuotaSection.test.tsx | 69 ++ apps/web/src/components/quota/quotaConfigs.ts | 378 ++++++---- .../src/components/quota/useQuotaLoader.ts | 34 +- .../features/aiProviders/AiProvidersPage.tsx | 129 ++++ .../AuthFilesPage.quotaCooldown.test.tsx | 533 +++++++++++++- .../src/features/authFiles/AuthFilesPage.tsx | 292 +++++++- .../accountOverviewCardMetrics.test.ts | 2 - .../monitoring/accountOverviewState.test.ts | 2 - .../monitoring/accountOverviewState.ts | 2 - .../components/AccountOverviewCard.tsx | 94 ++- .../components/accountOverviewPresentation.ts | 1 + .../model/monitoringCenterPageModel.test.ts | 88 ++- .../model/monitoringCenterPageModel.ts | 56 +- .../styles/_monitoring-account-overview.scss | 110 +++ .../usageAnalyticsWiring.test.ts | 7 + apps/web/src/i18n/locales/en.json | 482 +++++++----- apps/web/src/i18n/locales/ru.json | 694 ++++++++++-------- apps/web/src/i18n/locales/zh-CN.json | 464 +++++++----- apps/web/src/i18n/locales/zh-TW.json | 439 ++++++----- .../api/antigravitySubscription.test.ts | 20 + .../services/api/antigravitySubscription.ts | 47 +- apps/web/src/services/api/codexQuota.ts | 23 +- apps/web/src/types/quota.ts | 10 + apps/web/src/utils/quota/builders.test.ts | 34 +- apps/web/src/utils/quota/builders.ts | 25 +- .../src/utils/quota/codexRequestHeaders.ts | 31 + apps/web/src/utils/quota/constants.ts | 18 +- apps/web/src/utils/quota/index.ts | 1 + .../src/utils/quota/providerRequests.test.ts | 244 +++++- apps/web/src/utils/quota/providerRequests.ts | 105 ++- apps/web/src/utils/usageHeaderSnapshots.ts | 92 +++ bin/native/cpa-manager-plusctl.ps1 | 688 +++++++++++++++++ bin/native/cpa-manager-plusctl.sh | 579 +++++++++++++++ bin/release/package-native.sh | 7 + docs/release-notes/v1.10.1-en.md | 47 ++ docs/release-notes/v1.10.1-zh.md | 47 ++ docs/release-notes/v1.10.2-en.md | 33 + docs/release-notes/v1.10.2-zh.md | 33 + docs/release-notes/v1.10.3-en.md | 47 ++ docs/release-notes/v1.10.3-zh.md | 47 ++ docs/release-notes/v1.10.4-en.md | 46 ++ docs/release-notes/v1.10.4-zh.md | 46 ++ tests/nativeControlScripts.test.mjs | 661 +++++++++++++++++ 81 files changed, 8802 insertions(+), 1336 deletions(-) create mode 100644 apps/docs/deployment/native-background-control.md create mode 100644 apps/docs/en/deployment/native-background-control.md create mode 100644 apps/manager-server/internal/http/controller/monitoring/handler_test.go create mode 100644 apps/manager-server/internal/repository/usagerollup/repository.go create mode 100644 apps/manager-server/internal/repository/usagerollup/repository_benchmark_test.go create mode 100644 apps/manager-server/internal/repository/usagerollup/repository_test.go create mode 100644 apps/manager-server/internal/service/monitoring/account_history_benchmark_test.go create mode 100644 apps/manager-server/internal/worker/account_history_rollup.go create mode 100644 apps/web/src/utils/quota/codexRequestHeaders.ts create mode 100644 bin/native/cpa-manager-plusctl.ps1 create mode 100755 bin/native/cpa-manager-plusctl.sh create mode 100644 docs/release-notes/v1.10.1-en.md create mode 100644 docs/release-notes/v1.10.1-zh.md create mode 100644 docs/release-notes/v1.10.2-en.md create mode 100644 docs/release-notes/v1.10.2-zh.md create mode 100644 docs/release-notes/v1.10.3-en.md create mode 100644 docs/release-notes/v1.10.3-zh.md create mode 100644 docs/release-notes/v1.10.4-en.md create mode 100644 docs/release-notes/v1.10.4-zh.md create mode 100644 tests/nativeControlScripts.test.mjs diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index ab28644cd..052e1ff2b 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -78,6 +78,33 @@ jobs: working-directory: apps/manager-server run: go test ./... + native-control: + name: Native Control (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test native control scripts + run: npx vitest run tests/nativeControlScripts.test.mjs + docker-build: name: Docker Build runs-on: ubuntu-latest diff --git a/README.md b/README.md index 3020ebc9b..601889101 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ CPAMP can also run as a CPA-hosted panel on `:8317`, or as a standalone frontend | Panel manual | [Dashboard](https://seakee.github.io/CPA-Manager-Plus/docs/en/manual/dashboard.html), [Configuration](https://seakee.github.io/CPA-Manager-Plus/docs/en/manual/configuration.html), [AI Providers](https://seakee.github.io/CPA-Manager-Plus/docs/en/manual/ai-providers.html), [Monitoring](https://seakee.github.io/CPA-Manager-Plus/docs/en/manual/monitoring.html), [Plugin Management](https://seakee.github.io/CPA-Manager-Plus/docs/en/manual/plugins.html) | | Docker deployment | [Docker Deployment](https://seakee.github.io/CPA-Manager-Plus/docs/en/deployment/docker.html) | | Native packages | [Native Packages](https://seakee.github.io/CPA-Manager-Plus/docs/en/deployment/native.html) | +| Native background control | [Native Background Control](https://seakee.github.io/CPA-Manager-Plus/docs/en/deployment/native-background-control.html) | | Manager Server config, endpoints, data, and security | [Manager Server Guide](https://seakee.github.io/CPA-Manager-Plus/docs/en/operations/manager-server.html) | | Reverse proxy | [Reverse Proxy](https://seakee.github.io/CPA-Manager-Plus/docs/en/deployment/reverse-proxy.html) | | Migrate from old CPA-Manager | [Migration from CPA-Manager](https://seakee.github.io/CPA-Manager-Plus/docs/en/migration/from-cpa-manager.html) | diff --git a/README_CN.md b/README_CN.md index 89eee10af..c8b784111 100644 --- a/README_CN.md +++ b/README_CN.md @@ -160,6 +160,7 @@ CPAMP 也支持作为 CPA 托管面板(`:8317`)或独立前端开发使用 | 面板手册 | [仪表盘](https://seakee.github.io/CPA-Manager-Plus/docs/manual/dashboard.html)、[配置中心](https://seakee.github.io/CPA-Manager-Plus/docs/manual/configuration.html)、[AI 提供商](https://seakee.github.io/CPA-Manager-Plus/docs/manual/ai-providers.html)、[请求监控](https://seakee.github.io/CPA-Manager-Plus/docs/manual/monitoring.html)、[插件管理](https://seakee.github.io/CPA-Manager-Plus/docs/manual/plugins.html) | | Docker 部署 | [Docker 部署](https://seakee.github.io/CPA-Manager-Plus/docs/deployment/docker.html) | | 原生运行包 | [原生包部署](https://seakee.github.io/CPA-Manager-Plus/docs/deployment/native.html) | +| 原生包后台控制 | [原生包后台控制](https://seakee.github.io/CPA-Manager-Plus/docs/deployment/native-background-control.html) | | Manager Server 配置、接口、数据和安全 | [Manager Server 指南](https://seakee.github.io/CPA-Manager-Plus/docs/operations/manager-server.html) | | 反向代理 | [反向代理](https://seakee.github.io/CPA-Manager-Plus/docs/deployment/reverse-proxy.html) | | 从旧 CPA-Manager 迁移 | [从 CPA-Manager 迁移](https://seakee.github.io/CPA-Manager-Plus/docs/migration/from-cpa-manager.html) | @@ -215,6 +216,10 @@ docker compose -f docker-compose.manager.yml up --build - 感谢上游项目 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 和 [Cli-Proxy-API-Management-Center](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) 提供基础与参考。 - 感谢 [Linux.do](https://linux.do/) 社区对项目推广与反馈的支持。 +## 社区与反馈 + +- Telegram 交流群: https://t.me/cpa_mp + ## Star History diff --git a/apps/docs/.vitepress/config.ts b/apps/docs/.vitepress/config.ts index 2c16d40d0..e1b187bb7 100644 --- a/apps/docs/.vitepress/config.ts +++ b/apps/docs/.vitepress/config.ts @@ -54,6 +54,7 @@ const zhSidebar: DefaultTheme.Sidebar = [ { text: '一键安装脚本', link: '/deployment/installer' }, { text: 'Docker 部署', link: '/deployment/docker' }, { text: '原生包部署', link: '/deployment/native' }, + { text: '原生包后台控制', link: '/deployment/native-background-control' }, { text: '反向代理', link: '/deployment/reverse-proxy' }, ], }, @@ -129,6 +130,7 @@ const enSidebar: DefaultTheme.Sidebar = [ { text: 'One-Click Installer', link: '/en/deployment/installer' }, { text: 'Docker Deployment', link: '/en/deployment/docker' }, { text: 'Native Packages', link: '/en/deployment/native' }, + { text: 'Native Background Control', link: '/en/deployment/native-background-control' }, { text: 'Reverse Proxy', link: '/en/deployment/reverse-proxy' }, ], }, diff --git a/apps/docs/deployment/native-background-control.md b/apps/docs/deployment/native-background-control.md new file mode 100644 index 000000000..d7a15a13f --- /dev/null +++ b/apps/docs/deployment/native-background-control.md @@ -0,0 +1,99 @@ +# 原生包后台运行控制 + +原生发布包内置控制脚本,可以让 CPA Manager Plus 在后台运行,不需要一直占用终端。 + +请在解压后的原生包目录中使用脚本。 + +## 命令 + +macOS 和 Linux: + +```bash +./cpa-manager-plusctl start +./cpa-manager-plusctl status +./cpa-manager-plusctl logs +./cpa-manager-plusctl logs -f +./cpa-manager-plusctl restart +./cpa-manager-plusctl stop +``` + +Windows PowerShell: + +```powershell +.\cpa-manager-plusctl.ps1 start +.\cpa-manager-plusctl.ps1 status +.\cpa-manager-plusctl.ps1 logs +.\cpa-manager-plusctl.ps1 logs -f +.\cpa-manager-plusctl.ps1 restart +.\cpa-manager-plusctl.ps1 stop +``` + +`logs [lines]` 只接受正整数行数。`logs -f` 和 `logs --follow` 会持续跟随当前日志输出。 + +## 运行文件 + +默认情况下,脚本会在解压后的包目录中写入运行状态: + +| 平台 | PID 记录 | 日志 | +|---|---|---| +| macOS/Linux | `run/cpa-manager-plus.pid` | `logs/cpa-manager-plus.log` | +| Windows | `run\cpa-manager-plus.pid` | `logs\cpa-manager-plus.log`、`logs\cpa-manager-plus.err.log` | + +PID 文件记录的是进程元数据,不只是裸 PID。`status`、`stop` 和 `restart` 不会信任无法校验或已经过期的 PID 记录。 + +## 环境变量覆盖 + +脚本支持这些环境变量: + +| 变量 | 用途 | +|---|---| +| `CPA_MANAGER_PLUS_BIN` | 覆盖二进制路径。 | +| `CPA_MANAGER_PLUS_RUN_DIR` | 覆盖默认运行目录。 | +| `CPA_MANAGER_PLUS_LOG_DIR` | 覆盖默认日志目录。 | +| `CPA_MANAGER_PLUS_PID_FILE` | 覆盖 PID 记录路径。 | +| `CPA_MANAGER_PLUS_LOG_FILE` | 覆盖 stdout 日志路径。 | +| `CPA_MANAGER_PLUS_ERR_LOG_FILE` | 覆盖 Windows stderr 日志路径。 | + +示例: + +```bash +CPA_MANAGER_PLUS_RUN_DIR=/var/lib/cpa-manager-plus/run \ +CPA_MANAGER_PLUS_LOG_DIR=/var/log/cpa-manager-plus \ +./cpa-manager-plusctl start +``` + +PowerShell: + +```powershell +$env:CPA_MANAGER_PLUS_RUN_DIR = 'C:\cpamp\run' +$env:CPA_MANAGER_PLUS_LOG_DIR = 'C:\cpamp\logs' +.\cpa-manager-plusctl.ps1 start +``` + +## 安全说明 + +Manager Server 首次启动时可能会把生成的管理员密钥打印到 stdout。后台运行模式会把 stdout 和 stderr 写入日志文件,因此日志目录应当视为敏感数据目录。 + +默认 `run/` 和 `logs/` 目录是私有的。macOS/Linux 上,默认目录权限会设置为 `0700`,运行文件权限为 `0600`。Windows 上,默认运行目录和文件会设置为仅当前用户可访问的受保护 ACL。 + +如果使用自定义 PID 或日志文件路径,父目录必须是私有、由当前用户控制的目录。脚本会拒绝 symlink/reparse-point 运行文件,也会拒绝已经存在且可被宽泛本地身份写入的自定义父目录,例如 Unix 的 group/world-writable 目录,或 Windows 上 Everyone、Authenticated Users、Users 可写的目录。 + +## Windows 参数说明 + +脚本支持简单的 `start [args...]` 参数转发。包含空格或引号的复杂 Windows 参数会经过 `Start-Process -ArgumentList`,具体解析行为可能受 shell 影响。 + +Windows 上更建议通过环境变量配置运行参数。 + +## 排障 + +`status` 提示 stale PID file: +记录中的进程已经不存在。执行一次 `stop` 可以清理过期 PID 记录。 + +`status` 或 `stop` 提示 PID record 无法校验: +PID 文件指向了一个仍在运行、但和记录元数据不匹配的进程。脚本会拒绝停止它。请手动检查 PID 文件和对应进程后,再决定是否删除 PID 文件。 + +`start` 失败: +查看脚本打印的最近日志。Windows 上需要同时查看 stdout 和 stderr 日志。 + +`logs` 提示日志文件不存在: +服务可能还没有写入日志,或者 `CPA_MANAGER_PLUS_LOG_FILE` 配置路径不正确。 diff --git a/apps/docs/deployment/native.md b/apps/docs/deployment/native.md index b676e5f4f..1036d34da 100644 --- a/apps/docs/deployment/native.md +++ b/apps/docs/deployment/native.md @@ -206,7 +206,9 @@ setup 后: ## 后台运行 -生产环境建议使用 systemd、launchd、Windows 服务管理器或进程管理工具托管进程。无论使用哪种方式,都要保证数据目录持久化并纳入备份。 +原生包内置后台控制脚本,可以直接执行 `start`、`status`、`logs`、`restart` 和 `stop`。脚本会写入 PID 记录和日志文件,并对默认运行目录使用私有权限。详见 [原生包后台控制](./native-background-control.md)。 + +生产环境也可以使用 systemd、launchd、Windows 服务管理器或进程管理工具托管进程。无论使用哪种方式,都要保证数据目录持久化并纳入备份。 ## 升级 diff --git a/apps/docs/en/deployment/native-background-control.md b/apps/docs/en/deployment/native-background-control.md new file mode 100644 index 000000000..94c7f1a47 --- /dev/null +++ b/apps/docs/en/deployment/native-background-control.md @@ -0,0 +1,99 @@ +# Native Background Control + +Native release packages include small control scripts for running CPA Manager Plus in the background without keeping a terminal open. + +Use the script from the extracted native package directory. + +## Commands + +macOS and Linux: + +```bash +./cpa-manager-plusctl start +./cpa-manager-plusctl status +./cpa-manager-plusctl logs +./cpa-manager-plusctl logs -f +./cpa-manager-plusctl restart +./cpa-manager-plusctl stop +``` + +Windows PowerShell: + +```powershell +.\cpa-manager-plusctl.ps1 start +.\cpa-manager-plusctl.ps1 status +.\cpa-manager-plusctl.ps1 logs +.\cpa-manager-plusctl.ps1 logs -f +.\cpa-manager-plusctl.ps1 restart +.\cpa-manager-plusctl.ps1 stop +``` + +`logs [lines]` accepts only a positive integer line count. `logs -f` and `logs --follow` follow the current log stream. + +## Runtime Files + +By default, the scripts write runtime state inside the extracted package directory: + +| Platform | PID record | Logs | +|---|---|---| +| macOS/Linux | `run/cpa-manager-plus.pid` | `logs/cpa-manager-plus.log` | +| Windows | `run\cpa-manager-plus.pid` | `logs\cpa-manager-plus.log`, `logs\cpa-manager-plus.err.log` | + +The PID record stores process metadata, not just a raw PID. `status`, `stop`, and `restart` refuse to trust an unverifiable or stale PID record. + +## Environment Overrides + +The scripts support these environment variables: + +| Variable | Purpose | +|---|---| +| `CPA_MANAGER_PLUS_BIN` | Override the binary path. | +| `CPA_MANAGER_PLUS_RUN_DIR` | Override the default runtime directory. | +| `CPA_MANAGER_PLUS_LOG_DIR` | Override the default log directory. | +| `CPA_MANAGER_PLUS_PID_FILE` | Override the PID record path. | +| `CPA_MANAGER_PLUS_LOG_FILE` | Override the stdout log path. | +| `CPA_MANAGER_PLUS_ERR_LOG_FILE` | Override the stderr log path on Windows. | + +Example: + +```bash +CPA_MANAGER_PLUS_RUN_DIR=/var/lib/cpa-manager-plus/run \ +CPA_MANAGER_PLUS_LOG_DIR=/var/log/cpa-manager-plus \ +./cpa-manager-plusctl start +``` + +PowerShell: + +```powershell +$env:CPA_MANAGER_PLUS_RUN_DIR = 'C:\cpamp\run' +$env:CPA_MANAGER_PLUS_LOG_DIR = 'C:\cpamp\logs' +.\cpa-manager-plusctl.ps1 start +``` + +## Security Notes + +The Manager Server can print the generated first-start admin key to stdout. In background mode, stdout and stderr are written to log files, so treat the log directory as sensitive. + +The default `run/` and `logs/` directories are private. On macOS/Linux, default directories are set to `0700` and runtime files to `0600`. On Windows, default runtime directories and files receive a protected ACL for the current user. + +When using custom PID or log file paths, keep the parent directories private and user-controlled. The scripts reject symlinked or reparse-point runtime files and reject existing custom parent directories that are writable by broad local identities such as group/world on Unix or Everyone, Authenticated Users, or Users on Windows. + +## Windows Argument Notes + +Simple `start [args...]` forwarding is supported. Complex Windows arguments containing spaces or quotes can be shell-dependent because PowerShell forwards them through `Start-Process -ArgumentList`. + +Prefer environment variables for runtime configuration on Windows. + +## Troubleshooting + +`status` reports a stale PID file: +The recorded process is gone. Run `stop` once to remove the stale PID record. + +`status` or `stop` reports an unverifiable PID record: +The PID file points at a running process that does not match the recorded process metadata. The script refuses to stop it. Inspect the PID file and process manually before deleting the PID file. + +`start` fails: +Check the recent log output printed by the script. On Windows, check both stdout and stderr logs. + +`logs` says the log file does not exist: +The server has not written logs yet, or the configured `CPA_MANAGER_PLUS_LOG_FILE` path is wrong. diff --git a/apps/docs/en/deployment/native.md b/apps/docs/en/deployment/native.md index 76f0d1b01..2dfbaa408 100644 --- a/apps/docs/en/deployment/native.md +++ b/apps/docs/en/deployment/native.md @@ -208,7 +208,9 @@ After setup: ## Running In The Background -For production, run the process through systemd, launchd, Windows Service Manager, or another process manager. Whichever method you use, make sure the data directory is persistent and backed up. +Native packages include background control scripts for `start`, `status`, `logs`, `restart`, and `stop`. The scripts write PID records and log files, and protect the default runtime directories with private permissions. See [Native Background Control](./native-background-control.md). + +For production, you can also run the process through systemd, launchd, Windows Service Manager, or another process manager. Whichever method you use, make sure the data directory is persistent and backed up. ## Upgrade diff --git a/apps/manager-server/cmd/cpa-manager-plus/main.go b/apps/manager-server/cmd/cpa-manager-plus/main.go index 0066860ce..c5183a610 100644 --- a/apps/manager-server/cmd/cpa-manager-plus/main.go +++ b/apps/manager-server/cmd/cpa-manager-plus/main.go @@ -85,6 +85,8 @@ func runServer() { ManagementKey: cfg.ManagementKey, }) accountActionWorker := worker.NewAccountActionCandidateWorker(db, runtimeSettings.AccountActionsAutoDisable) + accountHistoryRollupWorker := worker.NewAccountHistoryRollupWorker(db) + accountHistoryRollupWorker.Start(ctx) automationRuntime := worker.NewAutomationRuntime( automationSettingsService, manager, @@ -93,6 +95,10 @@ func runServer() { ) serverApp.AppContext().AutomationRuntimeService = automationRuntime automationRuntime.Start(ctx) + manager.SetUsageEventHandler(worker.NewUsageEventFanout( + automationRuntime.UsageEventHandler(), + accountHistoryRollupWorker, + )) collectorWorker.Start(ctx) diff --git a/apps/manager-server/internal/http/controller/monitoring/handler.go b/apps/manager-server/internal/http/controller/monitoring/handler.go index 60339f768..d6b5980d8 100644 --- a/apps/manager-server/internal/http/controller/monitoring/handler.go +++ b/apps/manager-server/internal/http/controller/monitoring/handler.go @@ -27,6 +27,10 @@ func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) { h.handleHeaderSnapshots(w, r) return } + if path == "/v0/management/monitoring/account-history" { + h.handleAccountHistory(w, r) + return + } if path != "/v0/management/monitoring/analytics" { response.MethodNotAllowed(w) return @@ -54,6 +58,30 @@ func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, result) } +func (h *Handler) handleAccountHistory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.MethodNotAllowed(w) + return + } + var req monitoringsvc.AccountHistoryRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, err) + return + } + if err := validateAccountHistoryRequest(req); err != nil { + response.Error(w, http.StatusBadRequest, err) + return + } + result, err := h.App.MonitoringService.AccountHistory(r.Context(), req) + if err != nil { + response.Error(w, http.StatusInternalServerError, err) + return + } + response.JSON(w, http.StatusOK, result) +} + func (h *Handler) handleHeaderSnapshots(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { response.MethodNotAllowed(w) @@ -91,6 +119,16 @@ func validateRequest(req monitoringsvc.Request) error { return nil } +func validateAccountHistoryRequest(req monitoringsvc.AccountHistoryRequest) error { + if len(req.Accounts) == 0 { + return errors.New("accounts are required") + } + if len(req.Accounts) > 200 { + return errors.New("accounts must be less than or equal to 200") + } + return nil +} + func parseOptionalInt(value string) (int, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { diff --git a/apps/manager-server/internal/http/controller/monitoring/handler_test.go b/apps/manager-server/internal/http/controller/monitoring/handler_test.go new file mode 100644 index 000000000..33dbc13d4 --- /dev/null +++ b/apps/manager-server/internal/http/controller/monitoring/handler_test.go @@ -0,0 +1,62 @@ +package monitoring + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/app" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/config" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/security" + adminauthsvc "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/service/adminauth" + monitoringsvc "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/service/monitoring" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" +) + +func TestHandleAccountHistoryRejectsUnknownTargetFields(t *testing.T) { + st := newHandlerTestStore(t) + const adminKey = "cpamp_test_key" + credential, err := security.NewAdminCredential(adminKey, "test") + if err != nil { + t.Fatalf("create admin credential: %v", err) + } + if err := st.SaveAdminCredential(context.Background(), credential); err != nil { + t.Fatalf("save admin credential: %v", err) + } + handler := &Handler{App: &app.Context{ + AdminAuthService: adminauthsvc.New(config.Config{}, st), + MonitoringService: monitoringsvc.New(st), + }} + req := httptest.NewRequest( + http.MethodPost, + "/v0/management/monitoring/account-history", + bytes.NewBufferString(`{"accounts":[{"source_hash":"source-only"}]}`), + ) + req.Header.Set("Authorization", "Bearer "+adminKey) + recorder := httptest.NewRecorder() + + handler.Handle(recorder, req) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "source_hash") { + t.Fatalf("body = %s", recorder.Body.String()) + } +} + +func newHandlerTestStore(t testing.TB) *store.Store { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "usage.sqlite")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { + _ = st.Close() + }) + return st +} diff --git a/apps/manager-server/internal/http/controller/panel/handler.go b/apps/manager-server/internal/http/controller/panel/handler.go index 7b5f2ddc6..fa76b6c57 100644 --- a/apps/manager-server/internal/http/controller/panel/handler.go +++ b/apps/manager-server/internal/http/controller/panel/handler.go @@ -12,5 +12,5 @@ type Handler struct { } func (h *Handler) ManagementHTML(w http.ResponseWriter, r *http.Request) { - h.App.PanelService.ServeManagementHTML(w, response.Error) + h.App.PanelService.ServeManagementHTML(w, r, response.Error) } diff --git a/apps/manager-server/internal/httpapi/server_compat_test.go b/apps/manager-server/internal/httpapi/server_compat_test.go index 25d3c3a01..c7772c28e 100644 --- a/apps/manager-server/internal/httpapi/server_compat_test.go +++ b/apps/manager-server/internal/httpapi/server_compat_test.go @@ -96,12 +96,16 @@ func TestServerCompatHealthInfoAndPanel(t *testing.T) { if !strings.Contains(strings.ToLower(panelRR.Body.String()), "custom panel")); err != nil { + customPanel := "custom panel" + if err := osWriteFile(panelPath, []byte(customPanel)); err != nil { t.Fatalf("write panel: %v", err) } cfg.PanelPath = panelPath @@ -109,9 +113,12 @@ func TestServerCompatPanelPathOverridesEmbeddedPanel(t *testing.T) { rr := testutil.Request(t, handler, http.MethodGet, "/management.html", "", "") testutil.RequireStatus(t, rr, http.StatusOK) - if rr.Body.String() != "custom panel" { + if rr.Body.String() != customPanel { t.Fatalf("panel body = %q", rr.Body.String()) } + if got, want := rr.Header().Get("Content-Length"), strconv.Itoa(len(customPanel)); got != want { + t.Fatalf("panel content length = %q, want %q", got, want) + } } func TestServerCompatSetupConfigAndEnvLock(t *testing.T) { diff --git a/apps/manager-server/internal/repository/sqlite/migrate.go b/apps/manager-server/internal/repository/sqlite/migrate.go index a556d2518..e7e01b1b0 100644 --- a/apps/manager-server/internal/repository/sqlite/migrate.go +++ b/apps/manager-server/internal/repository/sqlite/migrate.go @@ -67,6 +67,42 @@ func Migrate(db *sql.DB) error { `create index if not exists idx_usage_events_model on usage_events(model)`, `create index if not exists idx_usage_events_auth_index on usage_events(auth_index)`, `create index if not exists idx_usage_events_endpoint on usage_events(endpoint)`, + `create table if not exists usage_rollup_checkpoints ( + name text primary key, + last_event_id integer not null default 0, + updated_at_ms integer not null, + last_error text, + last_run_started_at_ms integer, + last_run_finished_at_ms integer + )`, + `create table if not exists usage_account_model_rollups ( + account_key text not null, + account_snapshot text, + auth_label_snapshot text, + auth_provider_snapshot text, + auth_index text, + source text, + source_hash text, + model text not null, + billing_model text not null, + service_tier text not null, + calls integer not null default 0, + success_calls integer not null default 0, + failure_calls integer not null default 0, + input_tokens integer not null default 0, + output_tokens integer not null default 0, + reasoning_tokens integer not null default 0, + cached_tokens integer not null default 0, + cache_read_tokens integer not null default 0, + cache_creation_tokens integer not null default 0, + total_tokens integer not null default 0, + first_seen_ms integer not null, + last_seen_ms integer not null, + updated_at_ms integer not null, + primary key (account_key, billing_model, service_tier) + )`, + `create index if not exists idx_usage_account_model_rollups_last_seen on usage_account_model_rollups(last_seen_ms)`, + `create index if not exists idx_usage_account_model_rollups_auth_index on usage_account_model_rollups(auth_index)`, `create table if not exists dead_letter_events ( id integer primary key autoincrement, payload text not null, diff --git a/apps/manager-server/internal/repository/usagerollup/repository.go b/apps/manager-server/internal/repository/usagerollup/repository.go new file mode 100644 index 000000000..b8b67c702 --- /dev/null +++ b/apps/manager-server/internal/repository/usagerollup/repository.go @@ -0,0 +1,647 @@ +package usagerollup + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +const AccountHistoryCheckpointName = "account_history" + +type Repository interface { + CatchUpAccountHistory(ctx context.Context, limit int, nowMS int64) (CatchUpResult, error) + Checkpoint(ctx context.Context, name string) (Checkpoint, error) + LatestEventID(ctx context.Context) (int64, error) + AccountHistoryRows(ctx context.Context, accountKeys []string) ([]AccountHistoryRow, error) +} + +type Checkpoint struct { + Name string + LastEventID int64 + UpdatedAtMS int64 + LastError string + LastRunStartedAtMS int64 + LastRunFinishedAtMS int64 +} + +type CatchUpResult struct { + Processed int + LastEventID int64 + Pending bool +} + +type AccountHistoryRow struct { + AccountKey string + AccountSnapshot string + AuthLabelSnapshot string + AuthProviderSnapshot string + AuthIndex string + Source string + SourceHash string + Model string + BillingModel string + ServiceTier string + Calls int64 + SuccessCalls int64 + FailureCalls int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + CacheReadTokens int64 + CacheCreationTokens int64 + TotalTokens int64 + FirstSeenMS int64 + LastSeenMS int64 + UpdatedAtMS int64 +} + +type repository struct { + db *sql.DB + catchUpGate chan struct{} +} + +func New(db *sql.DB) Repository { + return &repository{ + db: db, + catchUpGate: make(chan struct{}, 1), + } +} + +type eventRow struct { + ID int64 + TimestampMS int64 + AccountSnapshot string + AuthLabelSnapshot string + AuthProviderSnapshot string + AuthIndex string + Source string + SourceHash string + Model string + BillingModel string + ServiceTier string + Failed bool + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + CacheTokens int64 + CacheReadTokens int64 + CacheCreationTokens int64 + TotalTokens int64 +} + +func (r *repository) CatchUpAccountHistory(ctx context.Context, limit int, nowMS int64) (CatchUpResult, error) { + if limit <= 0 { + limit = 1000 + } + if nowMS <= 0 { + return CatchUpResult{}, errors.New("nowMS must be greater than 0") + } + if err := r.acquireCatchUp(ctx); err != nil { + return CatchUpResult{}, err + } + defer r.releaseCatchUp() + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return CatchUpResult{}, err + } + defer func() { + _ = tx.Rollback() + }() + + checkpoint, err := checkpointInTx(ctx, tx, AccountHistoryCheckpointName) + if err != nil { + return CatchUpResult{}, err + } + events, err := eventsAfterCheckpoint(ctx, tx, checkpoint.LastEventID, limit) + if err != nil { + return CatchUpResult{}, err + } + latestID, err := latestEventIDInTx(ctx, tx) + if err != nil { + return CatchUpResult{}, err + } + if len(events) == 0 { + if err := upsertCheckpoint(ctx, tx, AccountHistoryCheckpointName, checkpoint.LastEventID, nowMS, nowMS, nowMS, ""); err != nil { + return CatchUpResult{}, err + } + if err := tx.Commit(); err != nil { + return CatchUpResult{}, err + } + return CatchUpResult{LastEventID: checkpoint.LastEventID, Pending: latestID > checkpoint.LastEventID}, nil + } + + rollups := aggregateAccountHistory(events, nowMS) + if err := upsertAccountRollups(ctx, tx, rollups); err != nil { + return CatchUpResult{}, err + } + lastEventID := events[len(events)-1].ID + if err := upsertCheckpoint(ctx, tx, AccountHistoryCheckpointName, lastEventID, nowMS, nowMS, nowMS, ""); err != nil { + return CatchUpResult{}, err + } + if err := tx.Commit(); err != nil { + return CatchUpResult{}, err + } + return CatchUpResult{ + Processed: len(events), + LastEventID: lastEventID, + Pending: latestID > lastEventID, + }, nil +} + +func (r *repository) acquireCatchUp(ctx context.Context) error { + select { + case r.catchUpGate <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (r *repository) releaseCatchUp() { + select { + case <-r.catchUpGate: + default: + } +} + +func (r *repository) Checkpoint(ctx context.Context, name string) (Checkpoint, error) { + if strings.TrimSpace(name) == "" { + name = AccountHistoryCheckpointName + } + return checkpointQuery(ctx, r.db, name) +} + +func (r *repository) LatestEventID(ctx context.Context) (int64, error) { + var id int64 + if err := r.db.QueryRowContext(ctx, `select coalesce(max(id), 0) from usage_events`).Scan(&id); err != nil { + return 0, err + } + return id, nil +} + +func (r *repository) AccountHistoryRows(ctx context.Context, accountKeys []string) ([]AccountHistoryRow, error) { + keys := normalizeAccountKeys(accountKeys) + if len(keys) == 0 { + return []AccountHistoryRow{}, nil + } + placeholders := strings.TrimRight(strings.Repeat("?,", len(keys)), ",") + args := make([]any, 0, len(keys)) + for _, key := range keys { + args = append(args, key) + } + rows, err := r.db.QueryContext(ctx, `select + account_key, + coalesce(account_snapshot, ''), + coalesce(auth_label_snapshot, ''), + coalesce(auth_provider_snapshot, ''), + coalesce(auth_index, ''), + coalesce(source, ''), + coalesce(source_hash, ''), + model, + billing_model, + service_tier, + calls, + success_calls, + failure_calls, + input_tokens, + output_tokens, + reasoning_tokens, + cached_tokens, + cache_read_tokens, + cache_creation_tokens, + total_tokens, + first_seen_ms, + last_seen_ms, + updated_at_ms +from usage_account_model_rollups +where account_key in (`+placeholders+`) +order by account_key, last_seen_ms desc`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]AccountHistoryRow, 0) + for rows.Next() { + var row AccountHistoryRow + if err := rows.Scan( + &row.AccountKey, + &row.AccountSnapshot, + &row.AuthLabelSnapshot, + &row.AuthProviderSnapshot, + &row.AuthIndex, + &row.Source, + &row.SourceHash, + &row.Model, + &row.BillingModel, + &row.ServiceTier, + &row.Calls, + &row.SuccessCalls, + &row.FailureCalls, + &row.InputTokens, + &row.OutputTokens, + &row.ReasoningTokens, + &row.CachedTokens, + &row.CacheReadTokens, + &row.CacheCreationTokens, + &row.TotalTokens, + &row.FirstSeenMS, + &row.LastSeenMS, + &row.UpdatedAtMS, + ); err != nil { + return nil, err + } + result = append(result, row) + } + return result, rows.Err() +} + +func checkpointQuery(ctx context.Context, db *sql.DB, name string) (Checkpoint, error) { + var cp Checkpoint + var lastError sql.NullString + var started, finished sql.NullInt64 + err := db.QueryRowContext(ctx, `select + name, last_event_id, updated_at_ms, last_error, last_run_started_at_ms, last_run_finished_at_ms +from usage_rollup_checkpoints +where name = ?`, name).Scan( + &cp.Name, + &cp.LastEventID, + &cp.UpdatedAtMS, + &lastError, + &started, + &finished, + ) + if errors.Is(err, sql.ErrNoRows) { + return Checkpoint{Name: name}, nil + } + if err != nil { + return Checkpoint{}, err + } + cp.LastError = lastError.String + cp.LastRunStartedAtMS = started.Int64 + cp.LastRunFinishedAtMS = finished.Int64 + return cp, nil +} + +func checkpointInTx(ctx context.Context, tx *sql.Tx, name string) (Checkpoint, error) { + var cp Checkpoint + var lastError sql.NullString + var started, finished sql.NullInt64 + err := tx.QueryRowContext(ctx, `select + name, last_event_id, updated_at_ms, last_error, last_run_started_at_ms, last_run_finished_at_ms +from usage_rollup_checkpoints +where name = ?`, name).Scan( + &cp.Name, + &cp.LastEventID, + &cp.UpdatedAtMS, + &lastError, + &started, + &finished, + ) + if errors.Is(err, sql.ErrNoRows) { + return Checkpoint{Name: name}, nil + } + if err != nil { + return Checkpoint{}, err + } + cp.LastError = lastError.String + cp.LastRunStartedAtMS = started.Int64 + cp.LastRunFinishedAtMS = finished.Int64 + return cp, nil +} + +func latestEventIDInTx(ctx context.Context, tx *sql.Tx) (int64, error) { + var id int64 + if err := tx.QueryRowContext(ctx, `select coalesce(max(id), 0) from usage_events`).Scan(&id); err != nil { + return 0, err + } + return id, nil +} + +func eventsAfterCheckpoint(ctx context.Context, tx *sql.Tx, lastEventID int64, limit int) ([]eventRow, error) { + rows, err := tx.QueryContext(ctx, `select + id, + timestamp_ms, + coalesce(account_snapshot, ''), + coalesce(auth_label_snapshot, ''), + coalesce(nullif(auth_provider_snapshot, ''), provider, ''), + coalesce(auth_index, ''), + coalesce(source, ''), + coalesce(source_hash, ''), + model, + coalesce(nullif(resolved_model, ''), model) as billing_model, + coalesce(service_tier, '') as service_tier, + failed, + coalesce(input_tokens, 0), + coalesce(output_tokens, 0), + coalesce(reasoning_tokens, 0), + coalesce(cached_tokens, 0), + coalesce(cache_tokens, 0), + coalesce(cache_read_tokens, 0), + coalesce(cache_creation_tokens, 0), + coalesce(total_tokens, 0) +from usage_events +where id > ? +order by id +limit ?`, lastEventID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + events := make([]eventRow, 0, limit) + for rows.Next() { + var row eventRow + var failed int + if err := rows.Scan( + &row.ID, + &row.TimestampMS, + &row.AccountSnapshot, + &row.AuthLabelSnapshot, + &row.AuthProviderSnapshot, + &row.AuthIndex, + &row.Source, + &row.SourceHash, + &row.Model, + &row.BillingModel, + &row.ServiceTier, + &failed, + &row.InputTokens, + &row.OutputTokens, + &row.ReasoningTokens, + &row.CachedTokens, + &row.CacheTokens, + &row.CacheReadTokens, + &row.CacheCreationTokens, + &row.TotalTokens, + ); err != nil { + return nil, err + } + row.Failed = failed != 0 + row.CachedTokens = usage.CompatibleCachedTokens( + row.CachedTokens, + row.CacheTokens, + row.CacheReadTokens, + row.CacheCreationTokens, + ) + events = append(events, row) + } + return events, rows.Err() +} + +type accountRollupKey struct { + AccountKey string + BillingModel string + ServiceTier string +} + +func aggregateAccountHistory(events []eventRow, nowMS int64) []AccountHistoryRow { + grouped := map[accountRollupKey]*AccountHistoryRow{} + for _, event := range events { + accountKey := accountGroupKey(event.AccountSnapshot, event.AuthLabelSnapshot, event.Source, event.AuthIndex) + billingModel := strings.TrimSpace(event.BillingModel) + if billingModel == "" { + billingModel = strings.TrimSpace(event.Model) + } + if billingModel == "" { + billingModel = "-" + } + serviceTier := strings.TrimSpace(event.ServiceTier) + key := accountRollupKey{ + AccountKey: accountKey, + BillingModel: billingModel, + ServiceTier: serviceTier, + } + row := grouped[key] + if row == nil { + modelName := strings.TrimSpace(event.Model) + if modelName == "" { + modelName = billingModel + } + row = &AccountHistoryRow{ + AccountKey: accountKey, + AccountSnapshot: event.AccountSnapshot, + AuthLabelSnapshot: event.AuthLabelSnapshot, + AuthProviderSnapshot: event.AuthProviderSnapshot, + AuthIndex: event.AuthIndex, + Source: event.Source, + SourceHash: event.SourceHash, + Model: modelName, + BillingModel: billingModel, + ServiceTier: serviceTier, + FirstSeenMS: event.TimestampMS, + LastSeenMS: event.TimestampMS, + UpdatedAtMS: nowMS, + } + grouped[key] = row + } + fillSnapshotFields(row, event) + row.Calls++ + if event.Failed { + row.FailureCalls++ + } else { + row.SuccessCalls++ + } + row.InputTokens += event.InputTokens + row.OutputTokens += event.OutputTokens + row.ReasoningTokens += event.ReasoningTokens + row.CachedTokens += event.CachedTokens + row.CacheReadTokens += event.CacheReadTokens + row.CacheCreationTokens += event.CacheCreationTokens + row.TotalTokens += event.TotalTokens + if event.TimestampMS < row.FirstSeenMS { + row.FirstSeenMS = event.TimestampMS + } + if event.TimestampMS > row.LastSeenMS { + row.LastSeenMS = event.TimestampMS + } + } + result := make([]AccountHistoryRow, 0, len(grouped)) + for _, row := range grouped { + result = append(result, *row) + } + return result +} + +func fillSnapshotFields(row *AccountHistoryRow, event eventRow) { + if row.AccountSnapshot == "" { + row.AccountSnapshot = event.AccountSnapshot + } + if row.AuthLabelSnapshot == "" { + row.AuthLabelSnapshot = event.AuthLabelSnapshot + } + if row.AuthProviderSnapshot == "" { + row.AuthProviderSnapshot = event.AuthProviderSnapshot + } + if row.AuthIndex == "" { + row.AuthIndex = event.AuthIndex + } + if row.Source == "" { + row.Source = event.Source + } + if row.SourceHash == "" { + row.SourceHash = event.SourceHash + } +} + +func upsertAccountRollups(ctx context.Context, tx *sql.Tx, rows []AccountHistoryRow) error { + if len(rows) == 0 { + return nil + } + stmt, err := tx.PrepareContext(ctx, `insert into usage_account_model_rollups ( + account_key, + account_snapshot, + auth_label_snapshot, + auth_provider_snapshot, + auth_index, + source, + source_hash, + model, + billing_model, + service_tier, + calls, + success_calls, + failure_calls, + input_tokens, + output_tokens, + reasoning_tokens, + cached_tokens, + cache_read_tokens, + cache_creation_tokens, + total_tokens, + first_seen_ms, + last_seen_ms, + updated_at_ms +) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +on conflict(account_key, billing_model, service_tier) do update set + account_snapshot = coalesce(nullif(excluded.account_snapshot, ''), usage_account_model_rollups.account_snapshot), + auth_label_snapshot = coalesce(nullif(excluded.auth_label_snapshot, ''), usage_account_model_rollups.auth_label_snapshot), + auth_provider_snapshot = coalesce(nullif(excluded.auth_provider_snapshot, ''), usage_account_model_rollups.auth_provider_snapshot), + auth_index = coalesce(nullif(excluded.auth_index, ''), usage_account_model_rollups.auth_index), + source = coalesce(nullif(excluded.source, ''), usage_account_model_rollups.source), + source_hash = coalesce(nullif(excluded.source_hash, ''), usage_account_model_rollups.source_hash), + model = coalesce(nullif(excluded.model, ''), usage_account_model_rollups.model), + calls = usage_account_model_rollups.calls + excluded.calls, + success_calls = usage_account_model_rollups.success_calls + excluded.success_calls, + failure_calls = usage_account_model_rollups.failure_calls + excluded.failure_calls, + input_tokens = usage_account_model_rollups.input_tokens + excluded.input_tokens, + output_tokens = usage_account_model_rollups.output_tokens + excluded.output_tokens, + reasoning_tokens = usage_account_model_rollups.reasoning_tokens + excluded.reasoning_tokens, + cached_tokens = usage_account_model_rollups.cached_tokens + excluded.cached_tokens, + cache_read_tokens = usage_account_model_rollups.cache_read_tokens + excluded.cache_read_tokens, + cache_creation_tokens = usage_account_model_rollups.cache_creation_tokens + excluded.cache_creation_tokens, + total_tokens = usage_account_model_rollups.total_tokens + excluded.total_tokens, + first_seen_ms = min(usage_account_model_rollups.first_seen_ms, excluded.first_seen_ms), + last_seen_ms = max(usage_account_model_rollups.last_seen_ms, excluded.last_seen_ms), + updated_at_ms = excluded.updated_at_ms`) + if err != nil { + return err + } + defer stmt.Close() + + for _, row := range rows { + if _, err := stmt.ExecContext( + ctx, + row.AccountKey, + nullString(row.AccountSnapshot), + nullString(row.AuthLabelSnapshot), + nullString(row.AuthProviderSnapshot), + nullString(row.AuthIndex), + nullString(row.Source), + nullString(row.SourceHash), + row.Model, + row.BillingModel, + row.ServiceTier, + row.Calls, + row.SuccessCalls, + row.FailureCalls, + row.InputTokens, + row.OutputTokens, + row.ReasoningTokens, + row.CachedTokens, + row.CacheReadTokens, + row.CacheCreationTokens, + row.TotalTokens, + row.FirstSeenMS, + row.LastSeenMS, + row.UpdatedAtMS, + ); err != nil { + return err + } + } + return nil +} + +func upsertCheckpoint(ctx context.Context, tx *sql.Tx, name string, lastEventID int64, updatedAtMS int64, startedAtMS int64, finishedAtMS int64, lastError string) error { + _, err := tx.ExecContext(ctx, `insert into usage_rollup_checkpoints ( + name, last_event_id, updated_at_ms, last_error, last_run_started_at_ms, last_run_finished_at_ms +) values (?, ?, ?, ?, ?, ?) +on conflict(name) do update set + last_event_id = excluded.last_event_id, + updated_at_ms = excluded.updated_at_ms, + last_error = excluded.last_error, + last_run_started_at_ms = excluded.last_run_started_at_ms, + last_run_finished_at_ms = excluded.last_run_finished_at_ms`, + name, + lastEventID, + updatedAtMS, + nullString(lastError), + nullPositiveInt64(startedAtMS), + nullPositiveInt64(finishedAtMS), + ) + return err +} + +func accountGroupKey(accountSnapshot, authLabelSnapshot, source, authIndex string) string { + if strings.TrimSpace(accountSnapshot) != "" { + return accountSnapshot + } + if strings.TrimSpace(authLabelSnapshot) != "" { + return authLabelSnapshot + } + if strings.TrimSpace(source) != "" { + return source + } + if strings.TrimSpace(authIndex) != "" { + return authIndex + } + return "-" +} + +func normalizeAccountKeys(values []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + key := strings.TrimSpace(value) + if key == "" || seen[key] { + continue + } + seen[key] = true + result = append(result, key) + } + return result +} + +func nullString(value string) any { + if strings.TrimSpace(value) == "" { + return nil + } + return value +} + +func nullPositiveInt64(value int64) any { + if value <= 0 { + return nil + } + return value +} + +func AccountKey(accountSnapshot, authLabelSnapshot, source, authIndex string) string { + return accountGroupKey(accountSnapshot, authLabelSnapshot, source, authIndex) +} diff --git a/apps/manager-server/internal/repository/usagerollup/repository_benchmark_test.go b/apps/manager-server/internal/repository/usagerollup/repository_benchmark_test.go new file mode 100644 index 000000000..4a7365d8b --- /dev/null +++ b/apps/manager-server/internal/repository/usagerollup/repository_benchmark_test.go @@ -0,0 +1,171 @@ +package usagerollup + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "testing" + + sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usageevent" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +const ( + benchmarkAccountCount = 200 + benchmarkModelCount = 8 + benchmarkBaseMS = int64(1_700_000_000_000) + benchmarkNowMS = int64(1_700_100_000_000) +) + +func BenchmarkCatchUpAccountHistoryInitial(b *testing.B) { + for _, eventCount := range []int{1000, 10000} { + b.Run(fmt.Sprintf("events_%d", eventCount), func(b *testing.B) { + b.ReportAllocs() + ctx := context.Background() + for i := 0; i < b.N; i++ { + b.StopTimer() + db, closeDB := openRollupBenchmarkDB(b) + events := usageevent.New(db) + repo := New(db) + insertRollupBenchmarkEvents(b, ctx, events, benchmarkRollupEvents("initial", i*eventCount, eventCount)) + b.StartTimer() + + result, err := repo.CatchUpAccountHistory(ctx, eventCount, benchmarkNowMS) + + b.StopTimer() + if err != nil { + closeDB() + b.Fatalf("catch-up: %v", err) + } + if result.Processed != eventCount { + closeDB() + b.Fatalf("processed = %d, want %d", result.Processed, eventCount) + } + closeDB() + } + }) + } +} + +func BenchmarkCatchUpAccountHistoryIncremental(b *testing.B) { + b.ReportAllocs() + ctx := context.Background() + db, closeDB := openRollupBenchmarkDB(b) + defer closeDB() + events := usageevent.New(db) + repo := New(db) + insertRollupBenchmarkEvents(b, ctx, events, benchmarkRollupEvents("baseline", 0, 10000)) + if result, err := repo.CatchUpAccountHistory(ctx, 10000, benchmarkNowMS); err != nil { + b.Fatalf("baseline catch-up: %v", err) + } else if result.Processed != 10000 { + b.Fatalf("baseline processed = %d", result.Processed) + } + + const batchSize = 1000 + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + start := 10000 + i*batchSize + insertRollupBenchmarkEvents(b, ctx, events, benchmarkRollupEvents("incremental", start, batchSize)) + b.StartTimer() + + result, err := repo.CatchUpAccountHistory(ctx, batchSize, benchmarkNowMS+int64(i+1)) + + b.StopTimer() + if err != nil { + b.Fatalf("incremental catch-up: %v", err) + } + if result.Processed != batchSize { + b.Fatalf("processed = %d, want %d", result.Processed, batchSize) + } + } +} + +func BenchmarkAccountHistoryRows(b *testing.B) { + ctx := context.Background() + db, closeDB := openRollupBenchmarkDB(b) + defer closeDB() + events := usageevent.New(db) + repo := New(db) + insertRollupBenchmarkEvents(b, ctx, events, benchmarkRollupEvents("rows", 0, 20000)) + if _, err := repo.CatchUpAccountHistory(ctx, 20000, benchmarkNowMS); err != nil { + b.Fatalf("catch-up: %v", err) + } + + for _, targetCount := range []int{20, 100, 200} { + keys := benchmarkAccountKeys(targetCount) + b.Run(fmt.Sprintf("targets_%d", targetCount), func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rows, err := repo.AccountHistoryRows(ctx, keys) + if err != nil { + b.Fatalf("rows: %v", err) + } + if len(rows) == 0 { + b.Fatalf("expected rows") + } + } + }) + } +} + +func openRollupBenchmarkDB(b *testing.B) (*sql.DB, func()) { + b.Helper() + db, err := sqliterepo.Open(filepath.Join(b.TempDir(), "usage.sqlite")) + if err != nil { + b.Fatalf("open sqlite: %v", err) + } + return db, func() { + _ = db.Close() + } +} + +func insertRollupBenchmarkEvents(b *testing.B, ctx context.Context, repo usageevent.Repository, events []usage.Event) { + b.Helper() + if _, err := repo.InsertBatch(ctx, events); err != nil { + b.Fatalf("insert events: %v", err) + } +} + +func benchmarkRollupEvents(prefix string, start int, count int) []usage.Event { + result := make([]usage.Event, 0, count) + for offset := 0; offset < count; offset++ { + seq := start + offset + accountIndex := seq % benchmarkAccountCount + cycle := seq / benchmarkAccountCount + modelIndex := (accountIndex + cycle) % benchmarkModelCount + event := rollupTestEvent( + fmt.Sprintf("%s-%08d", prefix, seq), + benchmarkBaseMS+int64(seq), + fmt.Sprintf("alias-%02d", modelIndex), + fmt.Sprintf("resolved-%02d", modelIndex), + fmt.Sprintf("account-%04d@example.com", accountIndex), + "", + fmt.Sprintf("auth-%04d", accountIndex), + seq%17 == 0, + int64(100+seq%97), + int64(40+seq%53), + int64(seq%11), + int64(seq%7), + int64(seq%5), + int64(seq%3), + int64(140+seq%151), + ) + if seq%5 == 0 { + event.ServiceTier = "priority" + } + result = append(result, event) + } + return result +} + +func benchmarkAccountKeys(count int) []string { + keys := make([]string, 0, count) + for index := 0; index < count; index++ { + keys = append(keys, fmt.Sprintf("account-%04d@example.com", index)) + } + return keys +} diff --git a/apps/manager-server/internal/repository/usagerollup/repository_test.go b/apps/manager-server/internal/repository/usagerollup/repository_test.go new file mode 100644 index 000000000..5508e7d40 --- /dev/null +++ b/apps/manager-server/internal/repository/usagerollup/repository_test.go @@ -0,0 +1,254 @@ +package usagerollup + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "sync" + "testing" + "time" + + sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usageevent" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +func TestMigrationCreatesAccountHistoryRollupTables(t *testing.T) { + db := newRollupTestDB(t) + + for _, table := range []string{"usage_rollup_checkpoints", "usage_account_model_rollups"} { + var count int + if err := db.QueryRow(`select count(*) from sqlite_master where type = 'table' and name = ?`, table).Scan(&count); err != nil { + t.Fatalf("query sqlite_master for %s: %v", table, err) + } + if count != 1 { + t.Fatalf("expected table %s to exist", table) + } + } +} + +func TestCatchUpAccountHistoryAggregatesByCheckpoint(t *testing.T) { + db := newRollupTestDB(t) + ctx := context.Background() + events := usageevent.New(db) + repo := New(db) + + if _, err := events.InsertBatch(ctx, []usage.Event{ + rollupTestEvent("rollup-a-1", 1_700_000_001_000, "alias-a", "resolved-a", "alice@example.com", "", "auth-a", false, 100, 50, 10, 40, 10, 5, 165), + rollupTestEvent("rollup-a-2", 1_700_000_002_000, "alias-a", "resolved-a", "alice@example.com", "", "auth-a", true, 20, 10, 0, 0, 0, 0, 30), + rollupTestEvent("rollup-b-1", 1_700_000_003_000, "alias-b", "", "", "team-b", "auth-b", false, 5, 6, 0, 0, 0, 0, 11), + }); err != nil { + t.Fatalf("insert events: %v", err) + } + + first, err := repo.CatchUpAccountHistory(ctx, 2, 1_700_000_010_000) + if err != nil { + t.Fatalf("first catch-up: %v", err) + } + if first.Processed != 2 || first.LastEventID != 2 || !first.Pending { + t.Fatalf("first catch-up = %#v", first) + } + + aliceRows, err := repo.AccountHistoryRows(ctx, []string{"alice@example.com"}) + if err != nil { + t.Fatalf("query alice rows: %v", err) + } + if len(aliceRows) != 1 { + t.Fatalf("alice rows = %#v", aliceRows) + } + alice := aliceRows[0] + if alice.Calls != 2 || alice.SuccessCalls != 1 || alice.FailureCalls != 1 { + t.Fatalf("alice calls = %#v", alice) + } + if alice.BillingModel != "resolved-a" || alice.Model != "alias-a" { + t.Fatalf("alice model fields = %#v", alice) + } + if alice.InputTokens != 120 || alice.OutputTokens != 60 || alice.CachedTokens != 25 || alice.TotalTokens != 195 { + t.Fatalf("alice token totals = %#v", alice) + } + + second, err := repo.CatchUpAccountHistory(ctx, 10, 1_700_000_011_000) + if err != nil { + t.Fatalf("second catch-up: %v", err) + } + if second.Processed != 1 || second.LastEventID != 3 || second.Pending { + t.Fatalf("second catch-up = %#v", second) + } + third, err := repo.CatchUpAccountHistory(ctx, 10, 1_700_000_012_000) + if err != nil { + t.Fatalf("third catch-up: %v", err) + } + if third.Processed != 0 || third.LastEventID != 3 || third.Pending { + t.Fatalf("third catch-up = %#v", third) + } + + rows, err := repo.AccountHistoryRows(ctx, []string{"alice@example.com", "team-b"}) + if err != nil { + t.Fatalf("query rows: %v", err) + } + if len(rows) != 2 { + t.Fatalf("rows = %#v", rows) + } + for _, row := range rows { + if row.AccountKey == "alice@example.com" && row.Calls != 2 { + t.Fatalf("alice was double-counted: %#v", row) + } + if row.AccountKey == "team-b" && (row.Calls != 1 || row.BillingModel != "alias-b") { + t.Fatalf("team-b row = %#v", row) + } + } + checkpoint, err := repo.Checkpoint(ctx, AccountHistoryCheckpointName) + if err != nil { + t.Fatalf("checkpoint: %v", err) + } + if checkpoint.LastEventID != 3 { + t.Fatalf("checkpoint = %#v", checkpoint) + } +} + +func TestCatchUpAccountHistoryFailureDoesNotAdvanceCheckpoint(t *testing.T) { + db := newRollupTestDB(t) + ctx := context.Background() + events := usageevent.New(db) + repo := New(db) + + if _, err := events.InsertBatch(ctx, []usage.Event{ + rollupTestEvent("rollup-failure", 1_700_000_001_000, "gpt-a", "", "alice@example.com", "", "auth-a", false, 1, 1, 0, 0, 0, 0, 2), + }); err != nil { + t.Fatalf("insert events: %v", err) + } + if _, err := db.Exec(`drop table usage_account_model_rollups`); err != nil { + t.Fatalf("drop rollup table: %v", err) + } + if _, err := repo.CatchUpAccountHistory(ctx, 10, 1_700_000_010_000); err == nil { + t.Fatalf("expected catch-up to fail") + } + checkpoint, err := repo.Checkpoint(ctx, AccountHistoryCheckpointName) + if err != nil { + t.Fatalf("checkpoint: %v", err) + } + if checkpoint.LastEventID != 0 { + t.Fatalf("checkpoint advanced after failed catch-up: %#v", checkpoint) + } +} + +func TestCatchUpAccountHistorySerializesConcurrentCalls(t *testing.T) { + db := newRollupTestDB(t) + ctx := context.Background() + events := usageevent.New(db) + repo := New(db) + + input := make([]usage.Event, 0, 25) + for index := 0; index < 25; index++ { + input = append(input, rollupTestEvent( + fmt.Sprintf("rollup-concurrent-%02d", index), + 1_700_000_001_000+int64(index), + "gpt-a", + "", + "concurrent@example.com", + "", + "auth-a", + false, + 1, + 2, + 0, + 0, + 0, + 0, + 3, + )) + } + if _, err := events.InsertBatch(ctx, input); err != nil { + t.Fatalf("insert events: %v", err) + } + + start := make(chan struct{}) + var wg sync.WaitGroup + errs := make(chan error, 8) + for index := 0; index < 8; index++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := repo.CatchUpAccountHistory(ctx, 25, 1_700_000_010_000) + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("catch-up failed: %v", err) + } + } + + rows, err := repo.AccountHistoryRows(ctx, []string{"concurrent@example.com"}) + if err != nil { + t.Fatalf("query rows: %v", err) + } + if len(rows) != 1 { + t.Fatalf("rows = %#v", rows) + } + if rows[0].Calls != 25 || rows[0].InputTokens != 25 || rows[0].OutputTokens != 50 || rows[0].TotalTokens != 75 { + t.Fatalf("concurrent rollup was not serialized: %#v", rows[0]) + } +} + +func newRollupTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sqliterepo.Open(filepath.Join(t.TempDir(), "usage.sqlite")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + return db +} + +func rollupTestEvent( + hash string, + timestampMS int64, + model string, + resolvedModel string, + accountSnapshot string, + authLabelSnapshot string, + authIndex string, + failed bool, + inputTokens int64, + outputTokens int64, + reasoningTokens int64, + cachedTokens int64, + cacheReadTokens int64, + cacheCreationTokens int64, + totalTokens int64, +) usage.Event { + return usage.Event{ + EventHash: hash, + TimestampMS: timestampMS, + Timestamp: time.UnixMilli(timestampMS).UTC().Format(time.RFC3339Nano), + Provider: "openai", + Model: model, + ResolvedModel: resolvedModel, + Endpoint: "POST /v1/chat/completions", + Method: "POST", + Path: "/v1/chat/completions", + AuthIndex: authIndex, + Source: accountSnapshot, + SourceHash: "source-" + authIndex, + AccountSnapshot: accountSnapshot, + AuthLabelSnapshot: authLabelSnapshot, + AuthProviderSnapshot: "openai", + InputTokens: inputTokens, + OutputTokens: outputTokens, + ReasoningTokens: reasoningTokens, + CachedTokens: cachedTokens, + CacheReadTokens: cacheReadTokens, + CacheCreationTokens: cacheCreationTokens, + TotalTokens: totalTokens, + Failed: failed, + CreatedAtMS: timestampMS, + } +} diff --git a/apps/manager-server/internal/service/accountaction/service.go b/apps/manager-server/internal/service/accountaction/service.go index bf255fae0..0a7c956a9 100644 --- a/apps/manager-server/internal/service/accountaction/service.go +++ b/apps/manager-server/internal/service/accountaction/service.go @@ -67,7 +67,7 @@ func (s *Service) Enable(ctx context.Context, id int64) (model.AccountActionCand if _, err := s.verifyCurrentAuthFile(ctx, setup, item); err != nil { return model.AccountActionCandidate{}, err } - if err := s.patchAuthFile(ctx, setup, item.AuthFileName, false); err != nil { + if err := s.patchAuthFile(ctx, setup, item.AuthFileName, item.AuthIndex, false); err != nil { _ = s.store.RecordAccountActionCandidateFailure(ctx, id, err.Error()) return model.AccountActionCandidate{}, err } @@ -135,12 +135,7 @@ func (s *Service) resolveCandidateAndSetup(ctx context.Context, id int64) (model } func (s *Service) verifyCurrentAuthFile(ctx context.Context, setup store.Setup, item model.AccountActionCandidate) (authFile, error) { - files, err := cpaauthfiles.New(s.client).Fetch(ctx, setup.CPAUpstreamURL, setup.ManagementKey) - if err != nil { - _ = s.store.RecordAccountActionCandidateFailure(ctx, item.ID, err.Error()) - return authFile{}, err - } - file, err := cpaauthfiles.VerifyIdentity(files, cpaauthfiles.Identity{ + file, err := cpaauthfiles.New(s.client).Verify(ctx, setup.CPAUpstreamURL, setup.ManagementKey, cpaauthfiles.Identity{ AuthFileName: item.AuthFileName, AuthIndex: item.AuthIndex, Provider: item.Provider, @@ -148,14 +143,18 @@ func (s *Service) verifyCurrentAuthFile(ctx context.Context, setup store.Setup, AccountIDSnapshot: item.AccountIDSnapshot, }) if err != nil { + if !errors.Is(err, cpaauthfiles.ErrAuthFileNotFound) && !errors.Is(err, cpaauthfiles.ErrIdentityMismatch) { + _ = s.store.RecordAccountActionCandidateFailure(ctx, item.ID, err.Error()) + return authFile{}, err + } return authFile{}, ErrCandidateConflict } return file, nil } -func (s *Service) patchAuthFile(ctx context.Context, setup store.Setup, fileName string, disabled bool) error { - return cpaauthfiles.New(s.client).PatchDisabled(ctx, setup.CPAUpstreamURL, setup.ManagementKey, fileName, disabled) +func (s *Service) patchAuthFile(ctx context.Context, setup store.Setup, fileName string, authIndex string, disabled bool) error { + return cpaauthfiles.New(s.client).PatchDisabled(ctx, setup.CPAUpstreamURL, setup.ManagementKey, fileName, disabled, authIndex) } func (s *Service) deleteAuthFile(ctx context.Context, setup store.Setup, fileName string) error { diff --git a/apps/manager-server/internal/service/cpaauthfiles/client.go b/apps/manager-server/internal/service/cpaauthfiles/client.go index 8b1cb4615..cbecdb302 100644 --- a/apps/manager-server/internal/service/cpaauthfiles/client.go +++ b/apps/manager-server/internal/service/cpaauthfiles/client.go @@ -59,6 +59,21 @@ func New(client *http.Client, timeout ...time.Duration) *Client { const authFilesPath = "/v0/management/auth-files" const authFilesStatusPath = "/v0/management/auth-files/status" +func authFilesEndpoint(baseURL string, fileName string, authIndex string) string { + endpoint := baseURL + authFilesPath + query := url.Values{} + if fileName = strings.TrimSpace(fileName); fileName != "" { + query.Set("name", fileName) + } + if authIndex = strings.TrimSpace(authIndex); authIndex != "" { + query.Set("auth_index", authIndex) + } + if encoded := query.Encode(); encoded != "" { + endpoint += "?" + encoded + } + return endpoint +} + func (c *Client) Fetch(ctx context.Context, baseURL string, managementKey string) ([]File, error) { base := cpa.NormalizeBaseURL(baseURL) reqCtx, cancel := context.WithTimeout(ctx, c.timeout) @@ -73,17 +88,69 @@ func (c *Client) Fetch(ctx context.Context, baseURL string, managementKey string return nil, fmt.Errorf("GET %s: %w", authFilesPath, err) } defer res.Body.Close() - body, _ := io.ReadAll(io.LimitReader(res.Body, 1024*1024)) if res.StatusCode < 200 || res.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) return nil, fmt.Errorf("GET %s: HTTP %d %s", authFilesPath, res.StatusCode, strings.TrimSpace(string(body))) } - files, err := Parse(body) - if err != nil { + files := make([]File, 0) + if err := scanFiles(res.Body, func(file File) (bool, error) { + files = append(files, file) + return false, nil + }); err != nil { return nil, fmt.Errorf("GET %s: %w", authFilesPath, err) } return files, nil } +func (c *Client) Find(ctx context.Context, baseURL string, managementKey string, fileName string, authIndex string) (File, bool, error) { + base := cpa.NormalizeBaseURL(baseURL) + reqCtx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, authFilesEndpoint(base, fileName, authIndex), nil) + if err != nil { + return File{}, false, fmt.Errorf("GET %s: %w", authFilesPath, err) + } + req.Header.Set("Authorization", "Bearer "+managementKey) + res, err := c.httpClient.Do(req) + if err != nil { + return File{}, false, fmt.Errorf("GET %s: %w", authFilesPath, err) + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) + return File{}, false, fmt.Errorf("GET %s: HTTP %d %s", authFilesPath, res.StatusCode, strings.TrimSpace(string(body))) + } + + var matched File + found := false + err = scanFiles(res.Body, func(file File) (bool, error) { + if !matches(file, fileName, authIndex) { + return false, nil + } + matched = file + found = true + return true, nil + }) + if err != nil { + return File{}, false, fmt.Errorf("GET %s: %w", authFilesPath, err) + } + return matched, found, nil +} + +func (c *Client) Verify(ctx context.Context, baseURL string, managementKey string, identity Identity) (File, error) { + file, ok, err := c.Find(ctx, baseURL, managementKey, identity.AuthFileName, identity.AuthIndex) + if err != nil { + return File{}, err + } + if !ok { + return File{}, ErrAuthFileNotFound + } + if err := verifyFileIdentity(file, identity); err != nil { + return File{}, err + } + return file, nil +} + func Parse(body []byte) ([]File, error) { var decoded any decoder := json.NewDecoder(bytes.NewReader(body)) @@ -98,40 +165,195 @@ func Parse(body []byte) ([]File, error) { return files, nil } +func scanFiles(body io.Reader, visit func(File) (bool, error)) error { + decoder := json.NewDecoder(body) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return fmt.Errorf("expected JSON object or array") + } + switch delimiter { + case '[': + _, err := scanFileArray(decoder, visit) + return err + case '{': + raw := make(map[string]any) + scannedList := false + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("expected object key") + } + if !isAuthFilesListKey(key) { + var value any + if err := decoder.Decode(&value); err != nil { + return err + } + raw[key] = value + continue + } + valueToken, err := decoder.Token() + if err != nil { + return err + } + valueDelimiter, ok := valueToken.(json.Delim) + if !ok || valueDelimiter != '[' { + value, err := decodeValueAfterToken(decoder, valueToken) + if err != nil { + return err + } + raw[key] = value + continue + } + scannedList = true + stopped, err := scanFileArray(decoder, visit) + if err != nil || stopped { + return err + } + } + if _, err := decoder.Token(); err != nil { + return err + } + if !scannedList && stringField(raw, "name", "file_name", "fileName", "id") != "" { + _, err := visit(FromMap(raw)) + return err + } + return nil + default: + return fmt.Errorf("expected JSON object or array") + } +} + +func decodeValueAfterToken(decoder *json.Decoder, token json.Token) (any, error) { + delimiter, ok := token.(json.Delim) + if !ok { + return token, nil + } + switch delimiter { + case '{': + value := make(map[string]any) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return nil, err + } + key, ok := keyToken.(string) + if !ok { + return nil, fmt.Errorf("expected object key") + } + var child any + if err := decoder.Decode(&child); err != nil { + return nil, err + } + value[key] = child + } + _, err := decoder.Token() + return value, err + case '[': + value := make([]any, 0) + for decoder.More() { + var child any + if err := decoder.Decode(&child); err != nil { + return nil, err + } + value = append(value, child) + } + _, err := decoder.Token() + return value, err + default: + return nil, fmt.Errorf("unexpected delimiter %q", delimiter) + } +} + +func scanFileArray(decoder *json.Decoder, visit func(File) (bool, error)) (bool, error) { + for decoder.More() { + var raw map[string]any + if err := decoder.Decode(&raw); err != nil { + return false, err + } + if len(raw) == 0 { + continue + } + stop, err := visit(FromMap(raw)) + if err != nil || stop { + return stop, err + } + } + _, err := decoder.Token() + return false, err +} + +func isAuthFilesListKey(key string) bool { + switch key { + case "auth_files", "authFiles", "files", "items", "data": + return true + default: + return false + } +} + func Find(files []File, fileName string, authIndex string) (File, bool) { fileName = strings.TrimSpace(fileName) authIndex = strings.TrimSpace(authIndex) for _, file := range files { - if file.Name != fileName { - continue - } - if authIndex != "" && file.AuthIndex != authIndex { - continue + if matches(file, fileName, authIndex) { + return file, true } - return file, true } return File{}, false } +func matches(file File, fileName string, authIndex string) bool { + fileName = strings.TrimSpace(fileName) + authIndex = strings.TrimSpace(authIndex) + if fileName != "" && file.Name != fileName { + return false + } + if authIndex != "" && file.AuthIndex != authIndex { + return false + } + return true +} + func VerifyIdentity(files []File, identity Identity) (File, error) { file, ok := Find(files, identity.AuthFileName, identity.AuthIndex) if !ok { return File{}, ErrAuthFileNotFound } + if err := verifyFileIdentity(file, identity); err != nil { + return File{}, err + } + return file, nil +} + +func verifyFileIdentity(file File, identity Identity) error { if identity.AccountIDSnapshot != "" && file.AccountID != strings.TrimSpace(identity.AccountIDSnapshot) { - return File{}, fmt.Errorf("%w: account_id mismatch (expected %q, got %q)", ErrIdentityMismatch, strings.TrimSpace(identity.AccountIDSnapshot), file.AccountID) + return fmt.Errorf("%w: account_id mismatch (expected %q, got %q)", ErrIdentityMismatch, strings.TrimSpace(identity.AccountIDSnapshot), file.AccountID) } if identity.Provider != "" && !strings.EqualFold(file.Provider, strings.TrimSpace(identity.Provider)) { - return File{}, fmt.Errorf("%w: provider mismatch (expected %q, got %q)", ErrIdentityMismatch, strings.TrimSpace(identity.Provider), file.Provider) + return fmt.Errorf("%w: provider mismatch (expected %q, got %q)", ErrIdentityMismatch, strings.TrimSpace(identity.Provider), file.Provider) } if identity.AccountSnapshot != "" && file.AccountSnapshot != strings.TrimSpace(identity.AccountSnapshot) { - return File{}, fmt.Errorf("%w: account_snapshot mismatch (expected %q, got %q)", ErrIdentityMismatch, strings.TrimSpace(identity.AccountSnapshot), file.AccountSnapshot) + return fmt.Errorf("%w: account_snapshot mismatch (expected %q, got %q)", ErrIdentityMismatch, strings.TrimSpace(identity.AccountSnapshot), file.AccountSnapshot) } - return file, nil + return nil } -func (c *Client) PatchDisabled(ctx context.Context, baseURL string, managementKey string, fileName string, disabled bool) error { +func (c *Client) PatchDisabled(ctx context.Context, baseURL string, managementKey string, fileName string, disabled bool, authIndex ...string) error { payload := map[string]any{"name": fileName, "disabled": disabled} + if len(authIndex) > 0 { + if trimmed := strings.TrimSpace(authIndex[0]); trimmed != "" { + payload["auth_index"] = trimmed + } + } data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal payload: %w", err) @@ -259,6 +481,15 @@ func actionFailed(body []byte) bool { if err := json.Unmarshal(body, &payload); err != nil { return false } - failed, ok := payload["failed"].([]any) - return ok && len(failed) > 0 + if failed, ok := payload["failed"].([]any); ok && len(failed) > 0 { + return true + } + if ok, _ := payload["success"].(bool); ok { + return false + } + if ok, _ := payload["ok"].(bool); ok { + return false + } + status := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["status"]))) + return status == "error" || status == "failed" } diff --git a/apps/manager-server/internal/service/cpaauthfiles/client_test.go b/apps/manager-server/internal/service/cpaauthfiles/client_test.go index cfd0fcd21..33b3dfc77 100644 --- a/apps/manager-server/internal/service/cpaauthfiles/client_test.go +++ b/apps/manager-server/internal/service/cpaauthfiles/client_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -58,14 +59,15 @@ func TestClientPatchDisabledAndDelete(t *testing.T) { _ = json.NewEncoder(w).Encode([]map[string]any{{"name": "codex-auth.json", "auth_index": "7"}}) case "PATCH /v0/management/auth-files/status": var payload struct { - Name string `json:"name"` - Disabled bool `json:"disabled"` + Name string `json:"name"` + AuthIndex string `json:"auth_index"` + Disabled bool `json:"disabled"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if payload.Name != "codex-auth.json" || !payload.Disabled { + if payload.Name != "codex-auth.json" || payload.AuthIndex != "7" || !payload.Disabled { t.Fatalf("patch payload = %#v", payload) } patched = true @@ -90,7 +92,7 @@ func TestClientPatchDisabledAndDelete(t *testing.T) { if _, ok := Find(files, "codex-auth.json", "7"); !ok { t.Fatalf("find files = %#v", files) } - if err := client.PatchDisabled(context.Background(), server.URL, "mgmt", "codex-auth.json", true); err != nil { + if err := client.PatchDisabled(context.Background(), server.URL, "mgmt", "codex-auth.json", true, "7"); err != nil { t.Fatalf("patch disabled: %v", err) } if err := client.Delete(context.Background(), server.URL, "mgmt", "codex-auth.json"); err != nil { @@ -100,3 +102,80 @@ func TestClientPatchDisabledAndDelete(t *testing.T) { t.Fatalf("patched=%t deleted=%t", patched, deleted) } } + +func TestClientFindStreamsLargeAuthFilesResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v0/management/auth-files" { + http.NotFound(w, r) + return + } + if r.URL.Query().Get("name") != "target.json" || r.URL.Query().Get("auth_index") != "target-index" { + t.Fatalf("query = %s", r.URL.RawQuery) + } + if r.Header.Get("Authorization") != "Bearer mgmt" { + http.Error(w, "missing auth", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"files":[`) + padding := strings.Repeat("x", 2048) + for i := 0; i < 650; i++ { + if i > 0 { + _, _ = io.WriteString(w, ",") + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "filler.json", + "auth_index": "filler", + "status_message": padding, + }) + } + _, _ = io.WriteString(w, `,{"name":"target.json","auth_index":"target-index","provider":"codex","account":"user@example.com","account_id":"acct-123","disabled":false}]}`) + })) + defer server.Close() + + client := New(server.Client()) + file, ok, err := client.Find(context.Background(), server.URL, "mgmt", "target.json", "target-index") + if err != nil { + t.Fatalf("find: %v", err) + } + if !ok { + t.Fatal("expected target auth file to be found") + } + if file.Name != "target.json" || file.AuthIndex != "target-index" || file.AccountID != "acct-123" { + t.Fatalf("file = %#v", file) + } +} + +func TestClientFetchAndFindAcceptSingleAuthFileObject(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v0/management/auth-files" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "single.json", + "auth_index": "single-index", + "provider": "codex", + "account": "user@example.com", + "account_id": "acct-123", + "disabled": false, + }) + })) + defer server.Close() + + client := New(server.Client()) + files, err := client.Fetch(context.Background(), server.URL, "mgmt") + if err != nil { + t.Fatalf("fetch: %v", err) + } + if len(files) != 1 || files[0].Name != "single.json" || files[0].AuthIndex != "single-index" { + t.Fatalf("files = %#v", files) + } + file, ok, err := client.Find(context.Background(), server.URL, "mgmt", "single.json", "single-index") + if err != nil { + t.Fatalf("find: %v", err) + } + if !ok || file.Name != "single.json" || file.AuthIndex != "single-index" { + t.Fatalf("file=%#v ok=%t", file, ok) + } +} diff --git a/apps/manager-server/internal/service/dashboard/service.go b/apps/manager-server/internal/service/dashboard/service.go index cc5b7766d..517e68b6d 100644 --- a/apps/manager-server/internal/service/dashboard/service.go +++ b/apps/manager-server/internal/service/dashboard/service.go @@ -694,11 +694,7 @@ func aggregateModelStats(stats []store.ModelStat, prices map[string]store.ModelP } func costForStat(stat store.ModelStat, prices map[string]store.ModelPrice) float64 { - model := stat.BillingModel - if model == "" { - model = stat.Model - } - return pricing.CostForModelWithServiceTier(model, stat.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -708,11 +704,7 @@ func costForStat(stat store.ModelStat, prices map[string]store.ModelPrice) float } func costForChannelStat(stat store.ChannelModelStat, prices map[string]store.ModelPrice) float64 { - model := stat.BillingModel - if model == "" { - model = stat.Model - } - return pricing.CostForModelWithServiceTier(model, stat.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, diff --git a/apps/manager-server/internal/service/dashboard/service_test.go b/apps/manager-server/internal/service/dashboard/service_test.go index b9b461bac..4c542dae8 100644 --- a/apps/manager-server/internal/service/dashboard/service_test.go +++ b/apps/manager-server/internal/service/dashboard/service_test.go @@ -263,6 +263,51 @@ func TestSummaryUsesResolvedModelPricing(t *testing.T) { } } +func TestSummaryFallsBackToRequestedModelPriceWhenResolvedPriceIsMissing(t *testing.T) { + db := newDashboardTestStore(t) + ctx := context.Background() + todayStart := int64(1_778_005_000_000) + nowMS := todayStart + 60*60*1000 + + if err := db.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "GLM-5.2": {Prompt: 3}, + }); err != nil { + t.Fatalf("save prices: %v", err) + } + event := dashboardEvent("dashboard-alias-fallback-cost", todayStart+1_000, "GLM-5.2", false, 1_000_000, 0, 0, 0, 0, 1_000_000, nil) + event.RequestedModel = "GLM-5.2" + event.ResolvedModel = "zai/glm-5.2" + if _, err := db.InsertEvents(ctx, []usage.Event{event}); err != nil { + t.Fatalf("insert events: %v", err) + } + + resp, err := New(db).Summary(ctx, SummaryParams{ + TodayStartMS: todayStart, + NowMS: nowMS, + TopModels: 5, + RecentFailures: 1, + }) + if err != nil { + t.Fatalf("summary: %v", err) + } + + if math.Abs(resp.Today.TotalCost-3) > 0.000001 { + t.Fatalf("today cost = %v", resp.Today.TotalCost) + } + if len(resp.TopModelsToday) != 1 || resp.TopModelsToday[0].Model != "GLM-5.2" || + math.Abs(resp.TopModelsToday[0].Cost-3) > 0.000001 { + t.Fatalf("top models = %#v", resp.TopModelsToday) + } + if len(resp.ModelCostRank) != 1 || resp.ModelCostRank[0].Model != "GLM-5.2" || + math.Abs(resp.ModelCostRank[0].Cost-3) > 0.000001 { + t.Fatalf("model cost rank = %#v", resp.ModelCostRank) + } + if len(resp.ChannelHealth) != 1 || resp.ChannelHealth[0].AuthIndex != "auth-1" || + math.Abs(resp.ChannelHealth[0].Cost-3) > 0.000001 { + t.Fatalf("channel health = %#v", resp.ChannelHealth) + } +} + func TestSummaryPricesPriorityAndDefaultServiceTiersSeparately(t *testing.T) { db := newDashboardTestStore(t) ctx := context.Background() diff --git a/apps/manager-server/internal/service/monitoring/account_history_benchmark_test.go b/apps/manager-server/internal/service/monitoring/account_history_benchmark_test.go new file mode 100644 index 000000000..061107e3f --- /dev/null +++ b/apps/manager-server/internal/service/monitoring/account_history_benchmark_test.go @@ -0,0 +1,177 @@ +package monitoring + +import ( + "context" + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +const ( + accountHistoryBenchmarkAccountCount = 200 + accountHistoryBenchmarkModelCount = 8 + accountHistoryBenchmarkBaseMS = int64(1_700_000_000_000) + accountHistoryBenchmarkNowMS = int64(1_700_100_000_000) +) + +func BenchmarkAccountHistoryServiceRead(b *testing.B) { + ctx := context.Background() + st, closeStore := openAccountHistoryBenchmarkStore(b) + defer closeStore() + saveAccountHistoryBenchmarkPrices(b, ctx, st) + insertAccountHistoryBenchmarkEvents(b, ctx, st, accountHistoryBenchmarkEvents("read", 0, 20000)) + if result, err := st.CatchUpAccountHistoryRollups(ctx, 20000, accountHistoryBenchmarkNowMS); err != nil { + b.Fatalf("catch-up: %v", err) + } else if result.Processed != 20000 { + b.Fatalf("processed = %d, want 20000", result.Processed) + } + service := New(st) + + for _, targetCount := range []int{20, 100, 200} { + req := AccountHistoryRequest{ + Accounts: accountHistoryBenchmarkTargets(targetCount), + } + b.Run(fmt.Sprintf("targets_%d", targetCount), func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + resp, err := service.AccountHistory(ctx, req) + if err != nil { + b.Fatalf("account history: %v", err) + } + if len(resp.Items) != targetCount { + b.Fatalf("items = %d, want %d", len(resp.Items), targetCount) + } + } + }) + } +} + +func BenchmarkAccountHistoryServiceCatchUp(b *testing.B) { + b.ReportAllocs() + ctx := context.Background() + st, closeStore := openAccountHistoryBenchmarkStore(b) + defer closeStore() + saveAccountHistoryBenchmarkPrices(b, ctx, st) + insertAccountHistoryBenchmarkEvents(b, ctx, st, accountHistoryBenchmarkEvents("catchup-baseline", 0, 10000)) + if result, err := st.CatchUpAccountHistoryRollups(ctx, 10000, accountHistoryBenchmarkNowMS); err != nil { + b.Fatalf("baseline catch-up: %v", err) + } else if result.Processed != 10000 { + b.Fatalf("baseline processed = %d, want 10000", result.Processed) + } + service := New(st) + req := AccountHistoryRequest{ + Accounts: accountHistoryBenchmarkTargets(100), + CatchUp: true, + } + + const batchSize = 500 + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + start := 10000 + i*batchSize + insertAccountHistoryBenchmarkEvents(b, ctx, st, accountHistoryBenchmarkEvents("catchup-incremental", start, batchSize)) + b.StartTimer() + + resp, err := service.AccountHistory(ctx, req) + + b.StopTimer() + if err != nil { + b.Fatalf("account history catch-up: %v", err) + } + if resp.Checkpoint.Processed != batchSize { + b.Fatalf("processed = %d, want %d", resp.Checkpoint.Processed, batchSize) + } + } +} + +func openAccountHistoryBenchmarkStore(b *testing.B) (*store.Store, func()) { + b.Helper() + st, err := store.Open(filepath.Join(b.TempDir(), "usage.sqlite")) + if err != nil { + b.Fatalf("open store: %v", err) + } + return st, func() { + _ = st.Close() + } +} + +func saveAccountHistoryBenchmarkPrices(b *testing.B, ctx context.Context, st *store.Store) { + b.Helper() + prices := make(map[string]store.ModelPrice, accountHistoryBenchmarkModelCount) + for index := 0; index < accountHistoryBenchmarkModelCount; index++ { + prices[fmt.Sprintf("resolved-%02d", index)] = store.ModelPrice{ + Prompt: 1, + Completion: 2, + Cache: 0.5, + CacheRead: 0.25, + CacheCreation: 1.5, + } + } + if err := st.SaveModelPrices(ctx, prices); err != nil { + b.Fatalf("save prices: %v", err) + } +} + +func insertAccountHistoryBenchmarkEvents(b *testing.B, ctx context.Context, st *store.Store, events []usage.Event) { + b.Helper() + if _, err := st.InsertEvents(ctx, events); err != nil { + b.Fatalf("insert events: %v", err) + } +} + +func accountHistoryBenchmarkEvents(prefix string, start int, count int) []usage.Event { + result := make([]usage.Event, 0, count) + for offset := 0; offset < count; offset++ { + seq := start + offset + accountIndex := seq % accountHistoryBenchmarkAccountCount + cycle := seq / accountHistoryBenchmarkAccountCount + modelIndex := (accountIndex + cycle) % accountHistoryBenchmarkModelCount + timestampMS := accountHistoryBenchmarkBaseMS + int64(seq) + event := usage.Event{ + EventHash: fmt.Sprintf("%s-%08d", prefix, seq), + TimestampMS: timestampMS, + Timestamp: time.UnixMilli(timestampMS).UTC().Format(time.RFC3339Nano), + Provider: "openai", + Model: fmt.Sprintf("alias-%02d", modelIndex), + ResolvedModel: fmt.Sprintf("resolved-%02d", modelIndex), + Endpoint: "POST /v1/chat/completions", + Method: "POST", + Path: "/v1/chat/completions", + AuthIndex: fmt.Sprintf("auth-%04d", accountIndex), + Source: fmt.Sprintf("account-%04d@example.com", accountIndex), + SourceHash: fmt.Sprintf("source-%04d", accountIndex), + AccountSnapshot: fmt.Sprintf("account-%04d@example.com", accountIndex), + AuthLabelSnapshot: fmt.Sprintf("Account %04d", accountIndex), + AuthProviderSnapshot: "openai", + InputTokens: int64(100 + seq%97), + OutputTokens: int64(40 + seq%53), + ReasoningTokens: int64(seq % 11), + CachedTokens: int64(seq % 7), + CacheReadTokens: int64(seq % 5), + CacheCreationTokens: int64(seq % 3), + TotalTokens: int64(140 + seq%151), + Failed: seq%17 == 0, + CreatedAtMS: timestampMS, + } + if seq%5 == 0 { + event.ServiceTier = "priority" + } + result = append(result, event) + } + return result +} + +func accountHistoryBenchmarkTargets(count int) []AccountHistoryTarget { + targets := make([]AccountHistoryTarget, 0, count) + for index := 0; index < count; index++ { + targets = append(targets, AccountHistoryTarget{ + AccountKey: fmt.Sprintf("account-%04d@example.com", index), + }) + } + return targets +} diff --git a/apps/manager-server/internal/service/monitoring/service.go b/apps/manager-server/internal/service/monitoring/service.go index fd25ee87c..1c543a8f0 100644 --- a/apps/manager-server/internal/service/monitoring/service.go +++ b/apps/manager-server/internal/service/monitoring/service.go @@ -22,6 +22,8 @@ const ( maxDrilldownLimit = 100 maxHeaderSnapshotDays = 365 maxHeaderSnapshotLimit = 5000 + maxAccountHistoryTargets = 200 + accountHistoryCatchUpLimit = 5000 recentWindowMS = 30 * 60 * 1000 ) @@ -135,6 +137,46 @@ type HeaderSnapshotsResponse struct { Items []HeaderSnapshot `json:"items"` } +type AccountHistoryRequest struct { + Accounts []AccountHistoryTarget `json:"accounts"` + CatchUp bool `json:"catch_up"` +} + +type AccountHistoryTarget struct { + AccountKey string `json:"account_key,omitempty"` + AccountSnapshot string `json:"account_snapshot,omitempty"` + AuthLabelSnapshot string `json:"auth_label_snapshot,omitempty"` + AuthIndex string `json:"auth_index,omitempty"` + Source string `json:"source,omitempty"` +} + +type AccountHistoryResponse struct { + GeneratedAtMS int64 `json:"generated_at_ms"` + Checkpoint AccountHistoryCheckpointState `json:"checkpoint"` + Items []AccountHistoryItem `json:"items"` +} + +type AccountHistoryCheckpointState struct { + LastEventID int64 `json:"last_event_id"` + LatestID int64 `json:"latest_id"` + Pending bool `json:"pending"` + Processed int `json:"processed"` +} + +type AccountHistoryItem struct { + AccountKey string `json:"account_key"` + Matched bool `json:"matched"` + TotalRequests int64 `json:"total_requests"` + SuccessCalls int64 `json:"success_calls"` + FailureCalls int64 `json:"failure_calls"` + TotalTokens int64 `json:"total_tokens"` + TotalCost float64 `json:"total_cost"` + SuccessRate *float64 `json:"success_rate"` + FirstSeenMS *int64 `json:"first_seen_ms"` + LastSeenMS *int64 `json:"last_seen_ms"` + SyncStatus string `json:"sync_status"` +} + type Summary struct { TotalCalls int64 `json:"total_calls"` SuccessCalls int64 `json:"success_calls"` @@ -831,6 +873,104 @@ func (s *Service) Analytics(ctx context.Context, req Request) (Response, error) return response, nil } +func (s *Service) AccountHistory(ctx context.Context, req AccountHistoryRequest) (AccountHistoryResponse, error) { + if len(req.Accounts) == 0 { + return AccountHistoryResponse{}, errors.New("accounts are required") + } + if len(req.Accounts) > maxAccountHistoryTargets { + return AccountHistoryResponse{}, fmt.Errorf("accounts must be less than or equal to %d", maxAccountHistoryTargets) + } + generatedAtMS := time.Now().UnixMilli() + processed := 0 + if req.CatchUp { + result, err := s.store.CatchUpAccountHistoryRollups(ctx, accountHistoryCatchUpLimit, generatedAtMS) + if err != nil { + return AccountHistoryResponse{}, err + } + processed = result.Processed + } + checkpoint, err := s.store.AccountHistoryRollupCheckpoint(ctx) + if err != nil { + return AccountHistoryResponse{}, err + } + latestID, err := s.store.LatestUsageEventID(ctx) + if err != nil { + return AccountHistoryResponse{}, err + } + + keys := make([]string, 0, len(req.Accounts)) + targetKeys := make([]string, len(req.Accounts)) + validTargets := make([]bool, len(req.Accounts)) + for index, account := range req.Accounts { + key, valid := accountHistoryTargetKey(account) + targetKeys[index] = key + validTargets[index] = valid + if valid { + keys = append(keys, key) + } + } + rows, err := s.store.AccountHistoryRollupRows(ctx, keys) + if err != nil { + return AccountHistoryResponse{}, err + } + prices, err := s.store.LoadModelPrices(ctx) + if err != nil { + return AccountHistoryResponse{}, err + } + totals := buildAccountHistoryTotals(rows, prices) + pending := latestID > checkpoint.LastEventID + items := make([]AccountHistoryItem, 0, len(req.Accounts)) + for index := range req.Accounts { + key := targetKeys[index] + if !validTargets[index] { + items = append(items, AccountHistoryItem{ + AccountKey: key, + Matched: false, + SyncStatus: accountHistorySyncStatus(false, false), + }) + continue + } + total := totals[key] + if total == nil { + items = append(items, AccountHistoryItem{ + AccountKey: key, + Matched: false, + SyncStatus: accountHistorySyncStatus(false, pending), + }) + continue + } + var successRate *float64 + if total.requests > 0 { + value := ratio(total.successCalls, total.requests) + successRate = &value + } + items = append(items, AccountHistoryItem{ + AccountKey: key, + Matched: true, + TotalRequests: total.requests, + SuccessCalls: total.successCalls, + FailureCalls: total.failureCalls, + TotalTokens: total.totalTokens, + TotalCost: total.cost, + SuccessRate: successRate, + FirstSeenMS: nullableMSPointer(total.firstSeenMS), + LastSeenMS: nullableMSPointer(total.lastSeenMS), + SyncStatus: accountHistorySyncStatus(true, pending), + }) + } + + return AccountHistoryResponse{ + GeneratedAtMS: generatedAtMS, + Checkpoint: AccountHistoryCheckpointState{ + LastEventID: checkpoint.LastEventID, + LatestID: latestID, + Pending: pending, + Processed: processed, + }, + Items: items, + }, nil +} + func (s *Service) HeaderSnapshots(ctx context.Context, req HeaderSnapshotsRequest) (HeaderSnapshotsResponse, error) { days := req.Days if days <= 0 { @@ -2217,6 +2357,85 @@ func buildHeaderSnapshots(items []store.HeaderSnapshot) []HeaderSnapshot { return result } +type accountHistoryTotal struct { + requests int64 + successCalls int64 + failureCalls int64 + totalTokens int64 + cost float64 + firstSeenMS int64 + lastSeenMS int64 +} + +func accountHistoryTargetKey(target AccountHistoryTarget) (string, bool) { + if key := strings.TrimSpace(target.AccountKey); key != "" { + return key, true + } + if strings.TrimSpace(target.AccountSnapshot) == "" && + strings.TrimSpace(target.AuthLabelSnapshot) == "" && + strings.TrimSpace(target.Source) == "" && + strings.TrimSpace(target.AuthIndex) == "" { + return "", false + } + return store.AccountHistoryKey( + target.AccountSnapshot, + target.AuthLabelSnapshot, + target.Source, + target.AuthIndex, + ), true +} + +func buildAccountHistoryTotals(rows []store.AccountHistoryRollupRow, prices map[string]store.ModelPrice) map[string]*accountHistoryTotal { + totals := map[string]*accountHistoryTotal{} + for _, row := range rows { + total := totals[row.AccountKey] + if total == nil { + total = &accountHistoryTotal{} + totals[row.AccountKey] = total + } + total.requests += row.Calls + total.successCalls += row.SuccessCalls + total.failureCalls += row.FailureCalls + total.totalTokens += row.TotalTokens + total.cost += pricing.CostForModelCandidatesWithServiceTier( + []string{row.BillingModel, row.Model}, + row.ServiceTier, + pricing.ModelTokens{ + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + CachedTokens: row.CachedTokens, + CacheReadTokens: row.CacheReadTokens, + CacheCreationTokens: row.CacheCreationTokens, + }, + prices, + ) + if total.firstSeenMS == 0 || (row.FirstSeenMS > 0 && row.FirstSeenMS < total.firstSeenMS) { + total.firstSeenMS = row.FirstSeenMS + } + if row.LastSeenMS > total.lastSeenMS { + total.lastSeenMS = row.LastSeenMS + } + } + return totals +} + +func accountHistorySyncStatus(matched bool, pending bool) string { + if pending { + return "pending" + } + if matched { + return "ready" + } + return "empty" +} + +func nullableMSPointer(value int64) *int64 { + if value <= 0 { + return nil + } + return &value +} + func sumCost(stats []store.ModelStat, prices map[string]store.ModelPrice) float64 { total := 0.0 for _, stat := range stats { @@ -2226,11 +2445,7 @@ func sumCost(stats []store.ModelStat, prices map[string]store.ModelPrice) float6 } func costForStat(stat store.ModelStat, prices map[string]store.ModelPrice) float64 { - model := stat.BillingModel - if model == "" { - model = stat.Model - } - return pricing.CostForModelWithServiceTier(model, stat.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -2240,11 +2455,7 @@ func costForStat(stat store.ModelStat, prices map[string]store.ModelPrice) float } func costForTimelinePoint(point store.TimelinePoint, prices map[string]store.ModelPrice) float64 { - model := point.BillingModel - if model == "" { - model = point.Model - } - return pricing.CostForModelWithServiceTier(model, point.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{point.BillingModel, point.Model}, point.ServiceTier, pricing.ModelTokens{ InputTokens: point.InputTokens, OutputTokens: point.OutputTokens, CachedTokens: point.CachedTokens, @@ -2254,11 +2465,7 @@ func costForTimelinePoint(point store.TimelinePoint, prices map[string]store.Mod } func costForHeatmapPoint(point store.HeatmapPoint, prices map[string]store.ModelPrice) float64 { - model := point.BillingModel - if model == "" { - model = point.Model - } - return pricing.CostForModelWithServiceTier(model, point.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{point.BillingModel, point.Model}, point.ServiceTier, pricing.ModelTokens{ InputTokens: point.InputTokens, OutputTokens: point.OutputTokens, CachedTokens: point.CachedTokens, @@ -2268,11 +2475,7 @@ func costForHeatmapPoint(point store.HeatmapPoint, prices map[string]store.Model } func costForChannelStat(stat store.ChannelModelStat, prices map[string]store.ModelPrice) float64 { - model := stat.BillingModel - if model == "" { - model = stat.Model - } - return pricing.CostForModelWithServiceTier(model, stat.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -2282,11 +2485,7 @@ func costForChannelStat(stat store.ChannelModelStat, prices map[string]store.Mod } func costForAccountModelStat(stat store.AccountModelStat, prices map[string]store.ModelPrice) float64 { - model := stat.BillingModel - if model == "" { - model = stat.Model - } - return pricing.CostForModelWithServiceTier(model, stat.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -2296,11 +2495,7 @@ func costForAccountModelStat(stat store.AccountModelStat, prices map[string]stor } func costForAPIKeyModelStat(stat store.APIKeyModelStat, prices map[string]store.ModelPrice) float64 { - model := stat.BillingModel - if model == "" { - model = stat.Model - } - return pricing.CostForModelWithServiceTier(model, stat.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -2310,11 +2505,7 @@ func costForAPIKeyModelStat(stat store.APIKeyModelStat, prices map[string]store. } func costForCredentialModelStat(stat store.CredentialModelStat, prices map[string]store.ModelPrice) float64 { - model := stat.BillingModel - if model == "" { - model = stat.Model - } - return pricing.CostForModelWithServiceTier(model, stat.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{stat.BillingModel, stat.Model}, stat.ServiceTier, pricing.ModelTokens{ InputTokens: stat.InputTokens, OutputTokens: stat.OutputTokens, CachedTokens: stat.CachedTokens, @@ -2324,11 +2515,7 @@ func costForCredentialModelStat(stat store.CredentialModelStat, prices map[strin } func costForCredentialTimelinePoint(point store.CredentialTimelinePoint, prices map[string]store.ModelPrice) float64 { - model := point.BillingModel - if model == "" { - model = point.Model - } - return pricing.CostForModelWithServiceTier(model, point.ServiceTier, pricing.ModelTokens{ + return pricing.CostForModelCandidatesWithServiceTier([]string{point.BillingModel, point.Model}, point.ServiceTier, pricing.ModelTokens{ InputTokens: point.InputTokens, OutputTokens: point.OutputTokens, CachedTokens: point.CachedTokens, diff --git a/apps/manager-server/internal/service/monitoring/service_test.go b/apps/manager-server/internal/service/monitoring/service_test.go index a96f447bc..c09b832c9 100644 --- a/apps/manager-server/internal/service/monitoring/service_test.go +++ b/apps/manager-server/internal/service/monitoring/service_test.go @@ -523,6 +523,59 @@ func TestAnalyticsUsesResolvedModelPricingInAggregates(t *testing.T) { } } +func TestAnalyticsFallsBackToRequestedModelPriceWhenResolvedPriceIsMissing(t *testing.T) { + db := newMonitoringTestStore(t) + ctx := context.Background() + fromMS := int64(1_778_005_000_000) + toMS := fromMS + 60*60*1000 + + if err := db.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "GLM-5.2": {Prompt: 3}, + }); err != nil { + t.Fatalf("save model prices: %v", err) + } + event := monitoringEvent("alias-fallback-cost", fromMS+1_000, "GLM-5.2", "auth-1", "source-a", false, 1_000_000, 0, 0, 0, 1_000_000, nil) + event.RequestedModel = "GLM-5.2" + event.ResolvedModel = "zai/glm-5.2" + if _, err := db.InsertEvents(ctx, []usage.Event{event}); err != nil { + t.Fatalf("insert events: %v", err) + } + + resp, err := New(db).Analytics(ctx, Request{ + FromMS: fromMS, + ToMS: toMS, + Include: Include{ + Summary: true, + ModelShare: true, + ModelStats: true, + ChannelShare: true, + Timeline: true, + }, + }) + if err != nil { + t.Fatalf("analytics: %v", err) + } + + if resp.Summary == nil || math.Abs(resp.Summary.TotalCost-3) > 0.000001 { + t.Fatalf("summary cost = %#v", resp.Summary) + } + if len(resp.ModelStats) != 1 || resp.ModelStats[0].Model != "GLM-5.2" || + math.Abs(resp.ModelStats[0].Cost-3) > 0.000001 { + t.Fatalf("model stats = %#v", resp.ModelStats) + } + if len(resp.ModelShare) != 1 || resp.ModelShare[0].Model != "GLM-5.2" || + math.Abs(resp.ModelShare[0].Cost-3) > 0.000001 { + t.Fatalf("model share = %#v", resp.ModelShare) + } + if len(resp.ChannelShare) != 1 || resp.ChannelShare[0].AuthIndex != "auth-1" || + math.Abs(resp.ChannelShare[0].Cost-3) > 0.000001 { + t.Fatalf("channel share = %#v", resp.ChannelShare) + } + if len(resp.Timeline) != 1 || math.Abs(resp.Timeline[0].Cost-3) > 0.000001 { + t.Fatalf("timeline = %#v", resp.Timeline) + } +} + func TestAnalyticsPricesPriorityAndDefaultServiceTiersSeparately(t *testing.T) { db := newMonitoringTestStore(t) ctx := context.Background() @@ -1319,6 +1372,107 @@ func TestAnalyticsSummaryAndHourlyDistributionUseRequestedTimeZone(t *testing.T) } } +func TestAccountHistoryReturnsRollupTotalsAndCost(t *testing.T) { + db := newMonitoringTestStore(t) + ctx := context.Background() + baseMS := int64(1_700_000_000_000) + if err := db.SaveModelPrices(ctx, map[string]store.ModelPrice{ + "resolved-a": { + Prompt: 1, + Completion: 2, + Cache: 0.5, + CacheRead: 0.25, + CacheCreation: 1.5, + }, + }); err != nil { + t.Fatalf("save model prices: %v", err) + } + + first := monitoringEvent("history-a-1", baseMS+1_000, "alias-a", "auth-1", "source-a", false, 1_000_000, 500_000, 0, 100_000, 1_530_000, nil) + first.ResolvedModel = "resolved-a" + first.AccountSnapshot = "hist@example.com" + first.Source = "hist@example.com" + first.CacheReadTokens = 20_000 + first.CacheCreationTokens = 10_000 + second := monitoringEvent("history-a-2", baseMS+2_000, "alias-a", "auth-1", "source-a", true, 0, 0, 0, 0, 0, nil) + second.ResolvedModel = "resolved-a" + second.AccountSnapshot = "hist@example.com" + second.Source = "hist@example.com" + if _, err := db.InsertEvents(ctx, []usage.Event{first, second}); err != nil { + t.Fatalf("insert events: %v", err) + } + + resp, err := New(db).AccountHistory(ctx, AccountHistoryRequest{ + Accounts: []AccountHistoryTarget{ + {AccountSnapshot: "hist@example.com"}, + {AccountSnapshot: "missing@example.com"}, + {AccountKey: "hist@example.com"}, + }, + CatchUp: true, + }) + if err != nil { + t.Fatalf("account history: %v", err) + } + if resp.Checkpoint.Pending || resp.Checkpoint.LatestID != 2 || resp.Checkpoint.LastEventID != 2 || resp.Checkpoint.Processed != 2 { + t.Fatalf("checkpoint = %#v", resp.Checkpoint) + } + if len(resp.Items) != 3 { + t.Fatalf("items = %#v", resp.Items) + } + history := resp.Items[0] + if history.AccountKey != "hist@example.com" || !history.Matched || history.SyncStatus != "ready" { + t.Fatalf("history item = %#v", history) + } + if history.TotalRequests != 2 || history.SuccessCalls != 1 || history.FailureCalls != 1 || history.TotalTokens != 1_530_000 { + t.Fatalf("history totals = %#v", history) + } + if history.SuccessRate == nil || math.Abs(*history.SuccessRate-0.5) > 0.000001 { + t.Fatalf("success rate = %#v", history.SuccessRate) + } + if math.Abs(history.TotalCost-1.985) > 0.000001 { + t.Fatalf("total cost = %v", history.TotalCost) + } + if history.FirstSeenMS == nil || *history.FirstSeenMS != baseMS+1_000 || history.LastSeenMS == nil || *history.LastSeenMS != baseMS+2_000 { + t.Fatalf("seen range = %#v %#v", history.FirstSeenMS, history.LastSeenMS) + } + if resp.Items[1].Matched || resp.Items[1].SyncStatus != "empty" { + t.Fatalf("missing item = %#v", resp.Items[1]) + } + if !resp.Items[2].Matched || resp.Items[2].AccountKey != "hist@example.com" || resp.Items[2].TotalRequests != 2 { + t.Fatalf("account_key item = %#v", resp.Items[2]) + } +} + +func TestAccountHistoryEmptyTargetDoesNotMatchAnonymousBucket(t *testing.T) { + db := newMonitoringTestStore(t) + ctx := context.Background() + baseMS := int64(1_700_000_000_000) + event := monitoringEvent("history-anonymous-source", baseMS+1_000, "gpt-a", "", "source-only", false, 10, 5, 0, 0, 15, nil) + event.AccountSnapshot = "" + event.AuthLabelSnapshot = "" + event.Source = "" + event.AuthIndex = "" + if _, err := db.InsertEvents(ctx, []usage.Event{event}); err != nil { + t.Fatalf("insert events: %v", err) + } + + resp, err := New(db).AccountHistory(ctx, AccountHistoryRequest{ + Accounts: []AccountHistoryTarget{ + {}, + }, + CatchUp: true, + }) + if err != nil { + t.Fatalf("account history: %v", err) + } + if len(resp.Items) != 1 { + t.Fatalf("items = %#v", resp.Items) + } + if resp.Items[0].Matched || resp.Items[0].AccountKey != "" || resp.Items[0].SyncStatus != "empty" { + t.Fatalf("empty target matched anonymous bucket: %#v", resp.Items[0]) + } +} + func newMonitoringTestStore(t *testing.T) *store.Store { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "usage.sqlite")) diff --git a/apps/manager-server/internal/service/panel/service.go b/apps/manager-server/internal/service/panel/service.go index 10e6b9559..9fbc235b0 100644 --- a/apps/manager-server/internal/service/panel/service.go +++ b/apps/manager-server/internal/service/panel/service.go @@ -1,11 +1,12 @@ package panel import ( - "io" "io/fs" "mime" "net/http" "os" + "strconv" + "strings" ) type Service struct { @@ -17,12 +18,17 @@ func New(panelPath string, embedded fs.FS) *Service { return &Service{PanelPath: panelPath, Embedded: embedded} } -func (s *Service) ServeManagementHTML(w http.ResponseWriter, writeError func(http.ResponseWriter, int, error)) { +func (s *Service) ServeManagementHTML(w http.ResponseWriter, r *http.Request, writeError func(http.ResponseWriter, int, error)) { if s.PanelPath != "" { if file, err := os.Open(s.PanelPath); err == nil { defer file.Close() + info, statErr := file.Stat() + if statErr != nil { + writeError(w, http.StatusInternalServerError, statErr) + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = io.Copy(w, file) + http.ServeContent(w, r, "management.html", info.ModTime(), file) return } } @@ -31,6 +37,11 @@ func (s *Service) ServeManagementHTML(w http.ResponseWriter, writeError func(htt writeError(w, http.StatusInternalServerError, err) return } - w.Header().Set("Content-Type", mime.TypeByExtension(".html")) + contentType := mime.TypeByExtension(".html") + if !strings.Contains(contentType, "charset=") { + contentType += "; charset=utf-8" + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) _, _ = w.Write(data) } diff --git a/apps/manager-server/internal/service/pricing/cost.go b/apps/manager-server/internal/service/pricing/cost.go index d2c279ac9..618f5f8d9 100644 --- a/apps/manager-server/internal/service/pricing/cost.go +++ b/apps/manager-server/internal/service/pricing/cost.go @@ -86,6 +86,25 @@ func CostForModelWithServiceTier(modelName string, serviceTier string, tokens Mo return CostForModel(modelName, tokens, prices) * ServiceTierMultiplier(modelName, serviceTier) } +// CostForModelCandidatesWithServiceTier computes cost using the first priced +// model name from the candidate list. Callers should pass resolved/upstream +// model first, followed by the requested display model or alias as fallback. +func CostForModelCandidatesWithServiceTier(modelNames []string, serviceTier string, tokens ModelTokens, prices map[string]model.ModelPrice) float64 { + seen := map[string]bool{} + for _, modelName := range modelNames { + modelName = strings.TrimSpace(modelName) + if modelName == "" || seen[modelName] { + continue + } + seen[modelName] = true + if _, ok := prices[modelName]; !ok { + continue + } + return CostForModelWithServiceTier(modelName, serviceTier, tokens, prices) + } + return 0 +} + // SumCost folds CostForModel over a slice of (model, tokens) tuples. type Item struct { Model string diff --git a/apps/manager-server/internal/service/pricing/cost_test.go b/apps/manager-server/internal/service/pricing/cost_test.go index ae434f7c7..cffc4e168 100644 --- a/apps/manager-server/internal/service/pricing/cost_test.go +++ b/apps/manager-server/internal/service/pricing/cost_test.go @@ -117,6 +117,41 @@ func TestCostForModelWithServiceTier(t *testing.T) { } } +func TestCostForModelCandidatesWithServiceTierFallsBackToRequestedModel(t *testing.T) { + prices := map[string]model.ModelPrice{ + "gpt-5.4": {Prompt: 2.5, Completion: 5, Cache: 1}, + } + + cost := CostForModelCandidatesWithServiceTier( + []string{"missing-upstream", "gpt-5.4"}, + "priority", + ModelTokens{InputTokens: 1_000_000}, + prices, + ) + + if math.Abs(cost-5) > 0.000001 { + t.Fatalf("fallback cost = %v, want 5", cost) + } +} + +func TestCostForModelCandidatesWithServiceTierPrefersResolvedModel(t *testing.T) { + prices := map[string]model.ModelPrice{ + "gpt-resolved": {Prompt: 1, Completion: 2, Cache: 0.5}, + "gpt-5.4": {Prompt: 2.5, Completion: 5, Cache: 1}, + } + + cost := CostForModelCandidatesWithServiceTier( + []string{"gpt-resolved", "gpt-5.4"}, + "priority", + ModelTokens{InputTokens: 1_000_000}, + prices, + ) + + if math.Abs(cost-1) > 0.000001 { + t.Fatalf("resolved cost = %v, want 1", cost) + } +} + func TestCostForModelWithServiceTierPreservesCacheBuckets(t *testing.T) { prices := map[string]model.ModelPrice{ "gpt-5.4": {Prompt: 2, Completion: 4, Cache: 1, CacheRead: 0.5, CacheCreation: 3}, diff --git a/apps/manager-server/internal/store/store.go b/apps/manager-server/internal/store/store.go index 30f3617a9..2e54063c7 100644 --- a/apps/manager-server/internal/store/store.go +++ b/apps/manager-server/internal/store/store.go @@ -15,6 +15,7 @@ import ( "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/setting" sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usageevent" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usagerollup" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/security" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" ) @@ -65,6 +66,9 @@ type TaskBucket = usageevent.TaskBucket type EventPageItem = usageevent.EventPageItem type EventsPage = usageevent.EventsPage type HeaderSnapshot = usageevent.HeaderSnapshot +type UsageRollupCheckpoint = usagerollup.Checkpoint +type UsageRollupCatchUpResult = usagerollup.CatchUpResult +type AccountHistoryRollupRow = usagerollup.AccountHistoryRow type Store struct { db *sql.DB @@ -77,6 +81,7 @@ type Store struct { AccountActions accountaction.Repository CodexInspections codexinspection.Repository QuotaCooldowns quotacooldown.Repository + UsageRollups usagerollup.Repository } func Open(path string, protector ...*security.Protector) (*Store, error) { @@ -98,6 +103,7 @@ func New(db *sql.DB, protector ...*security.Protector) *Store { AccountActions: accountaction.New(db), CodexInspections: codexinspection.New(db), QuotaCooldowns: quotacooldown.New(db), + UsageRollups: usagerollup.New(db), } } @@ -248,6 +254,26 @@ func (s *Store) InsertEvents(ctx context.Context, events []usage.Event) (InsertR return s.UsageEvents.InsertBatch(ctx, events) } +func (s *Store) CatchUpAccountHistoryRollups(ctx context.Context, limit int, nowMS int64) (UsageRollupCatchUpResult, error) { + return s.UsageRollups.CatchUpAccountHistory(ctx, limit, nowMS) +} + +func (s *Store) AccountHistoryRollupCheckpoint(ctx context.Context) (UsageRollupCheckpoint, error) { + return s.UsageRollups.Checkpoint(ctx, usagerollup.AccountHistoryCheckpointName) +} + +func (s *Store) LatestUsageEventID(ctx context.Context) (int64, error) { + return s.UsageRollups.LatestEventID(ctx) +} + +func (s *Store) AccountHistoryRollupRows(ctx context.Context, accountKeys []string) ([]AccountHistoryRollupRow, error) { + return s.UsageRollups.AccountHistoryRows(ctx, accountKeys) +} + +func AccountHistoryKey(accountSnapshot, authLabelSnapshot, source, authIndex string) string { + return usagerollup.AccountKey(accountSnapshot, authLabelSnapshot, source, authIndex) +} + func (s *Store) UpsertQuotaCooldown(ctx context.Context, cooldown QuotaCooldownUpsert) (QuotaCooldown, error) { return s.QuotaCooldowns.UpsertActive(ctx, cooldown) } diff --git a/apps/manager-server/internal/worker/account_action_candidate.go b/apps/manager-server/internal/worker/account_action_candidate.go index ae7f4e1ec..80ebb3b55 100644 --- a/apps/manager-server/internal/worker/account_action_candidate.go +++ b/apps/manager-server/internal/worker/account_action_candidate.go @@ -3,6 +3,7 @@ package worker import ( "context" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -158,13 +159,7 @@ func (w *AccountActionCandidateWorker) maybeAutoDisable(ctx context.Context, ite return } client := cpaauthfiles.New(nil) - files, err := client.Fetch(ctx, baseURL, managementKey) - if err != nil { - _ = w.store.RecordAccountActionCandidateFailure(ctx, item.ID, err.Error()) - log.Printf("[account-action] auto-disable verification failed for pending candidate %d authFile=%q: %v", item.ID, item.AuthFileName, err) - return - } - current, err := cpaauthfiles.VerifyIdentity(files, cpaauthfiles.Identity{ + current, err := client.Verify(ctx, baseURL, managementKey, cpaauthfiles.Identity{ AuthFileName: item.AuthFileName, AuthIndex: item.AuthIndex, Provider: item.Provider, @@ -172,16 +167,21 @@ func (w *AccountActionCandidateWorker) maybeAutoDisable(ctx context.Context, ite AccountIDSnapshot: item.AccountIDSnapshot, }) if err != nil { - reason := "current CPA auth file identity verification failed: " + err.Error() - _ = w.store.RecordAccountActionCandidateFailure(ctx, item.ID, reason) - log.Printf("[account-action] auto-disable skipped for pending candidate %d authFile=%q reason=identity_verification_failed detail=%v", item.ID, item.AuthFileName, err) + if errors.Is(err, cpaauthfiles.ErrAuthFileNotFound) || errors.Is(err, cpaauthfiles.ErrIdentityMismatch) { + reason := "current CPA auth file identity verification failed: " + err.Error() + _ = w.store.RecordAccountActionCandidateFailure(ctx, item.ID, reason) + log.Printf("[account-action] auto-disable skipped for pending candidate %d authFile=%q reason=identity_verification_failed detail=%v", item.ID, item.AuthFileName, err) + return + } + _ = w.store.RecordAccountActionCandidateFailure(ctx, item.ID, err.Error()) + log.Printf("[account-action] auto-disable verification failed for pending candidate %d authFile=%q: %v", item.ID, item.AuthFileName, err) return } if current.Disabled { log.Printf("[account-action] auto-disable skipped for pending candidate %d authFile=%q reason=already_disabled", item.ID, item.AuthFileName) return } - if err := client.PatchDisabled(ctx, baseURL, managementKey, item.AuthFileName, true); err != nil { + if err := client.PatchDisabled(ctx, baseURL, managementKey, item.AuthFileName, true, item.AuthIndex); err != nil { _ = w.store.RecordAccountActionCandidateFailure(ctx, item.ID, err.Error()) log.Printf("[account-action] auto-disable patch failed for pending candidate %d authFile=%q: %v", item.ID, item.AuthFileName, err) return diff --git a/apps/manager-server/internal/worker/account_action_candidate_test.go b/apps/manager-server/internal/worker/account_action_candidate_test.go index bdf68a0fb..d744a8413 100644 --- a/apps/manager-server/internal/worker/account_action_candidate_test.go +++ b/apps/manager-server/internal/worker/account_action_candidate_test.go @@ -256,6 +256,52 @@ func TestAccountActionCandidateWorkerAutoDisableRejectsIdentityMismatch(t *testi } } +func TestAccountActionCandidateWorkerAutoDisableRecordsVerificationTransportError(t *testing.T) { + st, err := store.Open(t.TempDir() + "/usage.sqlite") + if err != nil { + t.Fatalf("open store: %v", err) + } + defer st.Close() + + var patched bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /v0/management/auth-files": + http.Error(w, "temporary CPA failure", http.StatusInternalServerError) + case "PATCH /v0/management/auth-files/status": + patched = true + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + worker := NewAccountActionCandidateWorker(st, true) + worker.handleCandidate(context.Background(), accountActionCandidate{ + BaseURL: server.URL, + ManagementKey: "mgmt", + FileName: "codex-auth.json", + AuthIndex: "7", + DisplayAccount: "user@example.com", + AccountID: "acct-123", + Provider: "codex", + ActionType: model.AccountActionTypeDelete, + Reason: "token revoked", + }) + + if patched { + t.Fatal("PATCH should not be called when verification request fails") + } + items, err := st.ListAccountActionCandidates(context.Background(), model.AccountActionStatusPending, 10) + if err != nil { + t.Fatalf("list candidates: %v", err) + } + if len(items) != 1 || !strings.Contains(items[0].LastError, "HTTP 500") || strings.Contains(items[0].LastError, "identity verification failed") { + t.Fatalf("items = %#v", items) + } +} + func TestAccountActionCandidateWorkerAutoDisablesReauth(t *testing.T) { st, err := store.Open(t.TempDir() + "/usage.sqlite") if err != nil { diff --git a/apps/manager-server/internal/worker/account_history_rollup.go b/apps/manager-server/internal/worker/account_history_rollup.go new file mode 100644 index 000000000..b184e2b8f --- /dev/null +++ b/apps/manager-server/internal/worker/account_history_rollup.go @@ -0,0 +1,98 @@ +package worker + +import ( + "context" + "log" + "sync/atomic" + "time" + + collectorpkg "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/collector" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" +) + +const ( + defaultAccountHistoryRollupBatchLimit = 1000 + defaultAccountHistoryRollupMaxBatches = 10 + defaultAccountHistoryRollupCheckInterval = 30 * time.Second +) + +type AccountHistoryRollupWorker struct { + store *store.Store + wake chan struct{} + running int32 + batchLimit int + maxBatches int + checkInterval time.Duration +} + +func NewAccountHistoryRollupWorker(store *store.Store) *AccountHistoryRollupWorker { + return &AccountHistoryRollupWorker{ + store: store, + wake: make(chan struct{}, 1), + batchLimit: defaultAccountHistoryRollupBatchLimit, + maxBatches: defaultAccountHistoryRollupMaxBatches, + checkInterval: defaultAccountHistoryRollupCheckInterval, + } +} + +func (w *AccountHistoryRollupWorker) Start(ctx context.Context) { + if w == nil || w.store == nil { + return + } + go w.loop(ctx) + w.Wake() +} + +func (w *AccountHistoryRollupWorker) HandleUsageEvents(ctx context.Context, _ collectorpkg.RuntimeConfig, events []usage.Event) { + if w == nil || len(events) == 0 || ctx.Err() != nil { + return + } + w.Wake() +} + +func (w *AccountHistoryRollupWorker) Wake() { + if w == nil { + return + } + select { + case w.wake <- struct{}{}: + default: + } +} + +func (w *AccountHistoryRollupWorker) loop(ctx context.Context) { + ticker := time.NewTicker(w.checkInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-w.wake: + w.catchUp(ctx) + case <-ticker.C: + w.catchUp(ctx) + } + } +} + +func (w *AccountHistoryRollupWorker) catchUp(ctx context.Context) { + if !atomic.CompareAndSwapInt32(&w.running, 0, 1) { + return + } + defer atomic.StoreInt32(&w.running, 0) + + for batch := 0; batch < w.maxBatches; batch++ { + if ctx.Err() != nil { + return + } + result, err := w.store.CatchUpAccountHistoryRollups(ctx, w.batchLimit, time.Now().UnixMilli()) + if err != nil { + log.Printf("[usage-rollup] account history catch-up failed: %v", err) + return + } + if result.Processed == 0 || !result.Pending { + return + } + } +} diff --git a/apps/manager-server/internal/worker/automation_runtime.go b/apps/manager-server/internal/worker/automation_runtime.go index 800becc66..28d4203b5 100644 --- a/apps/manager-server/internal/worker/automation_runtime.go +++ b/apps/manager-server/internal/worker/automation_runtime.go @@ -60,6 +60,13 @@ func (r *AutomationRuntime) Start(ctx context.Context) { r.logState(ctx, "loaded") } +func (r *AutomationRuntime) UsageEventHandler() collectorpkg.UsageEventHandler { + if r == nil { + return nil + } + return r.handler +} + func (r *AutomationRuntime) Reload(ctx context.Context) error { if r == nil { return nil diff --git a/apps/manager-server/internal/worker/rate_limit_auto_disable.go b/apps/manager-server/internal/worker/rate_limit_auto_disable.go index 66d2f61fc..b85d97ccc 100644 --- a/apps/manager-server/internal/worker/rate_limit_auto_disable.go +++ b/apps/manager-server/internal/worker/rate_limit_auto_disable.go @@ -192,15 +192,16 @@ func (w *RateLimitAutoDisableWorker) handleCandidate(ctx context.Context, candid return } + resolvedAuthIndex := firstNonEmpty(candidate.AuthIndex, current.AuthIndex) log.Printf("[quota-auto-disable] Codex usage limit reached for auth file %q account=%q provider=%q resetAt=%s, disabling", candidate.FileName, candidate.DisplayAccount, candidate.Provider, candidate.ResetAt.Format(time.RFC3339)) - if err := w.patchAuthFile(ctx, candidate.BaseURL, candidate.ManagementKey, candidate.FileName, true); err != nil { + if err := w.patchAuthFile(ctx, candidate.BaseURL, candidate.ManagementKey, candidate.FileName, resolvedAuthIndex, true); err != nil { log.Printf("[quota-auto-disable] failed to disable auth file %q: %v", candidate.FileName, err) return } _, err = w.store.UpsertQuotaCooldown(ctx, store.QuotaCooldownUpsert{ AuthFileName: candidate.FileName, - AuthIndex: firstNonEmpty(candidate.AuthIndex, current.AuthIndex), + AuthIndex: resolvedAuthIndex, AccountSnapshot: candidate.DisplayAccount, Provider: strings.ToLower(strings.TrimSpace(candidate.Provider)), RecoverAtMS: candidate.ResetAt.UnixMilli(), @@ -211,7 +212,7 @@ func (w *RateLimitAutoDisableWorker) handleCandidate(ctx context.Context, candid }) if err != nil { log.Printf("[quota-auto-disable] disabled auth file %q but failed to persist cooldown ownership: %v", candidate.FileName, err) - if rollbackErr := w.patchAuthFile(ctx, candidate.BaseURL, candidate.ManagementKey, candidate.FileName, false); rollbackErr != nil { + if rollbackErr := w.patchAuthFile(ctx, candidate.BaseURL, candidate.ManagementKey, candidate.FileName, resolvedAuthIndex, false); rollbackErr != nil { log.Printf("[quota-auto-disable] failed to roll back auth file %q after cooldown persistence error: %v", candidate.FileName, rollbackErr) } return @@ -308,7 +309,7 @@ func (w *RateLimitAutoDisableWorker) recoverCooldown(ctx context.Context, baseUR } log.Printf("[quota-auto-disable] reset time reached for auth file %q account=%q, enabling", item.AuthFileName, item.AccountSnapshot) - if err := w.patchAuthFile(ctx, baseURL, managementKey, item.AuthFileName, false); err != nil { + if err := w.patchAuthFile(ctx, baseURL, managementKey, item.AuthFileName, item.AuthIndex, false); err != nil { _ = w.store.RecordQuotaCooldownFailure(ctx, item.ID, err.Error()) log.Printf("[quota-auto-disable] failed to enable auth file %q: %v", item.AuthFileName, err) return @@ -638,16 +639,12 @@ func parseCommonTime(text string) (time.Time, bool) { } func (w *RateLimitAutoDisableWorker) currentAuthFile(ctx context.Context, baseURL string, managementKey string, fileName string, authIndex string) (authFile, bool, error) { - files, err := cpaauthfiles.New(w.client, quotaAutoDisableActionTimeout).Fetch(ctx, baseURL, managementKey) - if err != nil { - return authFile{}, false, err - } - file, ok := cpaauthfiles.Find(files, fileName, authIndex) - return file, ok, nil + file, ok, err := cpaauthfiles.New(w.client, quotaAutoDisableActionTimeout).Find(ctx, baseURL, managementKey, fileName, authIndex) + return file, ok, err } -func (w *RateLimitAutoDisableWorker) patchAuthFile(ctx context.Context, baseURL string, managementKey string, fileName string, disabled bool) error { - return cpaauthfiles.New(w.client, quotaAutoDisableActionTimeout).PatchDisabled(ctx, baseURL, managementKey, fileName, disabled) +func (w *RateLimitAutoDisableWorker) patchAuthFile(ctx context.Context, baseURL string, managementKey string, fileName string, authIndex string, disabled bool) error { + return cpaauthfiles.New(w.client, quotaAutoDisableActionTimeout).PatchDisabled(ctx, baseURL, managementKey, fileName, disabled, authIndex) } func firstNonEmpty(values ...string) string { diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index e9180983b..91811b492 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -964,9 +964,7 @@ export function MainLayout({ routeBase = '', demoMode = false }: MainLayoutProps title={item.label} > {item.icon} - {showSidebarLabels && ( - {item.shortLabel ?? item.label} - )} + {showSidebarLabels && {item.label}} ))}
diff --git a/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss b/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss index 607ac9746..afae07fea 100644 --- a/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss +++ b/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss @@ -2,7 +2,13 @@ @use '@/styles/mixins.scss' as *; // 类型 / 标识 / 地址 / 模型 / 优先级 / 近期请求 / 启用 / 操作 -$table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px 60px 108px; +$table-columns: 92px minmax(132px, 0.72fr) minmax(180px, 1fr) 96px 72px minmax(188px, 0.68fr) 68px 112px; + +@mixin provider-card-layout { + @include narrow-laptop { + @content; + } +} .table { display: flex; @@ -14,14 +20,14 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px display: grid; grid-template-columns: $table-columns; align-items: center; - gap: 12px; + gap: 16px; padding: $spacing-sm $spacing-md; font-size: 12px; font-weight: 600; color: var(--text-tertiary); border-bottom: 1px solid var(--border-color); - @include mobile { + @include provider-card-layout { display: none; } } @@ -30,7 +36,7 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px display: grid; grid-template-columns: $table-columns; align-items: center; - gap: 12px; + gap: 16px; min-height: 58px; padding: 10px $spacing-md; border-bottom: 1px solid var(--border-subtle); @@ -47,14 +53,16 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px border-bottom: none; } - @include mobile { - grid-template-columns: auto 1fr auto; + @include provider-card-layout { + grid-template-columns: 1fr auto; grid-template-areas: - 'type identity toggle' - 'url url url' - 'priority models models' - 'recent recent recent' - 'actions actions actions'; + 'type toggle' + 'identity identity' + 'url url' + 'priority models' + 'recent recent' + 'actions actions'; + column-gap: 10px; row-gap: 6px; padding: $spacing-md; border: 1px solid var(--border-color); @@ -82,7 +90,7 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px gap: 8px; min-width: 0; - @include mobile { + @include provider-card-layout { grid-area: type; } } @@ -125,7 +133,7 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px gap: 0; min-width: 0; - @include mobile { + @include provider-card-layout { grid-area: identity; } } @@ -135,9 +143,15 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px align-items: center; gap: 6px; min-width: 0; + + @include provider-card-layout { + flex-wrap: wrap; + row-gap: 2px; + } } .identityLabel { + min-width: 0; font-size: 13px; font-weight: 600; color: var(--text-primary); @@ -149,6 +163,13 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px flex-shrink: 0; font-size: 12px; color: var(--text-tertiary); + + @include provider-card-layout { + flex-shrink: 1; + min-width: 0; + max-width: 100%; + @include text-ellipsis; + } } .cellUrl { @@ -156,7 +177,7 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px color: var(--text-secondary); @include text-ellipsis; - @include mobile { + @include provider-card-layout { grid-area: url; white-space: normal; overflow-wrap: anywhere; @@ -166,7 +187,7 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px .cellNumeric { text-align: right; - @include mobile { + @include provider-card-layout { text-align: left; } } @@ -177,29 +198,135 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px color: var(--text-secondary); font-variant-numeric: tabular-nums; - @include mobile { + @include provider-card-layout { display: flex; align-items: center; gap: 6px; } } +.cellModelsHeader, +.cellPriorityHeader { + text-align: center; +} + .cellPriority { - @include mobile { + display: flex; + align-items: center; + justify-content: center; + + @include provider-card-layout { grid-area: priority; + justify-content: flex-start; + } +} + +.priorityControl { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: default; + + @include provider-card-layout { + justify-content: flex-start; + } +} + +.priorityValueButton { + max-width: 100%; + min-width: 0; + height: 30px; + padding: 0 4px; + border: 0; + border-radius: 6px; + outline: none; + background: transparent; + text-align: center; + font: inherit; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 30px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + transition: + background $transition-fast, + color $transition-fast, + box-shadow $transition-fast; + + &:hover:not(:disabled) { + background: var(--app-accent-soft); + color: var(--primary-color); + } + + &:focus { + background: var(--focus-bg); + color: var(--focus-text); + box-shadow: inset 0 0 0 1px var(--focus-inset); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.62; + } +} + +.priorityInput { + width: 36px; + min-width: 36px; + height: 30px; + padding: 0 3px; + border: 0; + border-radius: 6px; + outline: none; + appearance: textfield; + background: transparent; + text-align: center; + font: inherit; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-primary); + transition: + background $transition-fast, + color $transition-fast, + box-shadow $transition-fast; + + &:focus { + background: var(--focus-bg); + color: var(--focus-text); + box-shadow: inset 0 0 0 1px var(--focus-inset); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.62; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + margin: 0; + appearance: none; } } .cellModels { - @include mobile { + text-align: center; + + @include provider-card-layout { grid-area: models; + text-align: left; } } .cellRecentHeader { - padding-left: 16px; + padding-left: 8px; + text-align: left; - @include mobile { + @include provider-card-layout { padding-left: 0; } } @@ -208,7 +335,7 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px .cellCaption { display: none; - @include mobile { + @include provider-card-layout { display: inline; font-size: 12px; color: var(--text-tertiary); @@ -220,13 +347,13 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px align-items: center; gap: $spacing-sm; min-width: 0; - width: 360px; - max-width: 360px; - padding-left: 16px; + width: auto; + max-width: none; + padding-left: 8px; font-size: 12px; font-variant-numeric: tabular-nums; - @include mobile { + @include provider-card-layout { grid-area: recent; width: 100%; max-width: 100%; @@ -237,8 +364,8 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px // 中和 ProviderStatusBar 默认样式中的纵向留白,保证单行对齐 .recentBarWrap { flex: 1; - min-width: 228px; - max-width: 260px; + min-width: 150px; + max-width: 220px; > div { margin-top: 0; @@ -246,7 +373,7 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px max-width: 100%; } - @include mobile { + @include provider-card-layout { min-width: 0; max-width: none; } @@ -278,7 +405,7 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px justify-content: flex-start; cursor: default; - @include mobile { + @include provider-card-layout { grid-area: toggle; justify-content: flex-end; } @@ -291,7 +418,7 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px gap: 5px; cursor: default; - @include mobile { + @include provider-card-layout { grid-area: actions; justify-content: flex-end; padding-top: 2px; diff --git a/apps/web/src/components/providers/ProviderTable/ProviderTable.test.tsx b/apps/web/src/components/providers/ProviderTable/ProviderTable.test.tsx index c9ae64f45..2b1488bcd 100644 --- a/apps/web/src/components/providers/ProviderTable/ProviderTable.test.tsx +++ b/apps/web/src/components/providers/ProviderTable/ProviderTable.test.tsx @@ -52,6 +52,76 @@ const toggleSwitch = (toggle: ReactTestInstance, value: boolean) => { }); }; +const changeInput = (input: ReactTestInstance, value: string) => { + const onChange = input.props.onChange as + | ((event: { target: { value: string } }) => void) + | undefined; + if (!onChange) throw new Error('Input change handler not found'); + + act(() => { + onChange({ target: { value } }); + }); +}; + +const blurInput = (input: ReactTestInstance) => { + const onBlur = input.props.onBlur as (() => void) | undefined; + if (!onBlur) throw new Error('Input blur handler not found'); + + act(() => { + onBlur(); + }); +}; + +const keyDownInput = (input: ReactTestInstance, key: string) => { + const onKeyDown = input.props.onKeyDown as + | ((event: { + key: string; + preventDefault: () => void; + currentTarget: { blur: () => void }; + }) => void) + | undefined; + const onBlur = input.props.onBlur as (() => void) | undefined; + if (!onKeyDown || !onBlur) throw new Error('Input keyboard handlers not found'); + + act(() => { + onKeyDown({ + key, + preventDefault: vi.fn(), + currentTarget: { + blur: onBlur, + }, + }); + }); +}; + +const getPriorityEditTrigger = (row: ReactTestInstance) => { + const trigger = row + .findAll((node) => node.type === 'button') + .find( + (button) => + button.props.type === 'button' && + button.props['aria-label'] === 'ai_providers.priority_edit' + ); + if (!trigger) throw new Error('Priority edit trigger not found'); + return trigger; +}; + +const getPriorityInput = (row: ReactTestInstance) => { + const input = getPriorityInputs(row)[0]; + if (!input) throw new Error('Priority input not found'); + return input; +}; + +const getPriorityInputs = (row: ReactTestInstance) => + row + .findAll((node) => node.type === 'input') + .filter((node) => node.props['aria-label'] === 'ai_providers.priority_edit'); + +const openPriorityEditor = (renderer: ReactTestRenderer, rowIndex = 0) => { + clickButton(getPriorityEditTrigger(getRows(renderer)[rowIndex])); + return getPriorityInput(getRows(renderer)[rowIndex]); +}; + describe('ProviderTable', () => { const codexConfigs: ProviderKeyConfig[] = [ { apiKey: 'low-key', baseUrl: 'https://low.example.com/v1', priority: 1 }, @@ -72,6 +142,11 @@ describe('ProviderTable', () => { onEdit?: (row: ProviderRow) => void; onDelete?: (row: ProviderRow) => void; onToggle?: (row: ProviderRow, enabled: boolean) => void; + onPriorityChange?: (row: ProviderRow, priority: number) => void; + } = {}, + options: { + actionsDisabled?: boolean; + toggleDisabled?: boolean; } = {} ) => { let renderer!: ReactTestRenderer; @@ -80,14 +155,15 @@ describe('ProviderTable', () => { empty
} onShowDetail={handlers.onShowDetail ?? (() => {})} onEdit={handlers.onEdit ?? (() => {})} onDelete={handlers.onDelete ?? (() => {})} onToggle={handlers.onToggle ?? (() => {})} + onPriorityChange={handlers.onPriorityChange ?? (() => {})} /> ); }); @@ -113,8 +189,11 @@ describe('ProviderTable', () => { 'https://disabled.example.com/v1' ); - const editButton = renderedRows[0].findAllByType(Button)[0]; - clickButton(editButton); + const editButton = renderedRows[0] + .findAllByType(Button) + .find((button) => button.props['aria-label'] === 'common.edit'); + expect(editButton).toBeTruthy(); + clickButton(editButton!); expect(onEdit).toHaveBeenLastCalledWith( expect.objectContaining({ kind: 'codex', originalIndex: 2 }) ); @@ -146,6 +225,127 @@ describe('ProviderTable', () => { expect(lastToggle.props.checked).toBe(false); }); + it('shows priority as an edit trigger before entering edit mode', () => { + const rows = filterAndSortProviderRows( + buildProviderRows({ ...emptyInput, codex: codexConfigs }) + ); + const renderer = renderTable(rows); + + const firstRow = getRows(renderer)[0]; + const priorityTrigger = getPriorityEditTrigger(firstRow); + expect(getText(priorityTrigger)).toBe('9'); + expect(getPriorityInputs(firstRow)).toHaveLength(0); + + const priorityInput = openPriorityEditor(renderer); + expect(priorityInput.props.value).toBe('9'); + }); + + it('commits a direct priority edit on blur without opening row detail', () => { + const rows = filterAndSortProviderRows( + buildProviderRows({ ...emptyInput, codex: codexConfigs }) + ); + const onPriorityChange = vi.fn(); + const onShowDetail = vi.fn(); + const renderer = renderTable(rows, { onPriorityChange, onShowDetail }); + + const priorityInput = openPriorityEditor(renderer); + + changeInput(priorityInput, '42'); + blurInput(priorityInput); + + expect(onPriorityChange).toHaveBeenLastCalledWith( + expect.objectContaining({ kind: 'codex', originalIndex: 2 }), + 42 + ); + expect(onShowDetail).not.toHaveBeenCalled(); + }); + + it('commits a direct priority edit with Enter only once', () => { + const rows = filterAndSortProviderRows( + buildProviderRows({ ...emptyInput, codex: codexConfigs }) + ); + const onPriorityChange = vi.fn(); + const renderer = renderTable(rows, { onPriorityChange }); + + const priorityInput = openPriorityEditor(renderer); + + changeInput(priorityInput, '12'); + keyDownInput(priorityInput, 'Enter'); + + expect(onPriorityChange).toHaveBeenCalledTimes(1); + expect(onPriorityChange).toHaveBeenLastCalledWith( + expect.objectContaining({ kind: 'codex', originalIndex: 2 }), + 12 + ); + }); + + it('cancels a direct priority edit with Escape without committing on blur', () => { + const rows = filterAndSortProviderRows( + buildProviderRows({ ...emptyInput, codex: codexConfigs }) + ); + const onPriorityChange = vi.fn(); + const renderer = renderTable(rows, { onPriorityChange }); + + const priorityInput = openPriorityEditor(renderer); + + changeInput(priorityInput, '12'); + keyDownInput(priorityInput, 'Escape'); + + const updatedTrigger = getPriorityEditTrigger(getRows(renderer)[0]); + expect(getText(updatedTrigger)).toBe('9'); + expect(onPriorityChange).not.toHaveBeenCalled(); + }); + + it('restores blank or invalid direct priority drafts without committing', () => { + const rows = filterAndSortProviderRows( + buildProviderRows({ ...emptyInput, codex: codexConfigs }) + ); + const onPriorityChange = vi.fn(); + const renderer = renderTable(rows, { onPriorityChange }); + + let priorityInput = openPriorityEditor(renderer); + + changeInput(priorityInput, ''); + blurInput(priorityInput); + let priorityTrigger = getPriorityEditTrigger(getRows(renderer)[0]); + expect(getText(priorityTrigger)).toBe('9'); + + priorityInput = openPriorityEditor(renderer); + changeInput(priorityInput, 'not-a-number'); + blurInput(priorityInput); + priorityTrigger = getPriorityEditTrigger(getRows(renderer)[0]); + expect(getText(priorityTrigger)).toBe('9'); + expect(onPriorityChange).not.toHaveBeenCalled(); + }); + + it('does not submit unchanged direct priority edits', () => { + const rows = filterAndSortProviderRows( + buildProviderRows({ ...emptyInput, codex: codexConfigs }) + ); + const onPriorityChange = vi.fn(); + const renderer = renderTable(rows, { onPriorityChange }); + + const priorityInput = openPriorityEditor(renderer); + + changeInput(priorityInput, '9'); + blurInput(priorityInput); + + expect(onPriorityChange).not.toHaveBeenCalled(); + }); + + it('disables inline priority controls with the rest of row actions', () => { + const rows = filterAndSortProviderRows( + buildProviderRows({ ...emptyInput, codex: codexConfigs }) + ); + const renderer = renderTable(rows, {}, { actionsDisabled: true }); + + const firstRow = getRows(renderer)[0]; + const priorityTrigger = getPriorityEditTrigger(firstRow); + + expect(priorityTrigger.props.disabled).toBe(true); + expect(getPriorityInputs(firstRow)).toHaveLength(0); + }); + it('renders the provided empty state when there are no rows', () => { const renderer = renderTable([]); expect(getText(renderer.root as unknown as ReactTestInstance)).toContain('empty'); diff --git a/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx b/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx index 9a2da1cbf..e63869e5e 100644 --- a/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx +++ b/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx @@ -1,8 +1,22 @@ -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'; +import { + useEffect, + useRef, + useState, + type ChangeEvent, + type KeyboardEvent, + type MouseEvent, + type ReactNode, +} from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; -import { IconCheck, IconEye, IconPencil, IconTrash2, IconX } from '@/components/ui/icons'; +import { + IconCheck, + IconEye, + IconPencil, + IconTrash2, + IconX, +} from '@/components/ui/icons'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { getProviderKindIcon, PROVIDER_KIND_LABELS } from './kindMeta'; import type { ProviderRow } from './rowData'; @@ -19,12 +33,130 @@ interface ProviderTableProps { onEdit: (row: ProviderRow) => void; onDelete: (row: ProviderRow) => void; onToggle: (row: ProviderRow, enabled: boolean) => void; + onPriorityChange: (row: ProviderRow, priority: number) => void; } const stopPropagation = (event: MouseEvent) => { event.stopPropagation(); }; +const priorityToDraft = (priority: number | undefined) => + Number.isFinite(priority) ? String(Math.trunc(priority ?? 0)) : ''; + +interface PriorityControlProps { + row: ProviderRow; + disabled: boolean; + editLabel: string; + onPriorityChange: (row: ProviderRow, priority: number) => void; +} + +function PriorityControl({ + row, + disabled, + editLabel, + onPriorityChange, +}: PriorityControlProps) { + const [draft, setDraft] = useState(() => priorityToDraft(row.priority)); + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + const skipNextBlurCommitRef = useRef(false); + const currentPriority = Number.isFinite(row.priority) ? Math.trunc(row.priority ?? 0) : 0; + const displayPriority = priorityToDraft(row.priority) || '—'; + + useEffect(() => { + if (!isEditing) return; + inputRef.current?.focus(); + inputRef.current?.select(); + }, [isEditing]); + + const commitDraft = () => { + if (skipNextBlurCommitRef.current) { + skipNextBlurCommitRef.current = false; + return; + } + + const trimmed = draft.trim(); + if (!trimmed) { + setDraft(priorityToDraft(row.priority)); + setIsEditing(false); + return; + } + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + setDraft(priorityToDraft(row.priority)); + setIsEditing(false); + return; + } + + const nextPriority = Math.trunc(parsed); + setDraft(String(nextPriority)); + setIsEditing(false); + if (nextPriority !== currentPriority || row.priority === undefined) { + onPriorityChange(row, nextPriority); + } + }; + + const startEditing = () => { + if (disabled) return; + skipNextBlurCommitRef.current = false; + setDraft(priorityToDraft(row.priority)); + setIsEditing(true); + }; + + const handleInputChange = (event: ChangeEvent) => { + setDraft(event.target.value); + }; + + const handleInputKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitDraft(); + skipNextBlurCommitRef.current = true; + event.currentTarget.blur(); + } else if (event.key === 'Escape') { + event.preventDefault(); + skipNextBlurCommitRef.current = true; + setDraft(priorityToDraft(row.priority)); + setIsEditing(false); + event.currentTarget.blur(); + } + }; + + return ( +
+ {isEditing ? ( + + ) : ( + + )} +
+ ); +} + export function ProviderTable({ rows, loading, @@ -36,6 +168,7 @@ export function ProviderTable({ onEdit, onDelete, onToggle, + onPriorityChange, }: ProviderTableProps) { const { t } = useTranslation(); @@ -61,10 +194,10 @@ export function ProviderTable({ {t('ai_providers.table_col_type')} {t('ai_providers.table_col_identity')} {t('common.base_url')} - + {t('ai_providers.table_col_models')} - + {t('common.priority')} @@ -129,7 +262,13 @@ export function ProviderTable({
{t('common.priority')} - {row.priority ?? '—'} +
diff --git a/apps/web/src/components/quota/QuotaSection.test.tsx b/apps/web/src/components/quota/QuotaSection.test.tsx index f85b1aa03..f825d1427 100644 --- a/apps/web/src/components/quota/QuotaSection.test.tsx +++ b/apps/web/src/components/quota/QuotaSection.test.tsx @@ -188,6 +188,11 @@ const findButtonByText = (renderer: ReactTestRenderer, text: string) => { const findButtonsByText = (renderer: ReactTestRenderer, text: string) => renderer.root.findAllByType('button').filter((node) => getText(node).includes(text)); +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + let runLoadQuota: | ((targets: AuthFileItem[], setLoading?: (loading: boolean) => void) => Promise) | undefined; @@ -420,6 +425,70 @@ describe('QuotaSection account display mode', () => { }); }); + it('limits bulk quota refresh concurrency', async () => { + const scopedConfig = createScopedTestConfig(); + const files: AuthFileItem[] = Array.from({ length: 7 }, (_, index) => ({ + ...testFile, + authIndex: index, + name: `${index}-${testFile.name}`, + })); + const resolvers: Array<() => void> = []; + let activeRequests = 0; + let maxActiveRequests = 0; + + mocks.quotaStoreState.codexQuota = {}; + mocks.fetchQuota.mockImplementation(async (file: AuthFileItem) => { + activeRequests += 1; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + + await new Promise((resolve) => { + resolvers.push(resolve); + }); + + activeRequests -= 1; + return { resetCredits: file.authIndex ?? 0 }; + }); + + let renderer!: ReactTestRenderer; + act(() => { + renderer = create( + { + runLoadQuota = nextLoadQuota; + }} + /> + ); + }); + + const loadPromise = runLoadQuota?.(files); + await act(async () => { + await flushMicrotasks(); + }); + + expect(mocks.fetchQuota).toHaveBeenCalledTimes(4); + expect(maxActiveRequests).toBe(4); + + while (mocks.fetchQuota.mock.calls.length < files.length) { + resolvers.shift()?.(); + await act(async () => { + await flushMicrotasks(); + }); + } + + resolvers.splice(0).forEach((resolve) => resolve()); + await act(async () => { + await loadPromise; + }); + + expect(maxActiveRequests).toBe(4); + expect(mocks.fetchQuota).toHaveBeenCalledTimes(files.length); + + act(() => { + renderer.unmount(); + }); + }); + it('keeps previous scoped quota data when bulk quota refresh fails', async () => { const scopedConfig = createScopedTestConfig(); const files: AuthFileItem[] = [{ ...testFile, authIndex: 0 }]; diff --git a/apps/web/src/components/quota/quotaConfigs.ts b/apps/web/src/components/quota/quotaConfigs.ts index bd94a73ac..f2622732c 100644 --- a/apps/web/src/components/quota/quotaConfigs.ts +++ b/apps/web/src/components/quota/quotaConfigs.ts @@ -7,6 +7,7 @@ import type { ReactNode } from 'react'; import type { TFunction } from 'i18next'; import type { AntigravityQuotaState, + AntigravityQuotaSubscription, AuthFileItem, ClaudeExtraUsage, ClaudeQuotaState, @@ -144,6 +145,95 @@ export const buildQuotaFailureState = ( ? config.buildFailureState(message, status, file, activeState, failedAtMs) : config.buildErrorState(message, status, file); +const formatAntigravityDuration = (t: TFunction, deltaMs: number): string => { + const totalMinutes = Math.max(0, Math.ceil(deltaMs / 60000)); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + + if (days > 0) { + return t('antigravity_quota.duration_day_hour', { days, hours }); + } + if (hours > 0) { + return t('antigravity_quota.duration_hour_minute', { hours, minutes }); + } + if (minutes > 0) { + return t('antigravity_quota.duration_minute', { minutes }); + } + return t('antigravity_quota.duration_less_than_minute'); +}; + +const formatAntigravityResetLabel = ( + resetTime: string | undefined, + t: TFunction, + nowMs: number +): string => { + if (!resetTime) return '-'; + const resetMs = new Date(resetTime).getTime(); + if (Number.isNaN(resetMs)) return formatQuotaResetTime(resetTime); + const deltaMs = resetMs - nowMs; + if (deltaMs <= 0) return t('antigravity_quota.refresh_available'); + return t('antigravity_quota.refreshes_in', { + duration: formatAntigravityDuration(t, deltaMs), + }); +}; + +const ANTIGRAVITY_GROUP_LABEL_KEYS = new Map([ + ['gemini models', 'group_gemini_models'], + ['claude and gpt models', 'group_claude_gpt_models'], +]); + +const ANTIGRAVITY_BUCKET_LABEL_KEYS = new Map([ + ['weekly limit', 'weekly_limit'], + ['daily limit', 'daily_limit'], + ['5 hour limit', 'five_hour_limit'], + ['5-hour limit', 'five_hour_limit'], + ['five hour limit', 'five_hour_limit'], + ['monthly limit', 'monthly_limit'], +]); + +const normalizeAntigravityQuotaText = (value: string): string => + value.trim().toLowerCase().replace(/\s+/g, ' '); + +const translateAntigravityQuotaLabel = ( + value: string, + keys: Map, + t: TFunction +): string => { + const key = keys.get(normalizeAntigravityQuotaText(value)); + return key ? t(`antigravity_quota.${key}`) : value; +}; + +const translateAntigravityQuotaDescription = ( + value: string | undefined, + t: TFunction +): string | undefined => { + if (!value) return undefined; + const modelsMatch = value.match(/^models within this group:\s*(.+)$/i); + if (modelsMatch) { + return t('antigravity_quota.group_models_description', { + models: modelsMatch[1].trim(), + }); + } + return value; +}; + +const getAntigravityPlanLabel = ( + subscription: AntigravityQuotaSubscription | null | undefined, + t: TFunction +): string | null => { + if (!subscription) return null; + if (subscription.plan === 'free') return t('antigravity_subscription.plan_free'); + if (subscription.plan === 'pro') return t('antigravity_subscription.plan_pro'); + if (subscription.plan === 'ultra') return t('antigravity_subscription.plan_ultra'); + if (subscription.plan === 'ultra-lite') return t('antigravity_subscription.plan_ultra_lite'); + return ( + subscription.tierName || + subscription.tierId || + (subscription.plan === 'unknown' ? t('antigravity_subscription.plan_unknown') : null) + ); +}; + const renderAntigravityItems = ( quota: AntigravityQuotaState, t: TFunction, @@ -152,40 +242,74 @@ const renderAntigravityItems = ( const { styles: styleMap, QuotaProgressBar } = helpers; const { createElement: h, Fragment } = React; const groups = quota.groups ?? []; + const nodes: ReactNode[] = []; + const planLabel = getAntigravityPlanLabel(quota.subscription, t); + const normalizedPlan = quota.subscription?.plan?.toLowerCase() ?? ''; + const isPremiumPlan = + normalizedPlan === 'pro' || normalizedPlan === 'ultra' || normalizedPlan === 'ultra-lite'; + + if (planLabel) { + nodes.push( + h( + 'div', + { key: 'plan', className: styleMap.codexPlan }, + h('span', { className: styleMap.codexPlanLabel }, t('antigravity_quota.plan_label')), + h( + 'span', + { className: isPremiumPlan ? styleMap.premiumPlanValue : styleMap.codexPlanValue }, + planLabel + ) + ) + ); + } if (groups.length === 0) { - return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models')); + nodes.push( + h( + 'div', + { key: 'empty', className: styleMap.quotaMessage }, + t('antigravity_quota.empty_models') + ) + ); + return h(Fragment, null, ...nodes); } const nowMs = Date.now() + (quota.serverTimeOffsetMs ?? 0); - return h( - Fragment, - null, + nodes.push( ...groups.flatMap((group) => { - const shouldRenderGroupHeader = group.buckets.length > 1 || Boolean(group.description); - const groupHeader = shouldRenderGroupHeader - ? [ - h( - 'div', - { key: `${group.id}-header`, className: styleMap.quotaMessage }, - group.description - ? h('span', { title: group.description }, group.label) - : h('span', null, group.label) - ), - ] - : []; + const groupLabel = translateAntigravityQuotaLabel( + group.label, + ANTIGRAVITY_GROUP_LABEL_KEYS, + t + ); + const groupDescription = translateAntigravityQuotaDescription(group.description, t); + const groupHeader = h( + 'div', + { key: `${group.id}-header`, className: styleMap.quotaMessage }, + groupDescription + ? h('span', { title: groupDescription }, groupLabel) + : h('span', null, groupLabel) + ); return [ - ...groupHeader, + groupHeader, ...group.buckets.map((bucket) => { const clamped = Math.max(0, Math.min(1, bucket.remainingFraction)); - const percent = Math.round(clamped * 100); - const resetMs = bucket.resetTime ? new Date(bucket.resetTime).getTime() : Number.NaN; - const resetLabel = - bucket.resetTime && !Number.isNaN(resetMs) && resetMs <= nowMs - ? t('antigravity_quota.refresh_available') - : formatQuotaResetTime(bucket.resetTime); + const percent = clamped * 100; + const percentLabel = + bucket.remainingFraction === 1 + ? t('antigravity_quota.quota_available') + : t('antigravity_quota.remaining_percent', { + percent: Math.round(percent), + }); + const resetLabel = formatAntigravityResetLabel(bucket.resetTime, t, nowMs); + const bucketLabel = translateAntigravityQuotaLabel( + bucket.label, + ANTIGRAVITY_BUCKET_LABEL_KEYS, + t + ); + const bucketDescription = translateAntigravityQuotaDescription(bucket.description, t); return h( 'div', @@ -193,15 +317,11 @@ const renderAntigravityItems = ( h( 'div', { className: styleMap.quotaRowHeader }, - h( - 'span', - { className: styleMap.quotaModel, title: bucket.description }, - bucket.label - ), + h('span', { className: styleMap.quotaModel, title: bucketDescription }, bucketLabel), h( 'div', { className: styleMap.quotaMeta }, - h('span', { className: styleMap.quotaPercent }, `${percent}%`), + h('span', { className: styleMap.quotaPercent }, percentLabel), h('span', { className: styleMap.quotaReset }, resetLabel) ) ), @@ -215,6 +335,8 @@ const renderAntigravityItems = ( ]; }) ); + + return h(Fragment, null, ...nodes); }; const PREMIUM_CODEX_PLAN_TYPES = new Set(['pro', 'prolite', 'pro-lite', 'pro_lite']); @@ -569,9 +691,6 @@ const formatCodexResetCreditExpiryTime = (expiresAt: string): string => { }); }; -const formatCodexTooltipPercent = (value: number | null): string | null => - value === null ? null : `${Math.round(value)}%`; - const renderCodexResetCreditExpiryInfo = ( quota: CodexQuotaState, t: TFunction, @@ -629,101 +748,40 @@ const renderCodexResetCreditExpiryInfo = ( const buildCodexWindowTooltipRows = ( quota: CodexQuotaState, - window: CodexQuotaWindow, - windowLabel: string, - usedPercent: number | null, - remainingPercent: number | null, t: TFunction ): CodexQuotaTooltipRow[] => { - const rows: CodexQuotaTooltipRow[] = []; - const usedLabel = formatCodexTooltipPercent(usedPercent); - const remainingLabel = formatCodexTooltipPercent(remainingPercent); - - if (quota.observedFromUsageHeaders) { - rows.push({ - key: 'source', - label: t('codex_quota.tooltip_source_label'), - value: t('codex_quota.tooltip_source_header'), - }); + const fromUsageHeaders = quota.observedFromUsageHeaders === true; + const timestampMs = fromUsageHeaders ? quota.observedAtMs : quota.fetchedAtMs; + const fetchedAt = + timestampMs && Number.isFinite(timestampMs) ? new Date(timestampMs).toLocaleString() : '--'; - if (quota.observedAtMs && Number.isFinite(quota.observedAtMs)) { - rows.push({ - key: 'recorded-at', - label: t('codex_quota.tooltip_recorded_at_label'), - value: new Date(quota.observedAtMs).toLocaleString(), - }); - } - } else { - rows.push({ + return [ + { key: 'source', label: t('codex_quota.tooltip_source_label'), - value: t('codex_quota.tooltip_source_api'), - }); - - if (quota.fetchedAtMs && Number.isFinite(quota.fetchedAtMs)) { - rows.push({ - key: 'fetched-at', - label: t('codex_quota.tooltip_fetched_at_label'), - value: new Date(quota.fetchedAtMs).toLocaleString(), - }); - } - } - - if (usedLabel) { - rows.push({ - key: 'used', - label: t('codex_quota.tooltip_used_label'), - value: usedLabel, - }); - } - - if (remainingLabel) { - rows.push({ - key: 'remaining', - label: t('codex_quota.tooltip_remaining_label'), - value: remainingLabel, - }); - } - - if (window.resetLabel && window.resetLabel !== '-') { - rows.push({ - key: 'reset', - label: t('codex_quota.tooltip_reset_label'), - value: window.resetLabel, - }); - } - - return rows.length > 0 - ? rows - : [ - { - key: 'window', - label: t('codex_quota.tooltip_window_label'), - value: windowLabel, - }, - ]; + value: fromUsageHeaders + ? t('codex_quota.tooltip_source_header') + : t('codex_quota.tooltip_source_api'), + }, + { + key: 'fetched-at', + label: t('codex_quota.tooltip_fetched_at_label'), + value: fetchedAt, + }, + ]; }; const renderCodexWindowInfo = ( quota: CodexQuotaState, window: CodexQuotaWindow, windowLabel: string, - usedPercent: number | null, - remainingPercent: number | null, t: TFunction, styleMap: QuotaRenderHelpers['styles'] ): ReactNode => { if (!CODEX_INFO_WINDOW_IDS.has(window.id)) return null; const { createElement: h } = React; - const rows = buildCodexWindowTooltipRows( - quota, - window, - windowLabel, - usedPercent, - remainingPercent, - t - ); + const rows = buildCodexWindowTooltipRows(quota, t); return h( 'span', @@ -831,8 +889,6 @@ const renderCodexItems = ( quota, window, windowLabel, - clampedUsed, - remaining, t, styleMap ); @@ -983,15 +1039,22 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig state.antigravityQuota, storeSetter: 'setAntigravityQuota', - buildLoadingState: () => ({ status: 'loading', groups: [], serverTimeOffsetMs: null }), + buildLoadingState: () => ({ + status: 'loading', + groups: [], + subscription: null, + serverTimeOffsetMs: null, + }), buildSuccessState: (data) => ({ status: 'success', groups: data.groups, + subscription: data.subscription ?? null, serverTimeOffsetMs: data.serverTimeOffsetMs, }), buildErrorState: (message, status) => ({ status: 'error', groups: [], + subscription: null, serverTimeOffsetMs: null, error: message, errorStatus: status, @@ -1003,10 +1066,7 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig = { +export const CODEX_CONFIG: QuotaConfig = { type: 'codex', i18nPrefix: 'codex_quota', cardIdleMessageKey: 'quota_management.card_idle_hint', @@ -1093,7 +1153,6 @@ const renderKimiItems = ( 'div', { className: styleMap.quotaMeta }, h('span', { className: styleMap.quotaPercent }, percentLabel), - limit > 0 ? h('span', { className: styleMap.quotaAmount }, `${used} / ${limit}`) : null, resetLabel ? h('span', { className: styleMap.quotaReset }, resetLabel) : null ) ), @@ -1134,6 +1193,22 @@ const formatXaiCurrency = (value: number | null): string => { return `$${(value / 100).toFixed(2)}`; }; +const formatXaiRemainingAmount = (billing: XaiBillingSummary): string => { + const remainingCents = + billing.monthlyLimitCents !== null && billing.includedUsedCents !== null + ? Math.max(0, billing.monthlyLimitCents - billing.includedUsedCents) + : null; + return `${formatXaiCurrency(remainingCents)} / ${formatXaiCurrency(billing.monthlyLimitCents)}`; +}; + +const formatXaiOnDemandAmount = (billing: XaiBillingSummary): string => { + const remainingCents = + billing.onDemandCapCents !== null && billing.onDemandUsedCents !== null + ? Math.max(0, billing.onDemandCapCents - billing.onDemandUsedCents) + : null; + return `${formatXaiCurrency(remainingCents)} / ${formatXaiCurrency(billing.onDemandCapCents)}`; +}; + const XAI_SUPERGROK_LIMIT_CENTS = 15_000; const XAI_SUPERGROK_HEAVY_LIMIT_CENTS = 150_000; @@ -1162,21 +1237,24 @@ const renderXaiItems = ( return h('div', { className: styleMap.quotaMessage }, t('xai_quota.empty_data')); } - const usedPercent = billing.usedPercent; - const clampedUsed = usedPercent === null ? null : Math.max(0, Math.min(100, usedPercent)); + const clampedUsed = + billing.usedPercent === null ? null : Math.max(0, Math.min(100, billing.usedPercent)); const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed)); const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`; - const remainingCents = - billing.monthlyLimitCents !== null && billing.usedCents !== null - ? Math.max(0, billing.monthlyLimitCents - billing.usedCents) - : null; - const amountLabel = t('xai_quota.usage_amount', { - remaining: formatXaiCurrency(remainingCents), - limit: formatXaiCurrency(billing.monthlyLimitCents), - }); + const amountLabel = formatXaiRemainingAmount(billing); const resetLabel = billing.billingPeriodEnd ? formatQuotaResetTime(billing.billingPeriodEnd) : t('xai_quota.reset_unknown'); + const onDemandCap = billing.onDemandCapCents ?? 0; + const clampedOnDemandUsed = + billing.onDemandUsedPercent === null + ? null + : Math.max(0, Math.min(100, billing.onDemandUsedPercent)); + const onDemandRemaining = + clampedOnDemandUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedOnDemandUsed)); + const onDemandPercentLabel = + onDemandRemaining === null ? '--' : `${Math.round(onDemandRemaining)}%`; + const onDemandAmountLabel = formatXaiOnDemandAmount(billing); const plan = resolveXaiPlan(billing.monthlyLimitCents); const nodes: ReactNode[] = [ @@ -1192,13 +1270,40 @@ const renderXaiItems = ( ) ) : null, + onDemandCap > 0 + ? h( + 'div', + { key: 'pay-as-you-go', className: styleMap.quotaRow }, + h( + 'div', + { className: styleMap.quotaRowHeader }, + h('span', { className: styleMap.quotaModel }, t('xai_quota.pay_as_you_go_label')), + h( + 'div', + { className: styleMap.quotaMeta }, + h('span', { className: styleMap.quotaPercent }, onDemandPercentLabel), + h('span', { className: styleMap.quotaAmount }, onDemandAmountLabel) + ) + ), + h(QuotaProgressBar, { + percent: onDemandRemaining, + highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD, + mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD, + }) + ) + : h( + 'div', + { key: 'pay-as-you-go', className: styleMap.codexPlan }, + h('span', { className: styleMap.codexPlanLabel }, t('xai_quota.pay_as_you_go_label')), + h('span', { className: styleMap.codexPlanValue }, t('xai_quota.pay_as_you_go_disabled')) + ), h( 'div', { key: 'billing', className: styleMap.quotaRow }, h( 'div', { className: styleMap.quotaRowHeader }, - h('span', { className: styleMap.quotaModel }, t('xai_quota.monthly_limit')), + h('span', { className: styleMap.quotaModel }, t('xai_quota.monthly_credits')), h( 'div', { className: styleMap.quotaMeta }, @@ -1215,21 +1320,6 @@ const renderXaiItems = ( ), ]; - if (billing.onDemandCapCents !== null) { - nodes.push( - h( - 'div', - { key: 'on-demand-cap', className: styleMap.codexPlan }, - h('span', { className: styleMap.codexPlanLabel }, t('xai_quota.on_demand_cap')), - h( - 'span', - { className: styleMap.codexPlanValue }, - formatXaiCurrency(billing.onDemandCapCents) - ) - ) - ); - } - return h(React.Fragment, null, ...nodes); }; diff --git a/apps/web/src/components/quota/useQuotaLoader.ts b/apps/web/src/components/quota/useQuotaLoader.ts index 095599175..3ff8231f6 100644 --- a/apps/web/src/components/quota/useQuotaLoader.ts +++ b/apps/web/src/components/quota/useQuotaLoader.ts @@ -29,6 +29,32 @@ interface LoadQuotaResult { errorStatus?: number; } +const DEFAULT_QUOTA_REFRESH_CONCURRENCY = 4; + +async function runWithConcurrencyLimit( + items: TInput[], + concurrency: number, + worker: (item: TInput, index: number) => Promise +): Promise { + if (items.length === 0) return []; + + const limit = Math.max(1, Math.min(concurrency, items.length)); + const results: TOutput[] = []; + let nextIndex = 0; + + await Promise.all( + Array.from({ length: limit }, async () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await worker(items[currentIndex], currentIndex); + } + }) + ); + + return results; +} + export function useQuotaLoader(config: QuotaConfig) { const { t } = useTranslation(); const quota = useQuotaStore(config.storeSelector); @@ -64,8 +90,10 @@ export function useQuotaLoader(config: QuotaConfig return nextState; }); - const results = await Promise.all( - targets.map(async (file): Promise> => { + const results = await runWithConcurrencyLimit( + targets, + DEFAULT_QUOTA_REFRESH_CONCURRENCY, + async (file): Promise> => { const storeKey = getQuotaStoreKey(config, file); try { const data = await config.fetchQuota(file, t); @@ -75,7 +103,7 @@ export function useQuotaLoader(config: QuotaConfig const errorStatus = getStatusFromError(err); return { storeKey, file, status: 'error', error: message, errorStatus }; } - }) + } ); if (requestId !== requestIdRef.current) return; diff --git a/apps/web/src/features/aiProviders/AiProvidersPage.tsx b/apps/web/src/features/aiProviders/AiProvidersPage.tsx index 3b0ac1b31..8d0a386f7 100644 --- a/apps/web/src/features/aiProviders/AiProvidersPage.tsx +++ b/apps/web/src/features/aiProviders/AiProvidersPage.tsx @@ -802,6 +802,130 @@ export function AiProvidersPage() { } }; + const setProviderPriority = async (row: ProviderRow, priority: number) => { + const nextPriority = Math.trunc(priority); + const switchingKey = `${row.key}:priority`; + + // 复用页面级切换锁,避免外层快捷优先级与抽屉保存、开关切换并发写入。 + if (row.kind === 'gemini') { + const current = geminiKeys[row.originalIndex]; + if (!current || current.priority === nextPriority) return; + + setConfigSwitchingKey(switchingKey); + const previousList = geminiKeys; + const nextList = previousList.map((item, idx) => + idx === row.originalIndex ? { ...item, priority: nextPriority } : item + ); + + setGeminiKeys(nextList); + updateConfigValue('gemini-api-key', nextList); + clearCache('gemini-api-key'); + + try { + await providersApi.saveGeminiKeys(nextList); + showNotification(t('notification.gemini_key_updated'), 'success'); + } catch (err: unknown) { + const message = getErrorMessage(err); + setGeminiKeys(previousList); + updateConfigValue('gemini-api-key', previousList); + clearCache('gemini-api-key'); + showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); + } finally { + setConfigSwitchingKey(null); + } + return; + } + + if (row.kind === 'openai') { + const current = openaiProviders[row.originalIndex]; + if (!current || current.priority === nextPriority) return; + + setConfigSwitchingKey(switchingKey); + const previousList = openaiProviders; + const nextList = previousList.map((item, idx) => + idx === row.originalIndex ? { ...item, priority: nextPriority } : item + ); + + setOpenaiProviders(nextList); + updateConfigValue('openai-compatibility', nextList); + clearCache('openai-compatibility'); + + try { + await providersApi.saveOpenAIProviders(nextList); + showNotification(t('notification.openai_provider_updated'), 'success'); + } catch (err: unknown) { + const message = getErrorMessage(err); + setOpenaiProviders(previousList); + updateConfigValue('openai-compatibility', previousList); + clearCache('openai-compatibility'); + showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); + } finally { + setConfigSwitchingKey(null); + } + return; + } + + const source = + row.kind === 'codex' + ? codexConfigs + : row.kind === 'claude' + ? claudeConfigs + : vertexConfigs; + const current = source[row.originalIndex]; + if (!current || current.priority === nextPriority) return; + + setConfigSwitchingKey(switchingKey); + const previousList = source; + const nextList = previousList.map((item, idx) => + idx === row.originalIndex ? { ...item, priority: nextPriority } : item + ); + + if (row.kind === 'codex') { + setCodexConfigs(nextList); + updateConfigValue('codex-api-key', nextList); + clearCache('codex-api-key'); + } else if (row.kind === 'claude') { + setClaudeConfigs(nextList); + updateConfigValue('claude-api-key', nextList); + clearCache('claude-api-key'); + } else { + setVertexConfigs(nextList); + updateConfigValue('vertex-api-key', nextList); + clearCache('vertex-api-key'); + } + + try { + if (row.kind === 'codex') { + await providersApi.saveCodexConfigs(nextList); + showNotification(t('notification.codex_config_updated'), 'success'); + } else if (row.kind === 'claude') { + await providersApi.saveClaudeConfigs(nextList); + showNotification(t('notification.claude_config_updated'), 'success'); + } else { + await providersApi.saveVertexConfigs(nextList); + showNotification(t('notification.vertex_config_updated'), 'success'); + } + } catch (err: unknown) { + const message = getErrorMessage(err); + if (row.kind === 'codex') { + setCodexConfigs(previousList); + updateConfigValue('codex-api-key', previousList); + clearCache('codex-api-key'); + } else if (row.kind === 'claude') { + setClaudeConfigs(previousList); + updateConfigValue('claude-api-key', previousList); + clearCache('claude-api-key'); + } else { + setVertexConfigs(previousList); + updateConfigValue('vertex-api-key', previousList); + clearCache('vertex-api-key'); + } + showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); + } finally { + setConfigSwitchingKey(null); + } + }; + // 删除(按 provider 分派,沿用既有 API 契约) const deleteGemini = (index: number) => { const entry = geminiKeys[index]; @@ -942,6 +1066,10 @@ export function AiProvidersPage() { void setProviderDisableCoolingEnabled(row.kind, row.originalIndex, enabled); }; + const handleRowPriorityChange = (row: ProviderRow, priority: number) => { + void setProviderPriority(row, priority); + }; + const handleRowEdit = (row: ProviderRow) => { setDetailRowKey(null); openEditorDrawer(row.kind, row.originalIndex); @@ -1041,6 +1169,7 @@ export function AiProvidersPage() { onEdit={handleRowEdit} onDelete={handleRowDelete} onToggle={handleRowToggle} + onPriorityChange={handleRowPriorityChange} /> {visibleRows.length > 0 && (visibleRows.length > PROVIDER_TABLE_DEFAULT_PAGE_SIZE || diff --git a/apps/web/src/features/authFiles/AuthFilesPage.quotaCooldown.test.tsx b/apps/web/src/features/authFiles/AuthFilesPage.quotaCooldown.test.tsx index cd4ab589f..f8ff11e4f 100644 --- a/apps/web/src/features/authFiles/AuthFilesPage.quotaCooldown.test.tsx +++ b/apps/web/src/features/authFiles/AuthFilesPage.quotaCooldown.test.tsx @@ -22,6 +22,9 @@ const { mocks } = vi.hoisted(() => { getCodexInspectionRun: vi.fn(), getActiveQuotaCooldowns: vi.fn(), getHeaderSnapshots: vi.fn(), + apiCallRequest: vi.fn(), + setCodexQuota: vi.fn(), + intervalCallbacks: [] as Array<{ callback: () => void; delay: number | null }>, codexQuota: {} as Record, panelFeatureAvailability: { checking: false, @@ -62,7 +65,9 @@ vi.mock('motion/mini', () => ({ })); vi.mock('@/hooks/useInterval', () => ({ - useInterval: () => {}, + useInterval: (callback: () => void, delay: number | null) => { + mocks.intervalCallbacks.push({ callback, delay }); + }, })); vi.mock('@/hooks/useHeaderRefresh', () => ({ @@ -98,6 +103,13 @@ vi.mock('@/services/api/usageService', () => ({ }, })); +vi.mock('@/services/api/apiCall', () => ({ + apiCallApi: { + request: mocks.apiCallRequest, + }, + getApiCallErrorMessage: () => 'api call failed', +})); + vi.mock('@/stores', () => ({ useNotificationStore: ( selector?: (state: { @@ -125,8 +137,16 @@ vi.mock('@/stores', () => ({ }), useThemeStore: (selector: (state: { resolvedTheme: 'dark' }) => unknown) => selector({ resolvedTheme: 'dark' }), - useQuotaStore: (selector: (state: { codexQuota: Record }) => unknown) => - selector({ codexQuota: mocks.codexQuota }), + useQuotaStore: ( + selector: (state: { + codexQuota: Record; + setCodexQuota: typeof mocks.setCodexQuota; + }) => unknown + ) => + selector({ + codexQuota: mocks.codexQuota, + setCodexQuota: mocks.setCodexQuota, + }), })); vi.mock('@/features/authFiles/hooks/useAuthFilesData', () => ({ @@ -228,7 +248,7 @@ vi.mock('@/features/authFiles/uiState', () => ({ vi.mock('@/features/authFiles/components/AuthFileCard', () => ({ AuthFileCard: (props: { - file: { name: string }; + file: { name: string; authIndex?: unknown; auth_index?: unknown }; quotaCooldown?: { authFileName: string; recoverAtMs: number } | null; codexDisplayQuota?: { status?: string; @@ -249,6 +269,7 @@ vi.mock('@/features/authFiles/components/AuthFileCard', () => ({ return (
{ }; // A controllable promise so a test can resolve a cooldown fetch at a chosen -// moment — used to cover the "request in flight, context changes, stale +// moment; used to cover the "request in flight, context changes, stale // response lands" race. const createDeferred = () => { let resolve!: (value: unknown[]) => void; @@ -345,7 +366,17 @@ describe('AuthFilesPage quota cooldown derived badge', () => { mocks.listCodexInspectionRuns.mockReset(); mocks.getCodexInspectionRun.mockReset(); mocks.getHeaderSnapshots.mockReset(); + mocks.apiCallRequest.mockReset(); + mocks.intervalCallbacks = []; mocks.codexQuota = {}; + mocks.setCodexQuota = vi.fn((updater: unknown) => { + mocks.codexQuota = + typeof updater === 'function' + ? (updater as (prev: Record) => Record)( + mocks.codexQuota + ) + : (updater as Record); + }); mocks.connectionStatus = 'connected'; mocks.managementKey = 'test-key'; mocks.pageTransitionStatus = 'current'; @@ -444,6 +475,433 @@ describe('AuthFilesPage quota cooldown derived badge', () => { expect(card.props['data-codex-quota-window-seconds']).toBe('2592000'); }); + it('refreshes Codex quota instead of showing expired usage response headers', async () => { + mocks.list.mockReturnValue([ + { name: 'codex-one.json', type: 'codex', authIndex: '0' }, + { name: 'codex-two.json', type: 'codex' }, + ]); + mocks.getHeaderSnapshots.mockResolvedValue({ + generated_at_ms: 1_700_018_000_001, + from_ms: 1_700_000_000_000, + to_ms: 1_700_018_000_001, + items: [ + { + event_hash: 'event-expired', + timestamp_ms: 1_700_000_000_000, + auth_file_snapshot: 'codex-one.json', + auth_index: '0', + auth_provider_snapshot: 'codex', + response_metadata: { + quota: { + plan_type: 'free', + rate_limit_reached_type: 'primary', + reached_window_kind: 'five_hour', + reached_window_source: 'primary', + primary: { + used_percent: 100, + reset_at_ms: 1_700_018_000_000, + window_minutes: 300, + }, + secondary: { + used_percent: 84, + reset_at_ms: 1_700_604_800_000, + window_minutes: 10_080, + }, + }, + }, + }, + ], + }); + mocks.apiCallRequest + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 12, + reset_after_seconds: 12_000, + limit_window_seconds: 18_000, + }, + }, + }), + }) + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ available_count: 1, credits: [] }), + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await vi.waitFor(() => { + expect(mocks.apiCallRequest).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + renderer!.update(); + }); + + const card = renderer!.root.findByProps({ 'data-auth-card': 'codex-one.json' }); + expect(card.props['data-codex-quota-status']).toBe('success'); + expect(card.props['data-codex-quota-observed']).toBe('false'); + expect(card.props['data-codex-quota-plan']).toBe('plus'); + expect(card.props['data-codex-quota-window-percent']).toBe('12'); + }); + + it('refreshes Codex quota when a CPAMP cooldown recovers', async () => { + const recoveredAtMs = Date.now() - 1_000; + mocks.list.mockReturnValue([ + { name: 'codex-one.json', type: 'codex', authIndex: '0' }, + { name: 'codex-two.json', type: 'codex' }, + ]); + mocks.getActiveQuotaCooldowns + .mockResolvedValueOnce([ + { + authFileName: 'codex-one.json', + authIndex: '0', + owner: 'cpamp_usage_429', + recoverAtMs: recoveredAtMs, + }, + ]) + .mockResolvedValueOnce([]); + mocks.apiCallRequest + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 12, + reset_after_seconds: 12_000, + limit_window_seconds: 18_000, + }, + }, + rate_limit_reset_credits: { available_count: 1 }, + }), + }) + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ available_count: 1, credits: [] }), + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await vi.waitFor(() => { + expect( + renderer!.root.findByProps({ 'data-auth-card': 'codex-one.json' }).props[ + 'data-quota-cooldown' + ] + ).toBe(`codex-one.json@${recoveredAtMs}`); + }); + + const quotaInterval = mocks.intervalCallbacks.find((item) => item.delay === 60_000); + await act(async () => { + quotaInterval?.callback(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(mocks.apiCallRequest).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + renderer!.update(); + }); + + const card = renderer!.root.findByProps({ 'data-auth-card': 'codex-one.json' }); + expect(card.props['data-quota-cooldown']).toBe(''); + expect(card.props['data-codex-quota-status']).toBe('success'); + expect(card.props['data-codex-quota-observed']).toBe('false'); + expect(card.props['data-codex-quota-plan']).toBe('plus'); + expect(card.props['data-codex-quota-window-percent']).toBe('12'); + expect(card.props['data-codex-quota-window-seconds']).toBe('18000'); + }); + + it('refreshes only the recovered auth index for shared Codex auth files', async () => { + const recoveredAtMs = Date.now() - 1_000; + mocks.list.mockReturnValue([ + { name: 'shared-codex.json', type: 'codex', authIndex: '0' }, + { name: 'shared-codex.json', type: 'codex', authIndex: '1' }, + ]); + mocks.getActiveQuotaCooldowns + .mockResolvedValueOnce([ + { + authFileName: 'shared-codex.json', + authIndex: '1', + owner: 'cpamp_usage_429', + recoverAtMs: recoveredAtMs, + }, + ]) + .mockResolvedValueOnce([]); + mocks.apiCallRequest + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 12, + reset_after_seconds: 12_000, + limit_window_seconds: 18_000, + }, + }, + rate_limit_reset_credits: { available_count: 1 }, + }), + }) + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ available_count: 1, credits: [] }), + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await vi.waitFor(() => { + const cards = renderer!.root.findAllByProps({ 'data-auth-card': 'shared-codex.json' }); + const card0 = cards.find((card) => card.props['data-auth-index'] === '0'); + const card1 = cards.find((card) => card.props['data-auth-index'] === '1'); + expect(card0?.props['data-quota-cooldown']).toBe(''); + expect(card1?.props['data-quota-cooldown']).toBe(`shared-codex.json@${recoveredAtMs}`); + }); + + const quotaInterval = mocks.intervalCallbacks.find((item) => item.delay === 60_000); + await act(async () => { + quotaInterval?.callback(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(mocks.apiCallRequest).toHaveBeenCalledTimes(2); + }); + + expect(mocks.apiCallRequest.mock.calls.map(([payload]) => payload.authIndex)).toEqual([ + '1', + '1', + ]); + + await act(async () => { + renderer!.update(); + }); + + const cards = renderer!.root.findAllByProps({ 'data-auth-card': 'shared-codex.json' }); + const card0 = cards.find((card) => card.props['data-auth-index'] === '0'); + const card1 = cards.find((card) => card.props['data-auth-index'] === '1'); + expect(card0?.props['data-codex-quota-status']).toBe(''); + expect(card1?.props['data-quota-cooldown']).toBe(''); + expect(card1?.props['data-codex-quota-status']).toBe('success'); + expect(card1?.props['data-codex-quota-observed']).toBe('false'); + expect(card1?.props['data-codex-quota-plan']).toBe('plus'); + }); + + it('uses file-only cooldowns for unique Codex auth file rows', async () => { + const recoveredAtMs = Date.now() - 1_000; + mocks.list.mockReturnValue([ + { name: 'legacy-codex.json', type: 'codex', authIndex: '0' }, + { name: 'codex-two.json', type: 'codex' }, + ]); + mocks.getActiveQuotaCooldowns + .mockResolvedValueOnce([ + { + authFileName: 'legacy-codex.json', + owner: 'cpamp_usage_429', + recoverAtMs: recoveredAtMs, + }, + ]) + .mockResolvedValueOnce([]); + mocks.apiCallRequest + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 12, + reset_after_seconds: 12_000, + limit_window_seconds: 18_000, + }, + }, + rate_limit_reset_credits: { available_count: 1 }, + }), + }) + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ available_count: 1, credits: [] }), + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await vi.waitFor(() => { + expect( + renderer!.root.findByProps({ 'data-auth-card': 'legacy-codex.json' }).props[ + 'data-quota-cooldown' + ] + ).toBe(`legacy-codex.json@${recoveredAtMs}`); + }); + + const quotaInterval = mocks.intervalCallbacks.find((item) => item.delay === 60_000); + await act(async () => { + quotaInterval?.callback(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(mocks.apiCallRequest).toHaveBeenCalledTimes(2); + }); + + expect(mocks.apiCallRequest.mock.calls.map(([payload]) => payload.authIndex)).toEqual([ + '0', + '0', + ]); + }); + + it('does not apply file-only cooldowns to shared Codex auth files', async () => { + const recoveredAtMs = Date.now() - 1_000; + mocks.list.mockReturnValue([ + { name: 'shared-codex.json', type: 'codex', authIndex: '0' }, + { name: 'shared-codex.json', type: 'codex', authIndex: '1' }, + ]); + mocks.getActiveQuotaCooldowns + .mockResolvedValueOnce([ + { + authFileName: 'shared-codex.json', + owner: 'cpamp_usage_429', + recoverAtMs: recoveredAtMs, + }, + ]) + .mockResolvedValueOnce([]); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await vi.waitFor(() => { + const cards = renderer!.root.findAllByProps({ 'data-auth-card': 'shared-codex.json' }); + expect(cards).toHaveLength(2); + expect(cards.every((card) => card.props['data-quota-cooldown'] === '')).toBe(true); + }); + + const quotaInterval = mocks.intervalCallbacks.find((item) => item.delay === 60_000); + await act(async () => { + quotaInterval?.callback(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.apiCallRequest).not.toHaveBeenCalled(); + }); + + it('retries expired usage response header refreshes after the next header poll', async () => { + mocks.list.mockReturnValue([{ name: 'codex-one.json', type: 'codex', authIndex: '0' }]); + const buildExpiredHeaderResponse = () => ({ + generated_at_ms: 1_700_018_000_001, + from_ms: 1_700_000_000_000, + to_ms: 1_700_018_000_001, + items: [ + { + event_hash: 'event-expired-retry', + timestamp_ms: 1_700_000_000_000, + auth_file_snapshot: 'codex-one.json', + auth_index: '0', + auth_provider_snapshot: 'codex', + response_metadata: { + quota: { + plan_type: 'free', + rate_limit_reached_type: 'primary', + reached_window_kind: 'five_hour', + reached_window_source: 'primary', + primary: { + used_percent: 100, + reset_at_ms: 1_700_018_000_000, + window_minutes: 300, + }, + }, + }, + }, + ], + }); + mocks.getHeaderSnapshots.mockImplementation(async () => buildExpiredHeaderResponse()); + mocks.apiCallRequest + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 12, + reset_after_seconds: 12_000, + limit_window_seconds: 18_000, + }, + }, + }), + }) + .mockResolvedValueOnce({ + statusCode: 200, + body: JSON.stringify({ available_count: 1, credits: [] }), + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await vi.waitFor(() => { + expect( + Object.values(mocks.codexQuota).some( + (quota) => + quota !== null && + typeof quota === 'object' && + 'status' in quota && + quota.status === 'error' + ) + ).toBe(true); + }); + expect(mocks.apiCallRequest).toHaveBeenCalledTimes(1); + + await act(async () => { + renderer!.update(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.apiCallRequest).toHaveBeenCalledTimes(1); + + const quotaInterval = mocks.intervalCallbacks.find((item) => item.delay === 60_000); + await act(async () => { + quotaInterval?.callback(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(mocks.apiCallRequest).toHaveBeenCalledTimes(3); + }); + + await act(async () => { + renderer!.update(); + }); + + const card = renderer!.root.findByProps({ 'data-auth-card': 'codex-one.json' }); + expect(card.props['data-codex-quota-status']).toBe('success'); + expect(card.props['data-codex-quota-observed']).toBe('false'); + expect(card.props['data-codex-quota-plan']).toBe('plus'); + }); + it('merges observed Codex header quota without clearing stored quota-only fields', async () => { mocks.codexQuota = { 'codex-one.json::-': { @@ -662,6 +1120,49 @@ describe('AuthFilesPage quota cooldown derived badge', () => { expect(card.props['data-codex-quota-window-ids']).toBe('five-hour,spark-five-hour-0'); }); + it('does not treat cooldown context changes as recovered cooldowns', async () => { + const recoveredAtMs = Date.now() - 1_000; + mocks.list.mockReturnValue([{ name: 'codex-one.json', type: 'codex', authIndex: '0' }]); + mocks.getActiveQuotaCooldowns + .mockResolvedValueOnce([ + { + authFileName: 'codex-one.json', + authIndex: '0', + owner: 'cpamp_usage_429', + recoverAtMs: recoveredAtMs, + }, + ]) + .mockResolvedValueOnce([]); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await vi.waitFor(() => { + expect( + renderer!.root.findByProps({ 'data-auth-card': 'codex-one.json' }).props[ + 'data-quota-cooldown' + ] + ).toBe(`codex-one.json@${recoveredAtMs}`); + }); + + setManagerServiceBase('http://manager-two.local:18317'); + await act(async () => { + renderer!.update(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(mocks.getActiveQuotaCooldowns).toHaveBeenCalledWith( + 'http://manager-two.local:18317', + 'test-key' + ); + }); + expect(mocks.apiCallRequest).not.toHaveBeenCalled(); + }); + it('clears stale cooldowns when managerServiceBase becomes empty', async () => { mocks.getActiveQuotaCooldowns.mockResolvedValue([ { authFileName: 'codex-one.json', recoverAtMs: 2_000_000_000_000 }, @@ -733,11 +1234,9 @@ describe('AuthFilesPage quota cooldown derived badge', () => { renderer!.update(); }); - // The stale response finally lands — it must not resurrect the badge. + // The stale response finally lands; it must not resurrect the badge. await act(async () => { - deferred.resolve([ - { authFileName: 'codex-one.json', recoverAtMs: 2_000_000_000_000 }, - ]); + deferred.resolve([{ authFileName: 'codex-one.json', recoverAtMs: 2_000_000_000_000 }]); await deferred.promise.catch(() => {}); }); @@ -775,19 +1274,15 @@ describe('AuthFilesPage quota cooldown derived badge', () => { expect(mocks.getActiveQuotaCooldowns).toHaveBeenCalledTimes(2); - // The new-context request resolves first with its own data — applied. + // The new-context request resolves first with its own data; applied. await act(async () => { - second.resolve([ - { authFileName: 'codex-two.json', recoverAtMs: 1_700_000_000_000 }, - ]); + second.resolve([{ authFileName: 'codex-two.json', recoverAtMs: 1_700_000_000_000 }]); await second.promise.catch(() => {}); }); // The stale (old-key) response lands afterwards and must be ignored. await act(async () => { - first.resolve([ - { authFileName: 'codex-one.json', recoverAtMs: 2_000_000_000_000 }, - ]); + first.resolve([{ authFileName: 'codex-one.json', recoverAtMs: 2_000_000_000_000 }]); await first.promise.catch(() => {}); }); @@ -828,12 +1323,10 @@ describe('AuthFilesPage quota cooldown derived badge', () => { }); expect(mocks.getActiveQuotaCooldowns).toHaveBeenCalledTimes(1); - // The old-context response lands before a new loader runs — it must be + // The old-context response lands before a new loader runs; it must be // dropped by the layout-effect invalidation alone. await act(async () => { - first.resolve([ - { authFileName: 'codex-one.json', recoverAtMs: 2_000_000_000_000 }, - ]); + first.resolve([{ authFileName: 'codex-one.json', recoverAtMs: 2_000_000_000_000 }]); await first.promise.catch(() => {}); }); diff --git a/apps/web/src/features/authFiles/AuthFilesPage.tsx b/apps/web/src/features/authFiles/AuthFilesPage.tsx index 8fe72b5b0..4b27e16ab 100644 --- a/apps/web/src/features/authFiles/AuthFilesPage.tsx +++ b/apps/web/src/features/authFiles/AuthFilesPage.tsx @@ -24,9 +24,14 @@ import { Select } from '@/components/ui/Select'; import { IconFilterAll, IconSearch } from '@/components/ui/icons'; import { EmptyState } from '@/components/ui/EmptyState'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; -import { buildObservedCodexQuotaState, resolveQuotaDisplayState } from '@/components/quota'; +import { + CODEX_CONFIG, + buildObservedCodexQuotaState, + getQuotaStoreKey, + resolveQuotaDisplayState, +} from '@/components/quota'; import { copyToClipboard } from '@/utils/clipboard'; -import { resolveAuthProvider } from '@/utils/quota'; +import { getStatusFromError, resolveAuthProvider } from '@/utils/quota'; import { MAX_CARD_PAGE_SIZE, MIN_CARD_PAGE_SIZE, @@ -64,6 +69,7 @@ import { import { buildUsageHeaderSnapshotLookup, getHighConfidenceUsageHeaderSnapshotForAuthFile, + isUsageHeaderQuotaSnapshotExpired, } from '@/utils/usageHeaderSnapshots'; import { useAuthFilesData } from '@/features/authFiles/hooks/useAuthFilesData'; import { useAuthFilesModels } from '@/features/authFiles/hooks/useAuthFilesModels'; @@ -174,6 +180,14 @@ const isStaleCodexReauthSnapshot = (item: AuthFileCodexInspectionSnapshot): bool return action === 'reauth' || statusCode === 401; }; +type QuotaCooldownState = { + contextKey: string; + items: Map; +}; + +const getQuotaCooldownContextKey = (managerServiceBase: string, managementKey: string): string => + `${managerServiceBase}\u0000${managementKey}`; + export function AuthFilesPage() { const { t } = useTranslation(); const showNotification = useNotificationStore((state) => state.showNotification); @@ -182,6 +196,7 @@ export function AuthFilesPage() { const managementKey = useAuthStore((state) => state.managementKey); const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); const codexQuota = useQuotaStore((state) => state.codexQuota); + const setCodexQuota = useQuotaStore((state) => state.setCodexQuota); const featureAvailability = usePanelFeatureAvailability(); const managerServiceBase = featureAvailability.managerServiceBase; const pageTransitionLayer = usePageTransitionLayer(); @@ -213,17 +228,28 @@ export function AuthFilesPage() { const [lastCodexInspectionResults, setLastCodexInspectionResults] = useState< AuthFileCodexInspectionSnapshot[] >([]); - const [quotaCooldowns, setQuotaCooldowns] = useState>( - () => new Map() - ); + const [quotaCooldownState, setQuotaCooldownState] = useState(() => ({ + contextKey: getQuotaCooldownContextKey(managerServiceBase, managementKey), + items: new Map(), + })); + const quotaCooldowns = quotaCooldownState.items; const [headerSnapshots, setHeaderSnapshots] = useState([]); + const [headerSnapshotGeneratedAtMs, setHeaderSnapshotGeneratedAtMs] = useState(0); const floatingBatchActionsRef = useRef(null); const batchActionAnimationRef = useRef(null); const previousSelectionCountRef = useRef(0); const selectionCountRef = useRef(0); + const quotaCooldownsRef = useRef({ + contextKey: getQuotaCooldownContextKey(managerServiceBase, managementKey), + items: new Map(), + }); + const pendingRecoveredCodexQuotaRefreshRef = useRef>(new Set()); + const autoRefreshingCodexQuotaRef = useRef>(new Set()); + const expiredHeaderCodexQuotaRefreshRef = useRef>(new Set()); + const skipNextRecoveredCooldownRefreshRef = useRef(false); // Generation token for in-flight cooldown fetches. Every fetch and every // context identity change bump it, so a slow, superseded response can be - // detected and dropped — otherwise it would re-introduce stale badges after + // detected and dropped; otherwise it would re-introduce stale badges after // the old context was invalidated. const cooldownReqId = useRef(0); const headerSnapshotReqId = useRef(0); @@ -231,6 +257,7 @@ export function AuthFilesPage() { // transitions synchronously (before passive effects fire) and invalidate any // in-flight request that belongs to the old context. const cooldownContextRef = useRef({ managerServiceBase, managementKey }); + const cooldownRecoveryContextRef = useRef({ managerServiceBase, managementKey }); const headerSnapshotContextRef = useRef({ managerServiceBase, managementKey }); const { @@ -266,6 +293,43 @@ export function AuthFilesPage() { } = useAuthFilesData(); const statusBarCache = useAuthFilesStatusBarCache(files); + const uniqueAuthFileKeyByFallbackCooldownKey = useMemo(() => { + const fallbackEntries = new Map(); + files.forEach((file) => { + if (isRuntimeOnlyAuthFile(file) || resolveAuthProvider(file) !== 'codex') return; + const fallbackKey = getAuthFileCodexInspectionKey(file.name, null); + const authFileKey = getAuthFileCodexInspectionKeyForFile(file); + const existing = fallbackEntries.get(fallbackKey); + if (existing) { + existing.count += 1; + return; + } + fallbackEntries.set(fallbackKey, { authFileKey, count: 1 }); + }); + + const uniqueKeys = new Map(); + fallbackEntries.forEach((entry, fallbackKey) => { + if (entry.count === 1) uniqueKeys.set(fallbackKey, entry.authFileKey); + }); + return uniqueKeys; + }, [files]); + const getQuotaCooldownForFile = useCallback( + (file: AuthFileItem): QuotaCooldownInfo | undefined => { + if (isRuntimeOnlyAuthFile(file) || resolveAuthProvider(file) !== 'codex') { + return undefined; + } + const authFileKey = getAuthFileCodexInspectionKeyForFile(file); + const exactCooldown = quotaCooldowns.get(authFileKey); + if (exactCooldown) return exactCooldown; + + const fallbackKey = getAuthFileCodexInspectionKey(file.name, null); + if (uniqueAuthFileKeyByFallbackCooldownKey.get(fallbackKey) !== authFileKey) { + return undefined; + } + return quotaCooldowns.get(fallbackKey); + }, + [quotaCooldowns, uniqueAuthFileKeyByFallbackCooldownKey] + ); const { excluded, @@ -577,6 +641,7 @@ export function AuthFilesPage() { // invalidation can supersede it. If the generation has changed by the time // we land, we drop the result instead of writing stale badges back. const id = ++cooldownReqId.current; + const contextKey = getQuotaCooldownContextKey(managerServiceBase, managementKey); try { const items = await usageServiceApi.getActiveQuotaCooldowns( managerServiceBase, @@ -586,12 +651,16 @@ export function AuthFilesPage() { const next = new Map(); for (const item of items) { if (!item.authFileName) continue; - const existing = next.get(item.authFileName); + const cooldownKey = getAuthFileCodexInspectionKey( + item.authFileName, + item.authIndex ?? null + ); + const existing = next.get(cooldownKey); if (!existing || (item.recoverAtMs ?? 0) > (existing.recoverAtMs ?? 0)) { - next.set(item.authFileName, item); + next.set(cooldownKey, item); } } - setQuotaCooldowns(next); + setQuotaCooldownState({ contextKey, items: next }); } catch { // The cooldown badge is a derived hint; fail silently and keep the last known state. } @@ -600,6 +669,7 @@ export function AuthFilesPage() { const loadHeaderSnapshots = useCallback(async () => { if (!managerServiceBase) { setHeaderSnapshots([]); + setHeaderSnapshotGeneratedAtMs(0); return; } const id = ++headerSnapshotReqId.current; @@ -613,15 +683,84 @@ export function AuthFilesPage() { } ); if (id !== headerSnapshotReqId.current) return; + const generatedAtMs = + response.generated_at_ms ?? + (response as { generatedAtMs?: number }).generatedAtMs ?? + Date.now(); + expiredHeaderCodexQuotaRefreshRef.current.clear(); setHeaderSnapshots(response.items ?? []); + setHeaderSnapshotGeneratedAtMs(generatedAtMs); } catch { // Header snapshots are passive hints; keep the current page usable if Manager data is unavailable. } }, [managementKey, managerServiceBase]); + const refreshRecoveredCodexQuotaForFile = useCallback( + async (file: AuthFileItem) => { + if (resolveAuthProvider(file) !== 'codex') return false; + if (isRuntimeOnlyAuthFile(file) || file.disabled) return false; + + const authFileKey = getAuthFileCodexInspectionKeyForFile(file); + if (autoRefreshingCodexQuotaRef.current.has(authFileKey)) return false; + autoRefreshingCodexQuotaRef.current.add(authFileKey); + + const storeKey = getQuotaStoreKey(CODEX_CONFIG, file); + const previousQuota = + (codexQuota[storeKey] as CodexQuotaState | undefined) ?? + (codexQuota[file.name] as CodexQuotaState | undefined); + setCodexQuota((prev) => ({ + ...prev, + [storeKey]: CODEX_CONFIG.buildLoadingState(file), + })); + + try { + const data = await CODEX_CONFIG.fetchQuota(file, t); + setCodexQuota((prev) => ({ + ...prev, + [storeKey]: CODEX_CONFIG.buildSuccessState(data, file), + })); + return true; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + const status = getStatusFromError(err); + setCodexQuota((prev) => ({ + ...prev, + [storeKey]: CODEX_CONFIG.buildFailureState + ? CODEX_CONFIG.buildFailureState(message, status, file, previousQuota, Date.now()) + : CODEX_CONFIG.buildErrorState(message, status, file), + })); + return false; + } finally { + autoRefreshingCodexQuotaRef.current.delete(authFileKey); + } + }, + [codexQuota, setCodexQuota, t] + ); + + const refreshPendingRecoveredCodexQuotas = useCallback(() => { + if (pendingRecoveredCodexQuotaRefreshRef.current.size === 0) return; + + for (const file of files) { + const authFileKey = getAuthFileCodexInspectionKeyForFile(file); + const fallbackKey = getAuthFileCodexInspectionKey(file.name, null); + const hasExactPending = pendingRecoveredCodexQuotaRefreshRef.current.has(authFileKey); + const hasFallbackPending = + fallbackKey !== authFileKey && + pendingRecoveredCodexQuotaRefreshRef.current.has(fallbackKey) && + uniqueAuthFileKeyByFallbackCooldownKey.get(fallbackKey) === authFileKey; + if (!hasExactPending && !hasFallbackPending) continue; + if (file.disabled || resolveAuthProvider(file) !== 'codex' || isRuntimeOnlyAuthFile(file)) { + continue; + } + if (hasExactPending) pendingRecoveredCodexQuotaRefreshRef.current.delete(authFileKey); + if (hasFallbackPending) pendingRecoveredCodexQuotaRefreshRef.current.delete(fallbackKey); + void refreshRecoveredCodexQuotaForFile(file); + } + }, [files, refreshRecoveredCodexQuotaForFile, uniqueAuthFileKeyByFallbackCooldownKey]); + // Synchronously invalidate in-flight cooldown requests when the context // (managerServiceBase or managementKey) changes, regardless of direction - // (A→B, A→empty, empty→A). This runs in the layout phase, before any + // (A to B, A to empty, empty to A). This runs in the layout phase, before any // passive effect that might fire a new loadQuotaCooldowns, so a stale // response that resolves between renders or inside the gap between a // re-render and its passive effects will find its generation token already @@ -633,7 +772,15 @@ export function AuthFilesPage() { } cooldownContextRef.current = { managerServiceBase, managementKey }; cooldownReqId.current += 1; - setQuotaCooldowns((current) => (current.size === 0 ? current : new Map())); + const contextKey = getQuotaCooldownContextKey(managerServiceBase, managementKey); + quotaCooldownsRef.current = { contextKey, items: new Map() }; + pendingRecoveredCodexQuotaRefreshRef.current.clear(); + skipNextRecoveredCooldownRefreshRef.current = true; + setQuotaCooldownState((current) => + current.contextKey === contextKey && current.items.size === 0 + ? current + : { contextKey, items: new Map() } + ); }, [managerServiceBase, managementKey]); useLayoutEffect(() => { @@ -643,7 +790,9 @@ export function AuthFilesPage() { } headerSnapshotContextRef.current = { managerServiceBase, managementKey }; headerSnapshotReqId.current += 1; + expiredHeaderCodexQuotaRefreshRef.current.clear(); setHeaderSnapshots((current) => (current.length === 0 ? current : [])); + setHeaderSnapshotGeneratedAtMs(0); }, [managerServiceBase, managementKey]); useEffect(() => { @@ -660,6 +809,54 @@ export function AuthFilesPage() { isCurrentLayer && managerServiceBase ? 60_000 : null ); + useEffect(() => { + const previous = quotaCooldownsRef.current; + const currentContextKey = getQuotaCooldownContextKey(managerServiceBase, managementKey); + const previousContext = cooldownRecoveryContextRef.current; + const contextChanged = + previousContext.managerServiceBase !== managerServiceBase || + previousContext.managementKey !== managementKey; + cooldownRecoveryContextRef.current = { managerServiceBase, managementKey }; + quotaCooldownsRef.current = quotaCooldownState; + if ( + contextChanged || + previous.contextKey !== quotaCooldownState.contextKey || + quotaCooldownState.contextKey !== currentContextKey || + skipNextRecoveredCooldownRefreshRef.current + ) { + skipNextRecoveredCooldownRefreshRef.current = false; + pendingRecoveredCodexQuotaRefreshRef.current.clear(); + return; + } + if (!isCurrentLayer || !managerServiceBase) return; + + const nowMs = Date.now(); + let hasRecovered = false; + previous.items.forEach((item, authFileKey) => { + if (quotaCooldowns.has(authFileKey)) return; + if (item.owner && item.owner !== 'cpamp_usage_429') return; + if (item.recoverAtMs > nowMs + 60_000) return; + pendingRecoveredCodexQuotaRefreshRef.current.add(authFileKey); + hasRecovered = true; + }); + + if (!hasRecovered) return; + void loadFiles().catch(() => {}); + refreshPendingRecoveredCodexQuotas(); + }, [ + isCurrentLayer, + loadFiles, + managerServiceBase, + managementKey, + quotaCooldowns, + quotaCooldownState, + refreshPendingRecoveredCodexQuotas, + ]); + + useEffect(() => { + refreshPendingRecoveredCodexQuotas(); + }, [files, refreshPendingRecoveredCodexQuotas]); + const existingTypes = useMemo(() => { const types = new Set(['all']); files.forEach((file) => { @@ -683,37 +880,81 @@ export function AuthFilesPage() { (file: AuthFileItem): CodexQuotaState | undefined => { if (resolveAuthProvider(file) !== 'codex') return undefined; const storeKey = getAuthFileCodexInspectionKeyForFile(file); - return getAuthFileScopedCodexQuota( - file, - codexQuota[storeKey] ?? codexQuota[file.name] - ); + return getAuthFileScopedCodexQuota(file, codexQuota[storeKey] ?? codexQuota[file.name]); }, [codexQuota] ); + useEffect(() => { + if (!isCurrentLayer || !managerServiceBase) return; + const nowMs = headerSnapshotGeneratedAtMs || Date.now(); + for (const file of files) { + if (resolveAuthProvider(file) !== 'codex' || isRuntimeOnlyAuthFile(file) || file.disabled) { + continue; + } + const headerSnapshot = getHighConfidenceUsageHeaderSnapshotForAuthFile( + headerSnapshotLookup, + file + ); + if (!isUsageHeaderQuotaSnapshotExpired(headerSnapshot, nowMs)) continue; + + const activeQuota = getActiveCodexQuota(file); + const fetchedAtMs = + activeQuota?.status === 'success' && typeof activeQuota.fetchedAtMs === 'number' + ? activeQuota.fetchedAtMs + : 0; + const headerAtMs = + typeof headerSnapshot?.timestamp_ms === 'number' ? headerSnapshot.timestamp_ms : 0; + if (fetchedAtMs > headerAtMs) continue; + + const authFileKey = getAuthFileCodexInspectionKeyForFile(file); + const marker = `${authFileKey}:${headerSnapshot?.event_hash ?? ''}:${headerAtMs}`; + if (expiredHeaderCodexQuotaRefreshRef.current.has(marker)) continue; + expiredHeaderCodexQuotaRefreshRef.current.add(marker); + void refreshRecoveredCodexQuotaForFile(file); + } + }, [ + files, + getActiveCodexQuota, + headerSnapshotGeneratedAtMs, + headerSnapshotLookup, + isCurrentLayer, + managerServiceBase, + refreshRecoveredCodexQuotaForFile, + ]); + const codexStatusSourcesByAuthFileKey = useMemo(() => { - const sourcesMap = new Map< - string, - ReturnType - >(); + const sourcesMap = new Map>(); files.forEach((file) => { const statusKey = getAuthFileCodexInspectionKeyForFile(file); const headerSnapshot = getHighConfidenceUsageHeaderSnapshotForAuthFile( headerSnapshotLookup, file ); + const freshHeaderSnapshot = isUsageHeaderQuotaSnapshotExpired( + headerSnapshot, + headerSnapshotGeneratedAtMs || Date.now() + ) + ? undefined + : headerSnapshot; sourcesMap.set( statusKey, getFreshAuthFileCodexStatusSources( file, getActiveCodexQuota(file), codexInspectionByAuthFile.get(statusKey), - headerSnapshot + freshHeaderSnapshot ) ); }); return sourcesMap; - }, [codexInspectionByAuthFile, files, getActiveCodexQuota, headerSnapshotLookup]); + }, [ + codexInspectionByAuthFile, + files, + getActiveCodexQuota, + headerSnapshotGeneratedAtMs, + headerSnapshotLookup, + ]); const getDisplayCodexQuota = useCallback( (file: AuthFileItem): CodexQuotaState | undefined => { @@ -857,8 +1098,7 @@ export function AuthFilesPage() { headerSnapshotLookup, item ); - const statusHeaderSnapshot = - codexStatusSourcesByAuthFileKey.get(authFileKey)?.headerSnapshot; + const statusHeaderSnapshot = codexStatusSourcesByAuthFileKey.get(authFileKey)?.headerSnapshot; const matchSearch = !normalizedSearch || stringifySearchValue( @@ -980,9 +1220,7 @@ export function AuthFilesPage() { const selectedWebsocketPatchTargets = useMemo( () => selectedTargetFiles - .filter( - (file) => supportsAuthFileWebsockets(String(file.type ?? file.provider ?? '')) - ) + .filter((file) => supportsAuthFileWebsockets(String(file.type ?? file.provider ?? ''))) .map(getAuthFilePatchTarget), [selectedTargetFiles] ); @@ -1495,7 +1733,7 @@ export function AuthFilesPage() { codexDisplayQuota={getDisplayCodexQuota(file)} antigravitySubscription={antigravitySubscriptions[file.name]} onRefreshAntigravitySubscription={refreshSubscription} - quotaCooldown={quotaCooldowns.get(file.name)} + quotaCooldown={getQuotaCooldownForFile(file)} onShowModels={showModels} onReauth={(targetFile) => setCodexReauthTarget(createCodexReauthTargetFromAuthFile(targetFile)) diff --git a/apps/web/src/features/monitoring/accountOverviewCardMetrics.test.ts b/apps/web/src/features/monitoring/accountOverviewCardMetrics.test.ts index af58fc062..12aeffa93 100644 --- a/apps/web/src/features/monitoring/accountOverviewCardMetrics.test.ts +++ b/apps/web/src/features/monitoring/accountOverviewCardMetrics.test.ts @@ -18,8 +18,6 @@ describe('accountOverviewCardMetrics', () => { 'input-tokens', 'output-tokens', 'cached-tokens', - 'cache-creation-tokens', - 'cache-read-tokens', ]); }); }); diff --git a/apps/web/src/features/monitoring/accountOverviewState.test.ts b/apps/web/src/features/monitoring/accountOverviewState.test.ts index f73a8b4ab..72393608a 100644 --- a/apps/web/src/features/monitoring/accountOverviewState.test.ts +++ b/apps/web/src/features/monitoring/accountOverviewState.test.ts @@ -219,8 +219,6 @@ describe('accountOverviewState', () => { 'input-tokens', 'output-tokens', 'cached-tokens', - 'cache-creation-tokens', - 'cache-read-tokens', ]); }); diff --git a/apps/web/src/features/monitoring/accountOverviewState.ts b/apps/web/src/features/monitoring/accountOverviewState.ts index 3bc74b9dc..085d6619a 100644 --- a/apps/web/src/features/monitoring/accountOverviewState.ts +++ b/apps/web/src/features/monitoring/accountOverviewState.ts @@ -40,8 +40,6 @@ export const ACCOUNT_OVERVIEW_CARD_METRIC_KEYS = [ 'input-tokens', 'output-tokens', 'cached-tokens', - 'cache-creation-tokens', - 'cache-read-tokens', ] as const; const DEFAULT_ACCOUNT_OVERVIEW_CARD_PAGINATION = { page: 1, diff --git a/apps/web/src/features/monitoring/components/AccountOverviewCard.tsx b/apps/web/src/features/monitoring/components/AccountOverviewCard.tsx index f0f3e9d6d..2a5ca0c40 100644 --- a/apps/web/src/features/monitoring/components/AccountOverviewCard.tsx +++ b/apps/web/src/features/monitoring/components/AccountOverviewCard.tsx @@ -34,6 +34,7 @@ import { getAccountStatusLabel, getAccountStatusTone, getSuccessRateClassName, + type AccountQuotaEntry, type AccountQuotaState, type AccountQuotaWindow, type AccountSummaryMetric, @@ -137,20 +138,66 @@ function AccountQuotaPanel({ }) { const quotaEntries = quotaState?.entries ?? []; const quotaLoading = quotaState?.status === 'loading'; - const lastQuotaSync = + const lastQuotaSyncMs = quotaState?.lastRefreshedAt && Number.isFinite(quotaState.lastRefreshedAt) - ? new Date(quotaState.lastRefreshedAt).toLocaleString(locale) - : ''; + ? quotaState.lastRefreshedAt + : undefined; const singleQuotaEntry = quotaEntries.length === 1 ? quotaEntries[0] : null; - const lastSyncLabel = shortLabel(t, 'monitoring.last_sync_short', 'monitoring.last_sync'); - const quotaMetaText = [ - ...(singleQuotaEntry?.metaLabels ?? []), - lastQuotaSync ? `${lastSyncLabel}: ${lastQuotaSync}` : '', - ] - .filter(Boolean) - .join(' · '); - - const renderQuotaWindows = (windows: AccountQuotaWindow[]) => ( + + const buildQuotaInfoRows = (entry: AccountQuotaEntry) => { + const fromUsageHeaders = entry.observedFromUsageHeaders === true; + const timestampMs = fromUsageHeaders + ? entry.observedAtMs + : (entry.fetchedAtMs ?? lastQuotaSyncMs); + const formattedTime = + timestampMs && Number.isFinite(timestampMs) + ? new Date(timestampMs).toLocaleString(locale) + : '--'; + + return [ + { + key: 'source', + label: t('codex_quota.tooltip_source_label'), + value: fromUsageHeaders + ? t('codex_quota.tooltip_source_header') + : t('codex_quota.tooltip_source_api'), + }, + { + key: 'fetched-at', + label: t('codex_quota.tooltip_fetched_at_label'), + value: formattedTime, + }, + ]; + }; + + const renderQuotaInfo = (entry: AccountQuotaEntry, windowLabel: string) => { + const rows = buildQuotaInfoRows(entry); + + return ( + + + ); + }; + + const renderQuotaWindows = (windows: AccountQuotaWindow[], entry: AccountQuotaEntry) => (
{windows.map((window) => { const percentLabel = @@ -163,7 +210,10 @@ function AccountQuotaPanel({ return (
- {window.label} + + {window.label} + {renderQuotaInfo(entry, window.label)} + {percentLabel}
@@ -220,7 +270,6 @@ function AccountQuotaPanel({
{t('monitoring.account_quota_title')} - {quotaMetaText ? {quotaMetaText} : null}
{renderRefreshButton()}
@@ -261,7 +310,7 @@ function AccountQuotaPanel({ true ) ) : singleQuotaEntry.windows.length > 0 ? ( - renderQuotaWindows(singleQuotaEntry.windows) + renderQuotaWindows(singleQuotaEntry.windows, singleQuotaEntry) ) : ( renderStateMessage( singleQuotaEntry.emptyMessage ?? t('monitoring.account_quota_empty'), @@ -271,10 +320,7 @@ function AccountQuotaPanel({ ) : quotaEntries.length > 0 ? (
{quotaEntries.map((entry) => { - const entryMetaText = - entry.metaLabels && entry.metaLabels.length > 0 - ? entry.metaLabels.join(' · ') - : `${entry.providerLabel} · ${entry.fileName}`; + const entryMetaText = `${entry.providerLabel} · ${entry.fileName}`; return (
@@ -291,7 +337,7 @@ function AccountQuotaPanel({ true ) : entry.windows.length > 0 - ? renderQuotaWindows(entry.windows) + ? renderQuotaWindows(entry.windows, entry) : renderStateMessage( entry.emptyMessage ?? t('monitoring.account_quota_empty'), t('monitoring.account_quota_idle') @@ -333,13 +379,7 @@ export function AccountTokenMetricGrid({ if (variant === 'table') { const tokenStructureMetrics = metrics.filter((metric) => - [ - 'input-tokens', - 'output-tokens', - 'cached-tokens', - 'cache-creation-tokens', - 'cache-read-tokens', - ].includes(metric.key) + ['input-tokens', 'output-tokens', 'cached-tokens'].includes(metric.key) ); const getTokenStructureRowToneClassName = (key: string) => { if (key === 'input-tokens') return styles.tokenStructureRowInput; diff --git a/apps/web/src/features/monitoring/components/accountOverviewPresentation.ts b/apps/web/src/features/monitoring/components/accountOverviewPresentation.ts index 0e3cac6a6..92b77cabe 100644 --- a/apps/web/src/features/monitoring/components/accountOverviewPresentation.ts +++ b/apps/web/src/features/monitoring/components/accountOverviewPresentation.ts @@ -28,6 +28,7 @@ export type AccountQuotaEntry = { windows: AccountQuotaWindow[]; error?: string; failedAtMs?: number; + fetchedAtMs?: number; observedAtMs?: number; observedFromUsageHeaders?: boolean; }; diff --git a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts index 5a06e017c..83c8f7e22 100644 --- a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts +++ b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts @@ -4,8 +4,11 @@ import { fetchAntigravityQuota, fetchClaudeQuota, fetchCodexQuota, + fetchKimiQuota, fetchXaiQuota, } from '@/utils/quota'; +import zhCN from '@/i18n/locales/zh-CN.json'; +import zhTW from '@/i18n/locales/zh-TW.json'; import type { MonitoringAccountQuotaTarget } from '@/features/monitoring/accountOverviewQuotaTargets'; import type { MonitoringAccountRow, @@ -57,6 +60,8 @@ const t = ((key: string, options?: Record) => { 'xai_quota.title': 'xAI Quota', 'xai_quota.empty_data': 'No xAI quota data', 'xai_quota.monthly_limit': 'Monthly billing limit', + 'xai_quota.monthly_credits': 'Monthly credits', + 'xai_quota.pay_as_you_go_label': 'Pay-as-you-go', 'xai_quota.on_demand_cap': 'On-demand cap', 'xai_quota.usage_amount': '{{remaining}} / {{limit}} remaining', }; @@ -139,6 +144,36 @@ const createApiKeyRow = (apiKeyHash: string, label: string): MonitoringApiKeyRow }); describe('monitoringCenterPageModel filter options', () => { + it('keeps Chinese compact all-filter labels contextual', () => { + const keys = [ + 'filter_all_accounts_short', + 'filter_all_providers_short', + 'filter_all_models_short', + 'filter_all_channels_short', + 'filter_all_api_keys_short', + 'filter_all_statuses_short', + ] as const; + + expect(keys.map((key) => zhCN.monitoring[key])).toEqual([ + '全部账号', + '全部提供方', + '全部模型', + '全部渠道', + '全部调用方密钥', + '全部状态', + ]); + expect(keys.map((key) => zhTW.monitoring[key])).toEqual([ + '全部帳號', + '全部提供方', + '全部模型', + '全部渠道', + '全部呼叫方密鑰', + '全部狀態', + ]); + expect(new Set(keys.map((key) => zhCN.monitoring[key])).size).toBe(keys.length); + expect(new Set(keys.map((key) => zhTW.monitoring[key])).size).toBe(keys.length); + }); + it('maps usage analytics drilldown query into initial realtime filters', () => { const initialState = { ...getDefaultMonitoringCenterUiState(), @@ -844,14 +879,51 @@ describe('monitoringCenterPageModel account quota', () => { ]); }); + it('maps Kimi quota rows without amount labels in account quota entries', async () => { + vi.mocked(fetchKimiQuota).mockResolvedValue([ + { + id: 'daily', + label: 'Daily', + used: 25, + limit: 100, + resetHint: '2026-07-31T00:00:00Z', + }, + ]); + + const entry = await requestAccountQuota( + createTarget({ + provider: 'kimi', + authIndex: '4', + fileName: 'kimi.json', + }), + t + ); + + expect(entry).toMatchObject({ + provider: 'kimi', + providerLabel: 'Kimi Quota', + windows: [ + { + id: 'daily', + label: 'Daily', + remainingPercent: 75, + usageLabel: null, + }, + ], + }); + }); + it('maps xAI billing into account quota entries', async () => { vi.mocked(fetchXaiQuota).mockResolvedValue({ monthlyLimitCents: 10000, - usedCents: 2500, + usedCents: 12500, + includedUsedCents: 10000, onDemandCapCents: 5000, + onDemandUsedCents: 2500, + onDemandUsedPercent: 50, billingPeriodStart: '2026-05-01T00:00:00Z', billingPeriodEnd: '2026-06-01T00:00:00Z', - usedPercent: 25, + usedPercent: 100, }); const entry = await requestAccountQuota( @@ -870,9 +942,15 @@ describe('monitoringCenterPageModel account quota', () => { windows: [ { id: 'monthly-limit', - label: 'Monthly billing limit', - remainingPercent: 75, - usageLabel: '$75.00 / $100.00 remaining', + label: 'Monthly credits', + remainingPercent: 0, + usageLabel: '$0.00 / $100.00 remaining', + }, + { + id: 'pay-as-you-go', + label: 'Pay-as-you-go', + remainingPercent: 50, + usageLabel: '$25.00 / $50.00 remaining', }, ], }); diff --git a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts index 055fc586e..dd992e32e 100644 --- a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts +++ b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts @@ -890,6 +890,7 @@ export const mergeObservedAccountQuotaEntry = ( planType: observedEntry.planType ?? mergeableActiveEntry.planType, metaLabels: mergeAccountQuotaEntryMetaLabels(mergeableActiveEntry, observedEntry), windows: mergeAccountQuotaWindows(mergeableActiveEntry.windows, observedEntry.windows), + fetchedAtMs: mergeableActiveEntry.fetchedAtMs, observedAtMs: observedEntry.observedAtMs ?? mergeableActiveEntry.observedAtMs, observedFromUsageHeaders: observedEntry.observedFromUsageHeaders ?? mergeableActiveEntry.observedFromUsageHeaders, @@ -1055,7 +1056,7 @@ const buildKimiAccountQuotaWindows = (rows: KimiQuotaRow[], t: TFunction): Accou label: rowLabel, remainingPercent, resetLabel: resetLabel || '-', - usageLabel: limit > 0 ? `${used} / ${limit}` : null, + usageLabel: null, }; }); @@ -1069,14 +1070,13 @@ const buildXaiAccountQuotaWindows = ( t: TFunction ): AccountQuotaWindow[] => { const remainingCents = - billing.monthlyLimitCents !== null && billing.usedCents !== null - ? Math.max(0, billing.monthlyLimitCents - billing.usedCents) + billing.monthlyLimitCents !== null && billing.includedUsedCents !== null + ? Math.max(0, billing.monthlyLimitCents - billing.includedUsedCents) : null; - - return [ + const windows: AccountQuotaWindow[] = [ { id: 'monthly-limit', - label: t('xai_quota.monthly_limit'), + label: t('xai_quota.monthly_credits'), remainingPercent: buildRemainingFromUsedPercent(billing.usedPercent), resetLabel: billing.billingPeriodEnd ? formatQuotaResetTime(billing.billingPeriodEnd) : '-', usageLabel: t('xai_quota.usage_amount', { @@ -1085,6 +1085,25 @@ const buildXaiAccountQuotaWindows = ( }), }, ]; + + if (billing.onDemandCapCents !== null && billing.onDemandCapCents > 0) { + const onDemandRemainingCents = + billing.onDemandUsedCents !== null + ? Math.max(0, billing.onDemandCapCents - billing.onDemandUsedCents) + : null; + windows.push({ + id: 'pay-as-you-go', + label: t('xai_quota.pay_as_you_go_label'), + remainingPercent: buildRemainingFromUsedPercent(billing.onDemandUsedPercent), + resetLabel: '-', + usageLabel: t('xai_quota.usage_amount', { + remaining: formatXaiCurrency(onDemandRemainingCents), + limit: formatXaiCurrency(billing.onDemandCapCents), + }), + }); + } + + return windows; }; export const getAccountQuotaProviderLabel = ( @@ -1140,6 +1159,11 @@ const buildBaseAccountQuotaEntry = ( }; }; +const stampAccountQuotaFetchTime = (entry: T): T => ({ + ...entry, + fetchedAtMs: Date.now(), +}); + export const buildAccountQuotaErrorEntry = ( target: MonitoringAccountQuotaTarget, error: string, @@ -1228,10 +1252,10 @@ export const requestAccountQuota = async ( switch (target.provider) { case 'antigravity': { const { groups } = await fetchAntigravityQuota(target.file, t); - return { + return stampAccountQuotaFetchTime({ ...buildBaseAccountQuotaEntry(target, t), windows: buildAntigravityAccountQuotaWindows(groups), - }; + }); } case 'claude': { const quota = await fetchClaudeQuota(target.file, t); @@ -1244,18 +1268,18 @@ export const requestAccountQuota = async ( `${t('claude_quota.extra_usage_label')}: $${(quota.extraUsage.used_credits / 100).toFixed(2)} / $${(quota.extraUsage.monthly_limit / 100).toFixed(2)}` ); } - return { + return stampAccountQuotaFetchTime({ ...buildBaseAccountQuotaEntry(target, t, metaLabels), planType: quota.planType ?? target.planType, windows: buildClaudeAccountQuotaWindows(quota.windows, t), - }; + }); } case 'kimi': { const rows = await fetchKimiQuota(target.file, t); - return { + return stampAccountQuotaFetchTime({ ...buildBaseAccountQuotaEntry(target, t), windows: buildKimiAccountQuotaWindows(rows, t), - }; + }); } case 'xai': { const billing = await fetchXaiQuota(target.file, t); @@ -1263,16 +1287,16 @@ export const requestAccountQuota = async ( billing.onDemandCapCents !== null ? [`${t('xai_quota.on_demand_cap')}: ${formatXaiCurrency(billing.onDemandCapCents)}`] : []; - return { + return stampAccountQuotaFetchTime({ ...buildBaseAccountQuotaEntry(target, t, metaLabels), windows: buildXaiAccountQuotaWindows(billing, t), - }; + }); } case 'codex': default: { const quota = await fetchCodexQuota(target.file, t); const planLabel = getCodexPlanLabel(quota.planType ?? target.planType, t); - return { + return stampAccountQuotaFetchTime({ ...buildBaseAccountQuotaEntry( { ...target, @@ -1283,7 +1307,7 @@ export const requestAccountQuota = async ( ), planType: quota.planType ?? target.planType, windows: buildCodexAccountQuotaWindows(quota.windows, t), - }; + }); } } }; diff --git a/apps/web/src/features/monitoring/styles/_monitoring-account-overview.scss b/apps/web/src/features/monitoring/styles/_monitoring-account-overview.scss index 727fd6d88..c9aee1d7c 100644 --- a/apps/web/src/features/monitoring/styles/_monitoring-account-overview.scss +++ b/apps/web/src/features/monitoring/styles/_monitoring-account-overview.scss @@ -987,6 +987,7 @@ align-items: center; justify-content: space-between; gap: 12px; + min-width: 0; } .quotaWindowHeader span { @@ -994,6 +995,115 @@ font-size: 12px; } + .quotaWindowLabel { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; + max-width: 100%; + overflow: visible; + } + + .quotaWindowLabel > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .quotaInfoTrigger { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex: 0 0 auto; + color: var(--monitor-muted); + cursor: help; + outline: none; + } + + .quotaInfoTrigger:hover, + .quotaInfoTrigger:focus-visible { + color: var(--monitor-ink); + } + + .quotaInfoTrigger:focus-visible { + border-radius: 50%; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--monitor-accent) 24%, transparent); + } + + .quotaInfoIcon { + display: block; + } + + .quotaInfoTooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + z-index: 20; + display: grid; + gap: 4px; + min-width: 210px; + max-width: min(320px, calc(100vw - 32px)); + padding: 8px 10px; + border: 1px solid var(--monitor-line); + border-radius: 8px; + background: var(--monitor-surface); + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16); + color: var(--monitor-ink); + font-size: 11px; + line-height: 1.45; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: + opacity 0.14s ease, + transform 0.14s ease, + visibility 0.14s ease; + visibility: hidden; + white-space: normal; + } + + .quotaInfoTooltip::after { + content: ''; + position: absolute; + bottom: -5px; + left: 6px; + width: 9px; + height: 9px; + border-right: 1px solid var(--monitor-line); + border-bottom: 1px solid var(--monitor-line); + background: inherit; + transform: rotate(45deg); + } + + .quotaInfoTrigger:hover .quotaInfoTooltip, + .quotaInfoTrigger:focus-within .quotaInfoTooltip { + opacity: 1; + transform: translateY(0); + visibility: visible; + } + + .quotaInfoTooltipRow { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 8px; + align-items: baseline; + } + + .quotaInfoTooltipLabel { + color: var(--monitor-muted); + white-space: nowrap; + } + + .quotaInfoTooltipValue { + color: var(--monitor-ink); + font-weight: 700; + overflow-wrap: anywhere; + } + .quotaProgressTrack { position: relative; overflow: hidden; diff --git a/apps/web/src/features/usage-analytics/usageAnalyticsWiring.test.ts b/apps/web/src/features/usage-analytics/usageAnalyticsWiring.test.ts index b6d51bac0..f5ac5f6f2 100644 --- a/apps/web/src/features/usage-analytics/usageAnalyticsWiring.test.ts +++ b/apps/web/src/features/usage-analytics/usageAnalyticsWiring.test.ts @@ -347,6 +347,13 @@ describe('usage analytics app wiring', () => { expect(monitoringIndex).toBeGreaterThan(usageIndex); }); + it('renders full sidebar labels when the sidebar is expanded', () => { + expect(layoutSource).toContain( + '{showSidebarLabels && {item.label}}' + ); + expect(layoutSource).not.toContain('item.shortLabel ?? item.label'); + }); + it('escapes user-controlled chart tooltip labels before returning tooltip HTML', () => { expect(pageSource).toContain('const escapeHtml = (value: string | number | null | undefined)'); expect(pageSource).not.toContain('${item.name}'); diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 93919b854..182ef641e 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -50,10 +50,11 @@ "copy": "Copy", "expand": "Expand", "collapse": "Collapse", + "navigation": "Navigation", "status": "Status", "action": "Action", "custom_headers_label": "Custom Headers", - "custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.", + "custom_headers_hint": "Optional. Add any extra headers the service should receive. Both name and value are required.", "custom_headers_add": "Add Header", "custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header", "custom_headers_value_placeholder": "Header value", @@ -86,7 +87,7 @@ "custom_connection_hint": "By default the current URL is used. Override it here if needed.", "cpa_connection_label": "CPA connection URL", "cpa_connection_placeholder": "Eg: http://127.0.0.1:8317", - "cpa_connection_hint": "Manager Server uses this URL to reach the CPA Management API and RESP usage queue.", + "cpa_connection_hint": "Manager Server uses this address to connect to CPA and read request usage data.", "cpa_address_required": "Enter the CPA URL", "usage_service_mode_hint": "This panel is hosted by Manager Server. Enter the CPA API URL.", "usage_service_configured_hint": "This panel is hosted by a configured Manager Server. After login it uses the saved CPA connection on the server.", @@ -101,7 +102,7 @@ "migration_detected_hint": "Historical data was detected. The server will keep it and migrate legacy settings automatically.", "cpa_management_key_label": "CPA Management Key", "cpa_management_key_placeholder": "Enter the CPA Management Key", - "cpa_management_key_hint": "Only used by Manager Server to validate and manage the CPA upstream.", + "cpa_management_key_hint": "Only used by Manager Server to connect to and manage CPA.", "cpa_management_key_required": "Enter the CPA Management Key", "setup_title": "Setup Wizard", "setup_steps": "Setup steps", @@ -114,10 +115,10 @@ "step_polling": "Polling Interval", "step_review": "Review", "request_monitoring_enabled": "Enable Request Monitoring", - "request_monitoring_enabled_hint": "Saving will automatically enable CPA usage statistics and start the Manager Server collector.", - "request_monitoring_disabled_hint": "The CPA connection will still be saved, but CPA usage statistics and the collector will not be enabled. If CPA usage statistics were enabled elsewhere, re-enabling the collector within the queue retention window may still collect retained events.", - "poll_interval_label": "Collection Polling Interval (ms)", - "poll_interval_hint": "Must be less than or equal to the CPA usage queue retention window. Saving will automatically enable CPA usage statistics.", + "request_monitoring_enabled_hint": "Saving will turn on CPA usage statistics and start reading request records.", + "request_monitoring_disabled_hint": "The CPA connection will still be saved, but new monitoring data will not be recorded. If CPA is still recording usage elsewhere, turning this back on shortly after may show a small amount of retained data.", + "poll_interval_label": "Read interval (ms)", + "poll_interval_hint": "The read interval cannot be longer than the time CPA keeps usage data. Saving will turn on CPA usage statistics.", "poll_interval_invalid": "Enter a valid collection interval", "show_key": "Show key", "hide_key": "Hide key", @@ -140,8 +141,8 @@ "error_forbidden": "Access denied, insufficient permissions", "error_not_found": "Server address invalid or management API not enabled", "error_server": "Internal server error, please try again later", - "error_backend_detail": "backend detail", - "error_cors": "Cross-origin request blocked, please check server configuration", + "error_backend_detail": "service response", + "error_cors": "The browser blocked this connection. Check the server CORS settings.", "error_ssl": "SSL/TLS certificate verification failed" }, "header": { @@ -183,20 +184,21 @@ "account_actions_short": "Issues", "config_management": "Config Panel", "config_management_short": "Config", + "system_config": "System Config", "logs": "Logs Viewer", "logs_short": "Logs", "system_info": "System Info", "system_info_short": "System" }, "account_actions": { - "eyebrow": "Auth Issue Queue", + "eyebrow": "Account issues", "title": "Auth Issue Handling", "back_to_monitoring": "Back to Monitoring", - "description": "When request monitoring sees a revoked OAuth token, unavailable authentication, or a deactivated workspace, CPA Manager Plus records an auth issue candidate here for you to confirm deletion, re-enable, ignore, or mark resolved.", + "description": "This page lists login or account problems found by request monitoring, such as expired accounts, failed authentication, or deactivated workspaces. You can delete, re-enable, ignore, or mark them resolved.", "pending_count": "Pending", "visible_count": "Visible", "status_filter": "Status filter", - "search_placeholder": "Search account, file, reason, header error, or trace", + "search_placeholder": "Search account, file, reason, or error message", "filter_pending": "Pending", "filter_all": "All", "filter_ignored": "Ignored", @@ -226,7 +228,7 @@ "evidence_title": "Failure evidence", "no_evidence": "No evidence to display.", "empty_title": "No auth issues", - "empty_desc": "Auth issues will appear here when request monitoring captures token_revoked, invalidated oauth token, or similar authentication failures.", + "empty_desc": "Account login or authentication problems found by request monitoring will appear here.", "load_failed_title": "Load failed", "load_failed": "Failed to load auth issues: {{message}}", "action_failed": "Action failed: {{message}}", @@ -234,9 +236,9 @@ "resolve_success": "Marked as resolved", "enable_success": "Auth file re-enabled", "delete_success": "Auth file deleted", - "confirm_delete_title": "Delete auth file?", - "confirm_delete_message": "This will delete auth file \"{{file}}\". This action cannot be undone. Confirm only when you are sure the account cannot recover.", - "confirm_delete_button": "Delete auth file" + "confirm_delete_title": "Delete login file?", + "confirm_delete_message": "This will delete login file \"{{file}}\". This action cannot be undone. Confirm only when you are sure the account cannot recover.", + "confirm_delete_button": "Delete login file" }, "dashboard": { "title": "Dashboard", @@ -289,14 +291,14 @@ "token_mix_cache_creation": "Cache Create", "model_cost_rank": "Model Cost Rank", "no_traffic_data": "No traffic yet", - "collector_status_title": "Collector Status", - "collector_events": "Events", - "collector_dead_letters": "Dead Letters", + "collector_status_title": "Request Record Reader", + "collector_events": "Records", + "collector_dead_letters": "Problem records", "collector_mode": "Mode", "collector_queue": "Queue", - "collector_last_inserted": "Last Inserted", - "collector_last_consumed": "Last Consumed", - "collector_total_inserted": "Total Inserted", + "collector_last_inserted": "Last written", + "collector_last_consumed": "Last read", + "collector_total_inserted": "Total written", "collector_total_skipped": "Total Skipped", "collector_last_error": "Last Error", "collector_unavailable": "Unavailable", @@ -316,7 +318,7 @@ "health_status_problem": "Problem", "health_status_disabled": "Disabled", "health_status_warning": "Warning", - "health_queue_status": "Queue Status", + "health_queue_status": "Data read status", "health_error_log_count": "{{count}} files", "today_overview_usage_service": "Today's Overview (Manager Server)", "metric_failure_count": "{{value}} failed", @@ -415,6 +417,7 @@ "gemini_add_modal_title": "Add Gemini API Key", "gemini_add_modal_key_label": "API Keys:", "gemini_add_modal_key_placeholder": "Enter Gemini API key", + "gemini_key_required": "Please enter a Gemini API Key", "gemini_add_modal_key_hint": "Add keys one by one and optionally specify a Base URL.", "gemini_keys_add_btn": "Add Key", "gemini_base_url_label": "Base URL (Optional):", @@ -426,7 +429,7 @@ "gemini_models_add_btn": "Add Model", "gemini_models_fetch_button": "Fetch via /v1beta/models", "gemini_models_fetch_title": "Pick Models from Gemini /v1beta/models", - "gemini_models_fetch_hint": "Call GET /v1beta/models per the Gemini API. By default, it sends x-goog-api-key from the API Key field, merged with your custom headers.", + "gemini_models_fetch_hint": "Uses the address and Gemini key above to load the model list. Extra headers are included if configured.", "gemini_models_fetch_url_label": "Request URL", "gemini_models_fetch_refresh": "Refresh", "gemini_models_fetch_loading": "Fetching models from Gemini /v1beta/models...", @@ -443,6 +446,7 @@ "gemini_models_count": "Models Count", "gemini_edit_modal_title": "Edit Gemini API Key", "gemini_edit_modal_key_label": "API Key:", + "gemini_delete_title": "Delete Gemini Key", "gemini_delete_confirm": "Are you sure you want to delete this Gemini key?", "excluded_models_label": "Excluded models (optional):", "excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash", @@ -454,7 +458,7 @@ "priority_label": "Priority (Optional):", "priority_hint": "Higher values are preferred. Leave empty for default (0).", "websockets_label": "WebSockets", - "websockets_hint": "Enable Responses API websocket transport for this auth file.", + "websockets_hint": "Enable WebSocket transport for this login file.", "config_toggle_label": "Enabled", "config_disabled_badge": "Disabled", "codex_title": "Codex API Configuration", @@ -467,6 +471,7 @@ "codex_add_modal_title": "Add Codex API Configuration", "codex_add_modal_key_label": "API Key:", "codex_add_modal_key_placeholder": "Please enter Codex API key", + "codex_key_required": "Please enter a Codex API Key", "codex_add_modal_url_label": "Base URL (Required):", "codex_add_modal_url_placeholder": "e.g.: https://api.example.com", "codex_add_modal_proxy_label": "Proxy URL (Optional):", @@ -478,7 +483,7 @@ "codex_models_add_btn": "Add Model", "codex_models_fetch_button": "Fetch via /v1/models", "codex_models_fetch_title": "Pick Models from /v1/models", - "codex_models_fetch_hint": "Call GET /v1/models using the Base URL above, sending the API key as Bearer (if provided) and merging your custom headers.", + "codex_models_fetch_hint": "Uses the address and key above to load the model list. Extra headers are included if configured.", "codex_models_fetch_url_label": "Request URL", "codex_models_fetch_refresh": "Refresh", "codex_models_fetch_loading": "Fetching models from /v1/models...", @@ -490,7 +495,7 @@ "codex_models_search_empty": "No models match your search. Try a different keyword.", "codex_models_fetch_added": "{{count}} new models added", "codex_test_title": "Connectivity Test", - "codex_test_hint": "Send a test request to /v1/responses to verify the current Codex settings are available.", + "codex_test_hint": "Sends one test request to confirm the current Codex settings work.", "codex_test_model_label": "Test model", "codex_test_select_placeholder": "Select or enter a model to test", "codex_test_select_empty": "Add a model first before testing", @@ -500,7 +505,7 @@ "codex_test_failed": "Codex connectivity test failed", "codex_test_endpoint_invalid": "Please enter a valid Base URL", "codex_test_model_required": "Please enter a test model first", - "codex_test_key_required": "Please provide an API key, Authorization header, or auth_index", + "codex_test_key_required": "Add an API key, or choose a signed-in account first.", "codex_models_count": "Models Count", "codex_edit_modal_title": "Edit Codex API Configuration", "codex_edit_modal_key_label": "API Key:", @@ -515,7 +520,9 @@ "claude_add_modal_title": "Add Claude API Configuration", "claude_add_modal_key_label": "API Key:", "claude_add_modal_key_placeholder": "Please enter Claude API key", + "claude_key_required": "Please enter a Claude API Key", "claude_add_modal_url_label": "Base URL (Optional):", + "claude_base_url_required": "Please enter a Claude Base URL", "claude_add_modal_url_placeholder": "e.g.: https://api.anthropic.com", "claude_add_modal_proxy_label": "Proxy URL (Optional):", "claude_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080", @@ -530,7 +537,7 @@ "claude_models_count": "Models Count", "claude_models_fetch_button": "Fetch via /v1/models", "claude_models_fetch_title": "Pick Models from Claude /v1/models", - "claude_models_fetch_hint": "Call GET /v1/models with Anthropic headers. By default, this sends x-api-key and anthropic-version: 2023-06-01, merged with your custom headers.", + "claude_models_fetch_hint": "Uses the address and Claude key above to load the model list. Extra headers are included if configured.", "claude_models_fetch_url_label": "Request URL", "claude_models_fetch_refresh": "Refresh", "claude_models_fetch_loading": "Fetching models from Claude /v1/models...", @@ -552,7 +559,7 @@ "claude_test_failed": "Test failed", "claude_test_key_required": "Please provide a Claude API key or set x-api-key in custom headers", "claude_test_model_required": "Please select a model to test", - "claude_test_endpoint_invalid": "Unable to build a valid Claude /v1/messages endpoint", + "claude_test_endpoint_invalid": "Unable to build a valid Claude test address", "claude_cloak_title": "Request Cloaking (Optional):", "claude_cloak_toggle_label": "Enable", "claude_cloak_toggle_aria": "Toggle request cloaking", @@ -576,7 +583,9 @@ "vertex_add_modal_title": "Add Vertex API Configuration", "vertex_add_modal_key_label": "API Key:", "vertex_add_modal_key_placeholder": "Please enter Vertex API key", + "vertex_key_required": "Please enter a Vertex API Key", "vertex_add_modal_url_label": "Base URL:", + "vertex_base_url_required": "Please enter the Vertex Base URL", "vertex_add_modal_url_placeholder": "e.g.: https://example.com/api", "vertex_add_modal_proxy_label": "Proxy URL (Optional):", "vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080", @@ -584,6 +593,7 @@ "vertex_edit_modal_key_label": "API Key:", "vertex_edit_modal_url_label": "Base URL:", "vertex_edit_modal_proxy_label": "Proxy URL (Optional):", + "vertex_delete_title": "Delete Vertex Config", "vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?", "vertex_models_label": "Model aliases:", "vertex_models_add_btn": "Add Mapping", @@ -610,6 +620,7 @@ "openai_add_modal_url_placeholder": "e.g.: https://openrouter.ai/api/v1", "openai_add_modal_keys_label": "API Keys", "openai_edit_modal_keys_label": "API Keys", + "openai_key_required": "Please add at least one API key", "openai_keys_hint": "Add each key separately with an optional proxy URL to keep things organized.", "openai_keys_add_btn": "Add Key", "openai_key_placeholder": "sk-... key", @@ -621,11 +632,11 @@ "openai_models_add_btn": "Add Model", "openai_models_fetch_button": "Fetch via /models", "openai_models_fetch_title": "Pick Models from /models", - "openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.", + "openai_models_fetch_hint": "Uses the address and first key above to load the model list. Extra headers are included if configured.", "openai_models_fetch_url_label": "Request URL", "openai_models_fetch_refresh": "Refresh", "openai_models_fetch_loading": "Fetching models from /models...", - "openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.", + "openai_models_fetch_empty": "No models returned. Check the address, key, or headers.", "openai_models_fetch_error": "Failed to fetch models", "openai_models_fetch_back": "Back to edit", "openai_models_fetch_apply": "Add selected models", @@ -638,12 +649,14 @@ "openai_edit_modal_name_label": "Provider Name:", "openai_edit_modal_url_label": "Base URL:", "openai_edit_modal_models_label": "Model List (name[, alias] one per line):", + "openai_delete_title": "Delete OpenAI Provider", "openai_delete_confirm": "Are you sure you want to delete this OpenAI provider?", "openai_keys_count": "Keys Count", "openai_models_count": "Models Count", "openai_test_title": "Connection Test", "openai_test_hint": "Send a /chat/completions request with the current settings to verify availability.", "openai_test_model_placeholder": "Model to test", + "openai_test_model_required": "Please select a model to test", "openai_test_action": "Run Test", "openai_test_running": "Sending test request...", "openai_test_timeout": "Test request timed out after {{seconds}} seconds.", @@ -670,6 +683,9 @@ "table_col_models": "Models", "table_col_recent": "Recent Requests", "table_col_actions": "Actions", + "priority_decrease": "Decrease priority", + "priority_edit": "Edit priority", + "priority_increase": "Increase priority", "filter_all": "All", "filter_kind_aria": "Filter by provider type", "search_placeholder": "Search name / URL / key", @@ -714,13 +730,13 @@ "health_check_no_key_entries": "No key entries configured", "health_check_error_missing_credential": "Missing API key or auth-index", "health_check_error_empty_models": "No models returned", - "health_check_error_vertex_standard_models": "No standard model discovery endpoint responded{{reason}}", + "health_check_error_vertex_standard_models": "The standard model list address did not respond{{reason}}", "health_check_error_provider_missing": "Provider configuration no longer exists" }, "auth_files": { "title": "Auth Files Management", "title_section": "Auth Files", - "description": "Manage all CLI Proxy JSON auth files here (e.g. Qwen, Gemini, Vertex). Uploading a credential immediately enables the corresponding AI integration.", + "description": "Manage login files for AI services such as Qwen, Gemini, and Vertex. After upload, the matching service can be used from the panel.", "upload_button": "Upload File", "paste_button": "Paste JSON", "paste_title": "Paste Auth JSON", @@ -730,21 +746,21 @@ "paste_type_sub2api": "sub2api account export JSON", "paste_file_name_label": "File name", "paste_json_label": "JSON content", - "paste_save_button": "Save Auth File", + "paste_save_button": "Save Login File", "paste_cpa_placeholder": "{\n \"type\": \"codex\",\n \"access_token\": \"...\"\n}", "paste_session_placeholder": "{\n \"user\": { \"email\": \"...\" },\n \"account\": { \"id\": \"...\" },\n \"accessToken\": \"...\",\n \"sessionToken\": \"...\"\n}", "paste_sub2api_placeholder": "{\n \"exported_at\": \"...\",\n \"proxies\": [],\n \"accounts\": [\n {\n \"platform\": \"openai\",\n \"type\": \"oauth\",\n \"credentials\": { \"access_token\": \"...\" }\n }\n ]\n}", - "paste_cpa_hint": "The JSON will be stored as-is after validating it is a JSON object.", - "paste_session_hint": "ChatGPT Web session JSON is converted to a CPA Codex auth file before upload. Tokens are sensitive credentials.", - "paste_sub2api_hint": "Official sub2api account export JSON is converted by extracting OpenAI OAuth accounts into CPA Codex auth files. Tokens are sensitive credentials.", + "paste_cpa_hint": "The content is saved after it is confirmed as valid JSON.", + "paste_session_hint": "ChatGPT web login data is converted into a CPA-compatible Codex login file before upload. Tokens are sensitive login data; do not share them.", + "paste_sub2api_hint": "sub2api account exports are converted into CPA-compatible Codex login files. Tokens are sensitive login data; do not share them.", "paste_error_file_name": "File name is required", "paste_error_file_extension": "File name must end with .json", "paste_error_file_name_invalid": "Use a .json file name without path separators or reserved characters", "paste_error_json_required": "JSON content is required", "paste_error_save_in_progress": "Auth JSON paste save is already in progress", - "paste_success": "Saved auth file {{name}}", + "paste_success": "Saved login file {{name}}", "delete_all_button": "Delete All", - "empty_title": "No Auth Files", + "empty_title": "No login files", "empty_desc": "Click the button above to upload the first file", "search_empty_title": "No matching files", "search_empty_desc": "Try changing the filters or clearing the search box.", @@ -762,39 +778,39 @@ "download_button": "Download", "delete_button": "Delete", "delete_confirm": "Are you sure you want to delete file", - "delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!", - "delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!", + "delete_all_confirm": "Are you sure you want to delete all login files? This operation cannot be undone!", + "delete_filtered_confirm": "Are you sure you want to delete all {{type}} login files? This operation cannot be undone!", "delete_problem_button": "Delete Problem Files", "delete_problem_button_with_type": "Delete Problematic {{type}} Files", - "delete_problem_confirm": "Are you sure you want to delete all problematic auth files? This operation cannot be undone!", - "delete_problem_filtered_confirm": "Are you sure you want to delete all problematic {{type}} auth files? This operation cannot be undone!", + "delete_problem_confirm": "Are you sure you want to delete all problematic login files? This operation cannot be undone!", + "delete_problem_filtered_confirm": "Are you sure you want to delete all problematic {{type}} login files? This operation cannot be undone!", "delete_filtered_result_button": "Delete filtered results", - "delete_filtered_result_confirm": "Are you sure you want to delete auth files in the current filtered results? This operation cannot be undone!", - "delete_filtered_result_confirm_file_scope": "Delete the auth files behind the current filtered results? Deletion is by file name; if multiple account rows come from the same auth file, the whole file will be removed. This cannot be undone!", + "delete_filtered_result_confirm": "Are you sure you want to delete login files in the current filtered results? This operation cannot be undone!", + "delete_filtered_result_confirm_file_scope": "Delete the login files behind the current filtered results? Deletion is by file name; if multiple account rows come from the same login file, the whole file will be removed. This cannot be undone!", "upload_error_json": "Only JSON files are allowed", "upload_error_size": "File size cannot exceed {{maxSize}}", "upload_success": "File uploaded successfully", "download_success": "File downloaded successfully", "delete_success": "File deleted successfully", "delete_all_success": "Successfully deleted", - "delete_filtered_success": "Deleted {{count}} {{type}} auth files successfully", - "delete_filtered_partial": "{{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed", - "delete_filtered_none": "No deletable auth files under the current filter ({{type}})", - "delete_problem_success": "Deleted {{count}} problematic auth files successfully", - "delete_problem_filtered_success": "Deleted {{count}} problematic {{type}} auth files successfully", - "delete_problem_partial": "Problematic auth files deletion finished: {{success}} succeeded, {{failed}} failed", - "delete_problem_filtered_partial": "Problematic {{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed", - "delete_problem_none": "No deletable problematic auth files under the current filter", - "delete_problem_filtered_none": "No deletable problematic {{type}} auth files under the current filter", - "delete_filtered_result_success": "Deleted {{count}} auth files from the filtered results successfully", + "delete_filtered_success": "Deleted {{count}} {{type}} login files successfully", + "delete_filtered_partial": "{{type}} login file deletion finished: {{success}} succeeded, {{failed}} failed", + "delete_filtered_none": "No deletable login files under the current filter ({{type}})", + "delete_problem_success": "Deleted {{count}} problematic login files successfully", + "delete_problem_filtered_success": "Deleted {{count}} problematic {{type}} login files successfully", + "delete_problem_partial": "Problematic login file deletion finished: {{success}} succeeded, {{failed}} failed", + "delete_problem_filtered_partial": "Problematic {{type}} login file deletion finished: {{success}} succeeded, {{failed}} failed", + "delete_problem_none": "No deletable problematic login files under the current filter", + "delete_problem_filtered_none": "No deletable problematic {{type}} login files under the current filter", + "delete_filtered_result_success": "Deleted {{count}} login files from the filtered results successfully", "delete_filtered_result_partial": "Filtered result deletion finished: {{success}} succeeded, {{failed}} failed", - "delete_filtered_result_none": "No deletable auth files in the current filtered results", + "delete_filtered_result_none": "No deletable login files in the current filtered results", "files_count": "files", "pagination_prev": "Previous", "pagination_next": "Next", "pagination_info": "Page {{current}} / {{total}} · {{count}} files", "search_label": "Search credentials", - "search_placeholder": "Search name, type, auth index, account ID, plan, or error status. Use * as a wildcard", + "search_placeholder": "Search name, type, account, plan, or error status. Use * as a wildcard", "problem_filter_label": "Problem Filter", "problem_filter_only": "Only show problematic credentials", "disabled_filter_only": "Only show disabled credentials", @@ -854,7 +870,7 @@ "type_vertex": "Vertex", "type_empty": "Empty", "type_unknown": "Other", - "type_virtual": "Virtual auth file", + "type_virtual": "Virtual login file", "models_button": "Models", "models_title": "Supported models", "models_loading": "Loading model list...", @@ -876,16 +892,18 @@ "codex_status_badge_monthly_limited_title": "The Codex monthly quota window is at its limit and usually needs time to recover.", "codex_status_badge_disabled_reset": "Restores {{reset}}", "codex_status_badge_disabled_reset_title": "This disabled Codex account has a known quota recovery time: {{reset}}.", - "codex_status_badge_observed_quota": "Usage header quota", - "codex_status_badge_observed_quota_title": "Recent usage response headers recorded quota or recovery data. This is historical request evidence, not a live quota query.", - "codex_status_badge_observed_error": "Usage header error", - "codex_status_badge_observed_error_title": "Recent usage response headers recorded auth, quota, or rate-limit errors. This is historical request evidence for diagnostics.", + "codex_status_badge_observed_quota": "Quota seen in recent requests", + "codex_status_badge_observed_quota_title": "Recent requests returned quota or recovery-time information. This comes from past requests and is not a live quota check.", + "codex_status_badge_observed_error": "Error seen in recent requests", + "codex_status_badge_observed_error_title": "Recent requests returned login, quota, or rate-limit errors. This comes from past requests and is only for troubleshooting.", "status_toggle_label": "Enabled", "status_enabled_success": "\"{{name}}\" enabled", "status_disabled_success": "\"{{name}}\" disabled", "batch_status_success": "{{count}} files updated successfully", "batch_status_partial": "{{success}} updated, {{failed}} failed", "batch_delete_title": "Delete Selected Files", + "delete_title": "Delete File", + "delete_all_title": "Delete All Files", "batch_delete_confirm": "Are you sure you want to delete {{count}} files?", "batch_selected": "{{count}} selected", "batch_select_all": "Select All", @@ -905,11 +923,14 @@ "batch_priority_invalid": "Enter an integer priority", "batch_websockets_enable": "Enable WebSockets", "batch_websockets_disable": "Disable WebSockets", - "prefix_proxy_button": "Auth File Details / Edit", - "auth_field_editor_title": "Auth File Details / Edit - {{name}}", - "prefix_proxy_loading": "Loading auth file...", - "prefix_proxy_info_label": "Auth file info (info)", - "prefix_proxy_source_label": "Auth file JSON (preview)", + "card_tools_title": "Tools", + "quota_refresh_single": "Refresh quota", + "quota_refresh_hint": "Refresh quota for this credential only", + "prefix_proxy_button": "Login File Details / Edit", + "auth_field_editor_title": "Login File Details / Edit - {{name}}", + "prefix_proxy_loading": "Loading login file...", + "prefix_proxy_info_label": "Login file info (info)", + "prefix_proxy_source_label": "Login file content (preview)", "prefix_proxy_invalid_content_label": "Downloaded content (truncated)", "prefix_label": "Prefix (prefix)", "proxy_url_label": "Proxy URL (proxy_url)", @@ -918,6 +939,8 @@ "priority_label": "Priority (priority)", "priority_placeholder": "e.g. 10 or -1", "priority_hint": "Integers only. Invalid values are ignored. Larger value means higher priority.", + "websockets_label": "WebSockets", + "websockets_hint": "Enable WebSocket transport for this login file.", "excluded_models_label": "Excluded models (excluded_models)", "excluded_models_placeholder": "Comma or newline separated, e.g. model-a, gpt-5-*, *-preview", "excluded_models_hint": "Saved as an array and normalized by trim/lowercase/dedup/sort.", @@ -930,17 +953,17 @@ "note_display": "Note", "headers_label": "Custom Headers (headers)", "headers_placeholder": "{\n \"Header-Name\": \"value\"\n}", - "headers_hint": "Enter custom HTTP headers as a JSON object, e.g., {\"X-My-Header\": \"value\"}", + "headers_hint": "Enter extra request headers as JSON, for example {\"X-My-Header\": \"value\"}", "headers_invalid_json": "Custom headers must be valid JSON.", "headers_invalid_object": "Custom headers must be a JSON object.", "headers_invalid_value": "Each custom header value must be a string.", - "prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.", - "prefix_proxy_html_challenge": "Downloaded content is an HTML challenge page, not an auth JSON object. Re-authenticate or replace this auth file before editing fields.", - "prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully", + "prefix_proxy_invalid_json": "This login file is not editable JSON content.", + "prefix_proxy_html_challenge": "The downloaded content is a verification page, not login-file content. Sign in again or replace the file before editing.", + "prefix_proxy_saved_success": "Updated login file \"{{name}}\" successfully", "quota_refresh_success": "Quota refreshed for \"{{name}}\"", "quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}", "quota_cooldown_badge": "Cooldown until {{recoverAt}}", - "quota_cooldown_badge_title": "This auth file is in a CPAMP-managed quota cooldown and will be recovered automatically. It is not the native CPA disabled state. Owner: {{owner}}. Expected recovery: {{recoverAt}}." + "quota_cooldown_badge_title": "This account was temporarily disabled by CPAMP because quota ran out. It should recover automatically at {{recoverAt}}; this is not a manual disable. Source: {{owner}}." }, "antigravity_quota": { "title": "Antigravity Quota", @@ -951,7 +974,22 @@ "load_failed": "Failed to load quota: {{message}}", "missing_auth_index": "Auth file missing auth_index", "empty_models": "No quota data available", + "plan_label": "Plan", + "group_gemini_models": "Gemini models", + "group_claude_gpt_models": "Claude and GPT models", + "group_models_description": "Models in this group: {{models}}", + "weekly_limit": "Weekly limit", + "daily_limit": "Daily limit", + "five_hour_limit": "5-hour limit", + "monthly_limit": "Monthly limit", + "quota_available": "Quota available", + "remaining_percent": "{{percent}}% remaining", + "refreshes_in": "Refreshes in {{duration}}", "refresh_available": "Refresh available", + "duration_day_hour": "{{days}}d {{hours}}h", + "duration_hour_minute": "{{hours}}h {{minutes}}m", + "duration_minute": "{{minutes}}m", + "duration_less_than_minute": "<1m", "refresh_button": "Refresh Quota", "fetch_all": "Fetch All" }, @@ -1023,6 +1061,7 @@ "reset_credits_unknown": "Not recorded", "reset_credits_earliest_expiry": "Earliest expires {{time}}", "reset_credits_expiry_label": "Reset credit expiry", + "reset_credits_invalid_payload": "The reset credits service returned an invalid response", "reset_credit_expiry_item": "Credit {{index}}", "active_limit_label": "Active limit", "credits_label": "Credits", @@ -1034,7 +1073,7 @@ "tooltip_label": "{{label}} details", "tooltip_source_label": "Source", "tooltip_source_header": "Latest usage response header", - "tooltip_source_api": "API quota endpoint", + "tooltip_source_api": "Quota API", "tooltip_recorded_at_label": "Recorded at", "tooltip_fetched_at_label": "Fetched at", "tooltip_used_label": "Used", @@ -1049,7 +1088,7 @@ "code_review_secondary_window": "Code review weekly limit", "code_review_monthly_window": "Code review monthly limit", "code_review_generic_window": "Code review {{duration}} limit", - "observed_window": "Latest usage header", + "observed_window": "Latest service response", "additional_primary_window": "{{name}} 5-hour limit", "additional_secondary_window": "{{name}} weekly limit", "additional_monthly_window": "{{name}} monthly limit", @@ -1091,9 +1130,12 @@ "refresh_button": "Refresh Quota", "fetch_all": "Fetch All", "monthly_limit": "Monthly billing limit", + "monthly_credits": "Monthly credits", "plan_label": "Plan", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", + "pay_as_you_go_label": "Pay-as-you-go", + "pay_as_you_go_disabled": "Disabled", "on_demand_cap": "On-demand cap", "usage_amount": "{{remaining}} / {{limit}} remaining", "reset_unknown": "reset unknown" @@ -1119,7 +1161,7 @@ }, "oauth_excluded": { "title": "OAuth Model Disablement", - "description": "Per-provider model disablement is shown as cards; click a card to edit or delete. Wildcards * are supported and the scope follows the auth file filter.", + "description": "Disabled models are shown as cards by provider. Click a card to edit or delete it. Wildcards * are supported and the scope follows the login-file filter.", "add": "Add Disablement", "add_title": "Add provider model disablement", "edit_title": "Edit model disablement for {{provider}}", @@ -1139,6 +1181,7 @@ "save_success": "Model disablement updated", "save_failed": "Failed to update model disablement", "delete": "Delete Provider", + "delete_title": "Delete Exclusion", "delete_confirm": "Delete model disablement for {{provider}}?", "delete_success": "Provider model disablement removed", "delete_failed": "Failed to delete model disablement", @@ -1175,6 +1218,7 @@ "save_success": "Model aliases updated", "save_failed": "Failed to update model aliases", "delete": "Delete Provider", + "delete_title": "Delete Mappings", "delete_confirm": "Delete model aliases for {{provider}}?", "delete_link_title": "Unlink mapping", "delete_link_confirm": "Unlink mapping from {{sourceModel}} ({{provider}}) to alias {{alias}}?", @@ -1288,6 +1332,17 @@ "xai_callback_hint": "Grok may only show a code on the page. Paste that code directly and the app will submit it as http://127.0.0.1:56121/callback?... automatically.", "xai_callback_required": "Please paste the code shown by Grok or the full callback URL first.", "xai_callback_state_missing": "Missing the state for this xAI login. Start xAI login again, then submit the code.", + "qwen_oauth_title": "Qwen OAuth", + "qwen_oauth_button": "Start Qwen Login", + "qwen_oauth_hint": "Log in to Qwen through device authorization, then automatically obtain and save the login file.", + "qwen_oauth_url_label": "Authorization URL:", + "qwen_open_link": "Open Link", + "qwen_copy_link": "Copy Link", + "qwen_oauth_status_waiting": "Waiting for authentication...", + "qwen_oauth_status_success": "Authentication successful!", + "qwen_oauth_status_error": "Authentication failed:", + "qwen_oauth_start_error": "Failed to start Qwen OAuth:", + "qwen_oauth_polling_error": "Failed to check authentication status:", "oauth_callback_label": "Callback URL", "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", "oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.", @@ -1300,7 +1355,7 @@ "oauth_callback_status_error": "Callback URL submission failed:", "missing_state": "Unable to retrieve authentication state parameter", "login_another_account": "Log in another account", - "view_auth_files": "View auth files", + "view_auth_files": "View login files", "iflow_oauth_title": "iFlow OAuth", "iflow_oauth_button": "Start iFlow Login", "iflow_oauth_hint": "Login to iFlow service through OAuth flow, automatically obtain and save authentication files.", @@ -1312,12 +1367,29 @@ "iflow_oauth_status_error": "Authentication failed:", "iflow_oauth_start_error": "Failed to start iFlow OAuth:", "iflow_oauth_polling_error": "Failed to check authentication status:", + "iflow_cookie_title": "iFlow Cookie Login", + "iflow_cookie_label": "Cookie content:", + "iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=", + "iflow_cookie_hint": "Submit the Cookie directly to complete login without opening an authorization link. The server will save the credential automatically.", + "iflow_cookie_key_hint": "Tip: create a Key on the platform first.", + "iflow_cookie_button": "Submit Cookie Login", + "iflow_cookie_status_success": "Cookie login succeeded. The credential has been saved.", + "iflow_cookie_status_error": "Cookie login failed:", + "iflow_cookie_status_duplicate": "Duplicate config:", + "iflow_cookie_start_error": "Failed to submit Cookie login:", + "iflow_cookie_config_duplicate": "A duplicate config file already exists. Delete the existing file first if you need to save it again.", + "iflow_cookie_required": "Enter Cookie content first", + "iflow_cookie_result_title": "Cookie Login Result", + "iflow_cookie_result_email": "Account", + "iflow_cookie_result_expired": "Expires at", + "iflow_cookie_result_path": "Saved path", + "iflow_cookie_result_type": "Type", "remote_access_disabled": "This login method is not available for remote access. Please access from localhost." }, "model_prices": { "eyebrow": "Model Pricing", "title": "Model Price Management", - "subtitle": "Maintain model pricing in Manager Server, sync high-confidence prices automatically, and confirm similar candidates manually.", + "subtitle": "Manage model prices in Manager Server, sync reliable matches automatically, and manually confirm similar matches.", "back_to_monitoring": "Back to Monitoring", "summary": "Model pricing summary", "summary_total": "Total models", @@ -1412,7 +1484,7 @@ "chart_line_hint": "Show up to 9 model lines at once", "no_data": "No Data Available", "loading_error": "Loading Failed", - "api_endpoint": "Call Bucket", + "api_endpoint": "Call source", "requests_count": "Request Count", "tokens_count": "Token Count", "models": "Model Statistics", @@ -1456,7 +1528,7 @@ "time": "Latency", "avg_time": "Avg Latency", "total_time": "Total Latency", - "latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.", + "latency_unit_hint": "Durations use service value {{field}} and are interpreted as {{unit}} before formatting.", "duration_unit_d": "d", "duration_unit_h": "h", "duration_unit_m": "m", @@ -1490,7 +1562,7 @@ "monitoring": { "eyebrow": "Runtime Overview", "title": "Request Monitoring", - "subtitle": "A unified operations view across model-level calls, upstream channels, auth credentials, and estimated cost. KPIs default to model calls, while task buckets are shown separately so usage.total_requests is not mistaken for business request count.", + "subtitle": "Track model requests, accounts, channels, and estimated cost here. Model requests are not the same as business requests; task counts are estimated and for reference only.", "connection_status": "Connection", "usage_switch": "Usage Stats", "logging_switch": "File Logging", @@ -1508,7 +1580,7 @@ "custom_range_end": "End", "custom_range_required": "Select both start and end times.", "custom_range_invalid": "End time cannot be earlier than start time.", - "search_placeholder": "Search account, label, auth index, model, channel, source, credential, API key, or endpoint path", + "search_placeholder": "Search account, label, model, channel, source, API key, or request path", "auto_refresh": "Auto Refresh", "auto_refresh_short": "Auto", "auto_refresh_off": "Off", @@ -1564,10 +1636,10 @@ "usage_disabled_body": "Request Monitoring depends on Manager Server or compatible usage data. Enable usage statistics and verify Manager Server is running to populate runtime metrics here.", "request_monitoring_disabled_title": "Request monitoring is disabled", "request_monitoring_unavailable_title": "Request monitoring service unavailable", - "request_monitoring_disabled_body": "CPA Manager Plus is not currently running the request collector. Enable request monitoring in CPA Manager Plus Configuration when you need this page.", - "request_monitoring_service_unavailable_body": "Request monitoring is unavailable from this Manager Server. Check setup, collector status, and the bound CPA connection.", + "request_monitoring_disabled_body": "CPA Manager Plus is not currently reading request records. Enable request monitoring in CPA Manager Plus Configuration when you need this page.", + "request_monitoring_service_unavailable_body": "Request monitoring is unavailable from this Manager Server. Check setup, request-record reading status, and the bound CPA connection.", "request_monitoring_not_configured_body": "Request monitoring is available only from the Manager Server-hosted panel after setup. CPA panel mode does not support usage-statistics features.", - "empty_diagnostics_body": "If CPA has produced requests but this view is still empty, confirm usage publishing is enabled, check Manager Server /status, and make sure only one Manager Server consumes the same CPA usage queue.", + "empty_diagnostics_body": "If CPA has requests but this page is still empty, confirm request monitoring is enabled, Manager Server is running, and only one Manager Server is reading usage from the same CPA.", "empty_diagnostics_link": "Why is this empty?", "zero_token_notice_title": "Zero-token model calls detected", "zero_token_notice_body": "There are {{count}} zero-token model calls in the current range ({{models}}). These models are usually not billed purely by tokens, so cost remains a partial estimate.", @@ -1632,7 +1704,7 @@ "cache_reuse_rate": "Hit rate", "cache_hit_rate": "Hit rate", "reasoning_share": "Reasoning Share", - "hot_endpoint": "Hot Endpoint", + "hot_endpoint": "Busy request path", "accounts_suffix": "accounts", "groups_suffix": "groups", "success_suffix": "successful", @@ -1640,7 +1712,7 @@ "approx_task_suffix": "approx tasks", "recent_window_suffix": "last 30 minute window", "failure_suffix": "failed", - "metric_scope_note": "Top-level KPIs use model-call semantics inside the selected window. Task metrics are approximate buckets grouped by timestamp + source + credential.", + "metric_scope_note": "Top metrics count model requests in the current filter. Task counts are estimated by grouping request time, source, and account.", "last_sync": "Last Sync", "last_sync_short": "Sync", "requests_trend_title": "Call & Token Trend", @@ -1654,7 +1726,7 @@ "hourly_tokens_title": "Hourly Token Distribution", "hourly_tokens_desc": "Shows which hours absorb the most token volume for capacity and spend analysis.", "channel_table_title": "Channel Execution Board", - "channel_table_desc": "Merges usage, openai-compatibility, and auth-files to inspect channel throughput, success rate, and latency.", + "channel_table_desc": "Summarizes request volume, success rate, and latency by channel so you can see which channels are stable.", "model_table_title": "Model Performance Ranking", "model_table_desc": "Tracks request volume, success rate, token usage, and latency by model.", "failure_table_title": "Failure Source Analysis", @@ -1713,10 +1785,10 @@ "account_overview_enabled_state_disabled": "Disabled", "account_overview_enabled_state_mixed": "Partially enabled", "account_overview_enabled_state_unavailable": "No status", - "account_overview_linked_files": "{{count}} linked auth files", + "account_overview_linked_files": "{{count}} linked login files", "account_overview_health_label": "Health", "account_overview_health_hint": "Based on request statistics in the current filter scope.", - "account_overview_status_hint": "Current account enabled state summarized from linked auth files.", + "account_overview_status_hint": "Current account enabled state summarized from linked login files.", "account_overview_success_rate_hint": "Successful calls divided by total calls in the current filter scope.", "account_overview_scope_current_filters": "Scope: current filters", "account_overview_scope_range": "Scope: {{range}}", @@ -1738,7 +1810,7 @@ "codex_inspection_eyebrow": "Codex Account Inspection", "codex_inspection_title": "Codex Account Inspection", "codex_inspection_desc": "Inspect Codex accounts independently for quota exhaustion, invalid accounts, and disabled recovery. Delete, disable, and enable actions are executed only after confirmation.", - "codex_inspection_back": "Back to Auth Files", + "codex_inspection_back": "Back to login files", "codex_inspection_run": "Start Inspection", "codex_inspection_run_local": "Start Local Inspection", "codex_inspection_running": "Inspecting...", @@ -1747,7 +1819,7 @@ "codex_inspection_mode_server": "Server Inspection", "codex_inspection_mode_current": "Current method: {{mode}}", "codex_inspection_mode_local_desc": "Runs through the local flow behind the current browser connection and depends on this connection and browser session. Use it for quick checks on the current device.", - "codex_inspection_mode_server_desc": "Submits the inspection to Manager Server. It supports scheduled runs, background execution, and persisted history for batch checks and audit.", + "codex_inspection_mode_server_desc": "Runs the account check on Manager Server. It supports schedules, background runs, and history, making it better for batch checks.", "codex_inspection_execution_mode": "Execution Method", "codex_inspection_pause": "Pause", "codex_inspection_resume": "Resume Inspection", @@ -1771,7 +1843,7 @@ "codex_inspection_logs_collapsed": "Logs collapsed, {{count}} entries in total.", "codex_inspection_results_title": "Inspection Results", "codex_inspection_results_desc": "Summarize account status, invalid-account errors, and the next suggested actions. Supports batch and row-level execution.", - "codex_inspection_observed_header_evidence": "Latest usage header", + "codex_inspection_observed_header_evidence": "Latest service response", "codex_inspection_pending_actions": "Pending Actions", "codex_inspection_preview_title": "Planned Action Preview", "codex_inspection_sampled_hint": "Random sampling is enabled, so only sampled accounts generate suggested actions.", @@ -1780,7 +1852,7 @@ "codex_inspection_pending_reauth_count": "Pending re-login {{count}}", "codex_inspection_delete_reauth_count": "Delete re-login accounts {{count}}", "codex_inspection_file_name": "File Name", - "codex_inspection_current_state": "Auth File State", + "codex_inspection_current_state": "Login file state", "codex_inspection_http_status": "HTTP Status", "codex_inspection_used_percent": "Quota Usage", "codex_inspection_next_action": "Suggested Action", @@ -1810,9 +1882,9 @@ "codex_inspection_execute_single_body": "Execute “{{action}}” for account {{account}}?", "codex_inspection_delete_reauth_now": "Confirm Delete", "codex_inspection_delete_reauth_confirm_title": "Delete Re-login Accounts", - "codex_inspection_delete_reauth_confirm_body": "{{count}} auth files for accounts that require re-login will be deleted. Deletion is performed by file name and cannot be undone. Continue?", - "codex_inspection_delete_reauth_single_title": "Delete Auth File", - "codex_inspection_delete_reauth_single_body": "Delete auth file {{file}} for account {{account}}? This cannot be undone.", + "codex_inspection_delete_reauth_confirm_body": "{{count}} login files for accounts that require re-login will be deleted. Deletion is performed by file name and cannot be undone. Continue?", + "codex_inspection_delete_reauth_single_title": "Delete login file", + "codex_inspection_delete_reauth_single_body": "Delete login file {{file}} for account {{account}}? This cannot be undone.", "codex_inspection_action_keep": "Keep", "codex_inspection_action_delete": "Delete", "codex_inspection_action_disable": "Disable", @@ -1833,7 +1905,7 @@ "codex_inspection_config_overview_title": "Current inspection config", "codex_inspection_config_overview_edit": "Edit config", "codex_inspection_settings_title": "Codex Inspection Settings", - "codex_inspection_settings_desc": "Configure the target type, concurrency, timeout, and threshold for Codex account inspection. Saved values are used for future runs in this browser.", + "codex_inspection_settings_desc": "Choose which accounts to check, how many to check at once, the timeout, and the quota threshold. Saved values are used for future checks in this browser.", "codex_inspection_settings_saved": "Inspection settings saved", "codex_inspection_settings_reset": "Inspection settings restored to defaults", "codex_inspection_settings_reset_button": "Restore Defaults", @@ -1914,7 +1986,7 @@ "server_codex_inspection_run_confirm_title": "Run server inspection now", "server_codex_inspection_run_confirm_body": "The server will inspect accounts with the current configuration. If automatic handling is enabled, accounts may be disabled or deleted directly. Continue?", "server_codex_inspection_config_title": "Server Inspection Config", - "server_codex_inspection_config_desc": "Configure the backend scheduled task trigger, probe concurrency, timeout, and automatic handling strategy.", + "server_codex_inspection_config_desc": "Configure the schedule, check concurrency, timeout, and automatic handling strategy.", "server_codex_inspection_config_saved": "Server inspection config saved", "server_codex_inspection_config_invalid": "Check the inspection config. Time points must be HH:mm and numeric values must be in range.", "server_codex_inspection_open_config": "Config", @@ -1971,7 +2043,7 @@ "server_codex_inspection_auto_mode_note_delete": "Scheduled runs automatically enable recovered accounts, delete invalid accounts, and disable over-threshold accounts.", "server_codex_inspection_trigger_manual": "Manual trigger", "server_codex_inspection_trigger_scheduled": "Scheduled trigger", - "server_codex_inspection_total_files": "{{count}} auth files", + "server_codex_inspection_total_files": "{{count}} login files", "server_codex_inspection_action_total_value": "{{count}} suggested actions", "server_codex_inspection_keep_count": "{{count}} kept", "server_codex_inspection_last_run": "Latest Run", @@ -2007,8 +2079,8 @@ "server_codex_inspection_action_status_failed": "Execution failed", "server_codex_inspection_action_status_skipped": "Skipped", "server_codex_inspection_action_status_needs_review": "Needs review", - "server_codex_inspection_action_needs_review_hint": "Resolve this conflict in auth file management", - "server_codex_inspection_file_level_action_hint": "Same-name auth files are merged into one file-level action", + "server_codex_inspection_action_needs_review_hint": "Resolve this conflict in login file management", + "server_codex_inspection_file_level_action_hint": "Same-name login files are merged into one action", "server_codex_inspection_logs_desc": "Shows persisted logs for this server inspection. Error details are kept in full.", "server_codex_inspection_service_unavailable": "Server Codex inspection is available only from the Manager Server-hosted panel.", "server_codex_inspection_connection_required": "Connect with a management key before viewing server inspection.", @@ -2026,8 +2098,8 @@ "model_pricing_desc": "Maintain model pricing directly on the monitoring page and update account, model, and live-log spend instantly.", "price_storage_hint": "Pricing is saved to SQLite when available; legacy browser-local pricing migrates automatically.", "workbench_title": "Request Workbench", - "workbench_desc": "Buckets recent calls into approximate task groups so you can review participating models, outcomes, cost, and endpoint paths together.", - "workbench_rows": "Task Buckets", + "workbench_desc": "Groups related recent calls so you can review the models, results, cost, and request paths for one task together.", + "workbench_rows": "Tasks", "recent_failures": "Recent Failures", "recent_failures_short": "Failures", "filtering_hint": "Filtered by “{{query}}”, matching {{count}} model calls.", @@ -2074,7 +2146,7 @@ "column_failure_rate": "Failure Rate", "column_last_seen": "Last Seen", "column_result": "Result", - "column_endpoints": "Endpoints", + "column_endpoints": "Request paths", "this_call_usage": "This Call Usage", "this_call_usage_short": "Usage", "this_call_cost": "This Call Cost", @@ -2134,6 +2206,7 @@ "log_content": "Log Content", "loading": "Loading logs...", "load_error": "Failed to load logs", + "clear_confirm_title": "Clear Logs", "clear_confirm": "Are you sure you want to clear all logs? This action cannot be undone!", "clear_success": "Logs cleared successfully", "download_success": "Logs downloaded successfully", @@ -2194,7 +2267,7 @@ "status_saved": "Configuration saved", "status_save_failed": "Save failed", "save_success": "Configuration saved successfully", - "error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.", + "error_yaml_not_supported": "Server did not return YAML. Verify that /config.yaml is available.", "visual_mode_unavailable": "Visual editor unavailable until YAML syntax is fixed", "visual_mode_unavailable_short": "YAML issue", "validation_blocked_short": "Fix errors", @@ -2221,13 +2294,13 @@ }, "manager": { "title": "CPA Manager Plus Configuration", - "description": "Manage CPA Manager Plus runtime settings separately from CPA configuration.", - "boundary_hint": "These settings are saved for CPAM itself. CPA usage statistics and queue retention still belong to CPA configuration.", + "description": "Manage CPA Manager Plus settings separately from CPA configuration.", + "boundary_hint": "These settings are saved for CPAMP itself. CPA usage statistics and data retention still belong to CPA configuration.", "load_failed": "Failed to load CPA Manager Plus configuration", "save_success": "CPA Manager Plus configuration saved", "number_invalid": "{{label}} must be a positive integer", "service_base_required": "Manager Server URL could not be resolved. Open the Manager Server-hosted panel.", - "poll_interval_retention_error": "The collection polling interval must be less than or equal to the CPA usage queue retention window", + "poll_interval_retention_error": "The read interval cannot be longer than the time CPA keeps usage data", "runtime_title": "Runtime", "runtime_embedded": "Same-origin embedded Manager Server", "runtime_external": "CPA panel mode", @@ -2260,9 +2333,9 @@ "cpa_management_key_hide": "Hide key", "cpa_management_key_clear": "Clear input", "cpa_management_key_saving": "Validating the new key against CPA…", - "cpa_connection_risk_inline": "Changing the CPA URL or management key affects proxying, usage collection, and monitoring data. Save only after confirming the target CPA is correct.", + "cpa_connection_risk_inline": "Changing the CPA URL or management key affects proxying and monitoring data. Save only after confirming the target CPA is correct.", "cpa_connection_risk_title": "Confirm CPA Connection Change", - "cpa_connection_risk_message": "You are changing the Manager Server CPA connection. Current CPA: {{currentBase}}; CPA after save: {{nextBase}}. This affects proxying, usage collection, and monitoring data. Confirm the target CPA URL and Management Key are exactly correct before continuing.", + "cpa_connection_risk_message": "You are changing the Manager Server CPA connection. Current CPA: {{currentBase}}; CPA after save: {{nextBase}}. This affects proxying and monitoring data. Confirm the target CPA URL and Management Key are exactly correct before continuing.", "cpa_connection_risk_confirm": "Confirm and Accept Risk", "current_cpa_base": "Current CPA URL", "bound_cpa_base": "Manager Server bound CPA", @@ -2280,17 +2353,17 @@ "status_binding_required": "Manager Server setup reset required", "status_binding_required_short": "Reset needed", "request_monitoring_title": "Request Monitoring", - "request_monitoring_hint": "When enabled, CPA usage statistics are enabled and the collector starts. When disabled, the CPAM collector stops but CPA usage statistics and the CPA queue are not cleared.", + "request_monitoring_hint": "When enabled, CPA usage statistics are turned on and request records are read. When disabled, reading stops, but CPA usage statistics and retained CPA data are not cleared.", "request_monitoring_enabled": "Enable request monitoring", "request_monitoring_dependency": "Complete Manager Server setup and keep the bound CPA connection before enabling request monitoring.", - "request_monitoring_queue_note": "If CPA usage statistics remain enabled, re-enabling request monitoring within the CPA queue retention window may collect data retained while the CPAM collector was stopped.", + "request_monitoring_queue_note": "If CPA usage statistics remain enabled, turning request monitoring back on shortly after may read data CPA retained while monitoring was off.", "collector_mode": "Collection Mode", "collector_mode_auto": "Auto", "collector_mode_http": "HTTP", "collector_mode_resp": "RESP", "collector_mode_subscribe": "RESP Pub/Sub", - "poll_interval_ms": "Collection Polling Interval (ms)", - "poll_interval_hint": "Must be less than or equal to the CPA usage queue retention window. Current validation uses {{seconds}} seconds.", + "poll_interval_ms": "Read interval (ms)", + "poll_interval_hint": "The read interval cannot be longer than the time CPA keeps usage data. Current validation uses {{seconds}} seconds.", "batch_size": "Max Items per Batch", "query_limit": "Usage Query Limit", "config_source": "Config Source", @@ -2298,7 +2371,7 @@ "config_source_db": "SQLite", "config_source_none": "Not saved", "cpa_usage_enabled": "CPA Usage Statistics", - "cpa_retention": "CPA Queue Retention", + "cpa_retention": "CPA data retention", "cpa_retention_value": "{{seconds}}s" }, "visual": { @@ -2347,7 +2420,7 @@ "commercial_mode": "Commercial Mode", "commercial_mode_desc": "Disable high-overhead middleware to support high concurrency", "usage_statistics_enabled": "Enable Usage Statistics", - "usage_statistics_enabled_desc": "Allow CPA to publish request usage events for Manager Server to consume continuously", + "usage_statistics_enabled_desc": "Allow CPA to record request usage so Manager Server can read it continuously", "logging_to_file": "Log to File", "logging_to_file_desc": "Save logs to files", "plugins_enabled": "Enable Plugin System", @@ -2358,7 +2431,7 @@ "plugin_store_sources_desc": "One plugin store manifest URL per line. Applies to CPA's plugins.store-sources array.", "plugin_store_auth": "Plugin Store Authentication Rules", "plugin_store_auth_desc": "Configure authentication headers for private registries, GitHub Release metadata, or download URLs.", - "plugin_store_auth_hint": "Enter environment variable names only, not real tokens. The backend reads values from its runtime environment.", + "plugin_store_auth_hint": "Enter environment variable names only, not real tokens. The service reads values from its startup environment.", "store_auth_empty": "No authentication rules configured. Public plugin sources do not need rules.", "store_auth_rule": "Authentication rule", "store_auth_add": "Add authentication rule", @@ -2366,7 +2439,7 @@ "store_auth_type": "Authentication type", "store_auth_apply_to": "Apply to", "store_auth_apply_to_hint": "When empty, the rule applies to all request types.", - "store_auth_apply_registry": "Registry", + "store_auth_apply_registry": "Plugin list", "store_auth_apply_metadata": "Metadata", "store_auth_apply_artifact": "Artifact", "store_auth_type_none": "No authentication", @@ -2382,8 +2455,8 @@ "store_auth_allow_insecure": "Allow insecure HTTP URLs", "logs_max_size": "Log File Size Limit (MB)", "error_logs_max_files": "Retained Error Log Files", - "redis_usage_queue_retention": "Usage Queue Retention (seconds)", - "redis_usage_queue_retention_hint": "CPA defaults to 60 seconds and allows up to 3600 seconds. Leave empty to use the default. Keep Manager Server running continuously.", + "redis_usage_queue_retention": "Usage data retention (seconds)", + "redis_usage_queue_retention_hint": "CPA keeps data for 60 seconds by default and allows up to 3600 seconds. Leave empty to use the default. Keep Manager Server running continuously.", "antigravity_signature_cache": "Enable Antigravity Signature Cache", "antigravity_signature_cache_desc": "Prefer and validate cached thinking-block signatures", "antigravity_signature_strict": "Strict Bypass Signature Validation", @@ -2417,7 +2490,7 @@ "auth_auto_refresh_workers": "Auth Auto-refresh Workers", "auth_auto_refresh_workers_hint": "When greater than 0, overrides the default worker count (16)", "disable_image_generation": "Disable Image Generation", - "disable_image_generation_hint": "false enables it; true disables all image generation; chat only disables non-image endpoint injection", + "disable_image_generation_hint": "false enables it; true disables all image generation; chat only disables adding images to regular chat requests", "disable_image_generation_false": "false (enabled)", "disable_image_generation_true": "true (disabled everywhere)", "disable_image_generation_chat": "chat (disable chat injection only)", @@ -2459,7 +2532,7 @@ }, "payload": { "title": "Payload Configuration", - "description": "Default values, raw JSON rules, override rules, and filter rules", + "description": "Set default values, overrides, and filters for request parameters", "default_rules": "Default Rules", "default_rules_desc": "Use these default values when parameters are not specified in the request", "default_raw_rules": "Default Raw Rules", @@ -2469,7 +2542,7 @@ "override_raw_rules": "Override Raw Rules", "override_raw_rules_desc": "Always overwrite parameter values as raw JSON fragments, useful for response_format, schemas, and other complex fields", "filter_rules": "Filter Rules", - "filter_rules_desc": "Pre-filter upstream request body via JSON Path, automatically remove non-compliant/redundant parameters (Request Sanitization)" + "filter_rules_desc": "Remove unsupported or unnecessary parameters before forwarding, based on JSON paths" } }, "api_keys": { @@ -2581,8 +2654,8 @@ "cpa_connection_required_for_monitoring": "CPA URL and Management Key are required when request monitoring is enabled.", "management_api_validation_failed": "CPA Management API validation failed. Check the CPA URL and Management Key.", "management_api_config_failed": "Failed to read CPA configuration from the Management API.", - "cpa_usage_retention_invalid": "CPA usage queue retention must be greater than 0.", - "poll_interval_exceeds_retention": "The collection polling interval must be less than or equal to the CPA usage queue retention window.", + "cpa_usage_retention_invalid": "CPA usage data retention must be greater than 0.", + "poll_interval_exceeds_retention": "The read interval cannot be longer than the time CPA keeps usage data.", "invalid_time_zone": "Failed to save the time zone. Check the time zone name or make sure tzdata is installed in the runtime environment.", "enable_cpa_usage_statistics_failed": "Failed to enable CPA usage statistics.", "setup_env_managed": "Setup is managed by environment variables.", @@ -2594,7 +2667,7 @@ "model_price_sync_failed": "Model price sync failed.", "api_key_aliases_required": "API key aliases are required.", "api_key_alias_duplicate": "API key alias already exists.", - "method_not_allowed": "This operation is not supported by the current endpoint." + "method_not_allowed": "This operation is not supported by the current service path." }, "usage_analytics": { "title": "Usage Analytics", @@ -2637,7 +2710,7 @@ "analysis_entry_api_keys": "Client Key Analysis", "analysis_entry_api_keys_desc": "Find concentrated, failed, or abnormal client keys.", "analysis_entry_credentials": "Credential Analysis", - "analysis_entry_credentials_desc": "Review auth file usage, provider health, and estimated quota.", + "analysis_entry_credentials_desc": "Review login-file usage, provider health, and estimated quota.", "analysis_entry_heatmap": "Usage Heatmap", "analysis_entry_heatmap_desc": "Locate peak periods, cost windows, and failure-rate anomalies.", "filter_metric": "Metric", @@ -2664,7 +2737,7 @@ "token_structure_title": "Token Structure", "token_structure_hint": "Input, output, cached, and reasoning tokens stacked by time bucket.", "entity_trend_title": "Entity Trend", - "entity_trend_hint": "Top entity curves are approximated from each entity's overall share — not real per-entity series.", + "entity_trend_hint": "Top item trends are estimated from each item's overall share; they are not exact per-item time series.", "selected_credential_trend_title": "Selected Credential Trend", "selected_credential_trend_hint": "Shows only the selected credential's real time-bucket trend without share-based estimation.", "insights_title": "Usage Insights", @@ -2694,7 +2767,7 @@ "quota_status_warning": "Warning", "quota_status_exhausted": "Exhausted", "heatmap_matrix_title": "Entity / Model Matrix", - "heatmap_matrix_hint": "Compare API Key, auth file, or provider usage against model combinations.", + "heatmap_matrix_hint": "Compare API keys, login files, or providers against model combinations.", "hot_combinations_title": "Hot Combinations", "abnormal_time_points_title": "Abnormal Time Points", "view_exception_combinations": "View combinations", @@ -2738,9 +2811,9 @@ "hide_advanced_filters": "Hide advanced filters", "filter_provider": "Provider", "filter_auth_file": "Auth file", - "filter_all_auth_files": "All auth files", + "filter_all_auth_files": "All login files", "filter_search": "Search usage records", - "filter_search_placeholder": "Search request ID, model, API key, credential, provider, endpoint, or failure", + "filter_search_placeholder": "Search request ID, model, API key, account, provider, request path, or failure", "filter_search_clear": "Clear search", "filter_latency": "Latency", "latency_all": "All latency", @@ -2810,8 +2883,8 @@ "overview_trend_hint": "Requests, tokens, and cost are shown together for the selected time range.", "health_trend_title": "Request Health Trend", "anomaly_points_title": "Anomaly Time Buckets", - "anomaly_points_hint": "Backend-detected buckets based on request, cost, token, cache, failure-rate, and latency changes.", - "drilldown_preview_title": "Request Drilldown Preview", + "anomaly_points_hint": "Unusual time periods detected from changes in requests, cost, tokens, cache, failure rate, and latency.", + "drilldown_preview_title": "Request Sample Preview", "drilldown_preview_hint": "After selecting a time bucket, real request samples from that bucket are shown here.", "drilldown_preview_empty": "Click a time bucket in the trend chart to preview request samples.", "model_overview_title": "Model Usage Overview", @@ -2939,7 +3012,7 @@ "quota_management": { "title": "Quota Management", "description": "Monitor OAuth quota status for Antigravity, Codex, Claude, Kimi, and xAI credentials.", - "refresh_files": "Refresh auth files", + "refresh_files": "Refresh login files", "refresh_files_and_quota": "Refresh files & quota", "refresh_all_credentials": "Refresh all credentials", "card_idle_hint": "Use the top \"Refresh all credentials\" button to fetch the latest quota data.", @@ -2987,7 +3060,7 @@ }, "plugin_management": { "title": "Plugin Management", - "description": "Review discovered and registered plugins, then manage instance toggles, config fields, and resource links.", + "description": "Review installed and available plugins, then manage status, settings, and links.", "refresh": "Refresh", "install_plugin": "Install Plugin", "tabs_aria_label": "Plugin management views", @@ -2995,12 +3068,12 @@ "tab_store": "Plugin Store", "load_failed": "Failed to load plugins", "config_load_failed": "Failed to read plugin config", - "config_not_found": "Unable to read plugin config because the backend could not find this plugin.", - "unsupported_backend": "The current backend does not expose the plugin management API. Use a newer backend build that includes plugin management endpoints, then restart the service.", + "config_not_found": "Unable to read plugin settings because the current service could not find this plugin.", + "unsupported_backend": "The current service version does not support plugin management. Update CPA or Manager Server, then restart.", "global_status": "Global status", "global_enabled": "Enabled", "global_disabled": "Disabled", - "global_disabled_hint": "plugins.enabled is false, so enabled plugin instances will not become effective.", + "global_disabled_hint": "The global plugin switch is off, so individual plugins will not run even if enabled.", "plugins_dir": "Plugin directory", "installed_count": "Installed", "effective_count": "Effective", @@ -3015,17 +3088,17 @@ "meta_author": "Author", "meta_path": "Path", "discovered": "Discovered", - "effective": "Effective", + "effective": "Running", "search_placeholder": "Search plugin ID, name, author, or resource...", "search_label": "Search plugins", "no_plugins": "No plugins", - "no_plugins_desc": "No plugins were found in the plugin directory or runtime registry.", + "no_plugins_desc": "No available plugins were found in the current plugin directory.", "configured": "Configured", "not_configured": "Not configured", "registered": "Registered", "not_registered": "Not registered", "oauth": "OAuth", - "status_effective": "Effective", + "status_effective": "Running", "status_global_disabled": "Globally disabled", "status_disabled": "Disabled", "status_inactive": "Inactive", @@ -3057,14 +3130,14 @@ "config_title": "Configure {{name}}", "base_settings": "Base settings", "enabled": "Enabled", - "enabled_hint": "Controls the enabled field for this plugin instance under plugins.configs.", + "enabled_hint": "Controls whether this plugin is enabled.", "priority": "Priority", - "config_fields": "Config fields", - "no_config_fields": "This plugin does not declare visual config fields.", + "config_fields": "Settings", + "no_config_fields": "This plugin has no settings that can be edited here.", "select_placeholder": "Select", - "array_item_placeholder": "Enter array item", - "add_array_item": "Add array item", - "remove_array_item": "Remove array item", + "array_item_placeholder": "Enter an item", + "add_array_item": "Add item", + "remove_array_item": "Remove this item", "toggle_success": "Plugin status updated", "toggle_failed": "Failed to update plugin status", "save_success": "Plugin config saved", @@ -3073,25 +3146,25 @@ "invalid_priority": "Enter an integer priority", "invalid_number": "Enter a valid number", "invalid_integer": "Enter a valid integer", - "invalid_json": "Enter valid JSON", - "expected_array": "Enter a JSON array", + "invalid_json": "Enter valid JSON content", + "expected_array": "Enter a JSON list", "expected_object": "Enter a JSON object", - "invalid_enum": "Choose one of the declared enum values" + "invalid_enum": "Choose one of the available options" }, "plugin_store": { "title": "Plugin Store", - "description": "Browse the plugin registry, then install or update plugins for the current backend.", + "description": "Browse available plugins, then install or update them for the current service.", "refresh": "Refresh", "retry": "Retry", "load_failed": "Failed to load the plugin store", "loading_title": "Loading plugin store", "loading_desc": "Reading plugin sources and installation status", - "unsupported_backend": "The current backend does not expose the plugin store API. Use a newer backend build that includes plugin store endpoints, then restart the service.", - "registry_failed": "Failed to reach the plugin registry", + "unsupported_backend": "The current service version does not support the plugin store. Update CPA or Manager Server, then restart.", + "registry_failed": "Failed to reach the plugin source", "global_status": "Global status", "global_enabled": "Enabled", "global_disabled": "Disabled", - "global_disabled_hint": "plugins.enabled is false, so installed plugins will not become effective.", + "global_disabled_hint": "The global plugin switch is off, so installed plugins will not run.", "plugins_dir": "Plugin directory", "stat_available": "Available", "sources": "Sources", @@ -3103,7 +3176,7 @@ "meta_platforms": "Platforms", "source_name": "Source: {{source}}", "cli_proxy_api_source": "CLIProxyAPI source", - "source_errors_title": "Source warnings", + "source_errors_title": "Plugin source warnings", "unknown_source": "Unknown source", "source_required": "This plugin exists in multiple store sources. Use a source-specific plugin card and try again.", "search_placeholder": "Search plugin ID, name, author, or tag...", @@ -3122,20 +3195,20 @@ "platforms": "{{platforms}}", "auth_required": "Authentication required", "auth_configured": "Authentication configured", - "auth_required_hint": "Configure plugins.store-auth before installing from this source or artifact URL.", + "auth_required_hint": "This plugin source needs access credentials before installation.", "install": "Install", "update": "Update", "manage": "Manage", "reinstall_plugin": "Reinstall", "delete_plugin": "Delete", "install_confirm_title": "Install plugin", - "install_confirm_message": "Download {{target}} from the plugin registry and install it into the local plugin directory?", - "install_security_warning_title": "Third-party plugins run with full backend privileges", - "install_security_warning_message": "Plugins execute code inside the proxy service and can read your credentials and traffic. Install only plugins you trust, and be especially cautious with anything not published by the official router-for-me organization.", + "install_confirm_message": "Download {{target}} and install it into the local plugin directory?", + "install_security_warning_title": "Third-party plugins can access service data", + "install_security_warning_message": "Plugins run inside the proxy service and may read your credentials, requests, and responses. Install only plugins you trust, and be especially cautious with anything not published by the official router-for-me organization.", "update_confirm_title": "Update plugin", "update_confirm_message": "Download and install the latest version of {{target}}?", "install_version_label": "Version (optional)", - "install_version_hint": "Leave empty to use the backend default/latest version. Listed version: {{version}}.", + "install_version_hint": "Leave empty to use the service default or latest version. Listed version: {{version}}.", "install_version_latest": "Latest version", "reinstall_confirm_title": "Reinstall plugin", "reinstall_confirm_message": "This will delete {{target}} first, then reinstall it from the plugin store. If deletion succeeds but installation fails, the plugin will remain deleted. Continue?", @@ -3155,15 +3228,15 @@ "restart_required_banner": "Restart the service to apply the new version of: {{plugins}}", "open_repository": "Open repository", "open_homepage": "Open homepage", - "no_plugins": "Registry is empty", - "no_plugins_desc": "The plugin registry did not return any plugins.", + "no_plugins": "No available plugins", + "no_plugins_desc": "The current plugin source did not return any plugins.", "no_matches": "No matching plugins", "no_matches_desc": "No plugins match the current search or filter.", "clear_filters": "Clear filters", "gate_title": "Install {{name}}", "gate_title_update": "Update {{name}}", - "gate_warning": "Third-party plugins run with full backend access.", - "gate_effect_runs_code": "This plugin will run third-party code inside the proxy backend and may access credentials, requests, and responses.", + "gate_warning": "Third-party plugins can access service data.", + "gate_effect_runs_code": "This plugin will run third-party code inside the proxy service and may access credentials, requests, and responses.", "gate_effect_no_review": "It is not published or reviewed by the official router-for-me organization. Future versions may change behavior.", "gate_effect_restart": "Installing it changes the local plugin directory and may require a service restart.", "gate_untrusted_alert": "Continue only if you fully trust this plugin's author, repository, and source.", @@ -3180,12 +3253,12 @@ "title": "Plugin Page", "page_count": "{{count}} pages", "load_failed": "Failed to load plugin page", - "unsupported_backend": "The current backend does not expose the plugin page API. Use a newer backend build that includes plugin management endpoints, then restart the service.", + "unsupported_backend": "The current service version does not support plugin pages. Update CPA or Manager Server, then restart.", "unavailable": "Plugin page unavailable", "not_found": "Plugin page not found", - "not_found_desc": "This plugin page is not declared by the current backend plugin list.", + "not_found_desc": "The current service does not declare this plugin page.", "empty_src": "Plugin page URL is empty", - "empty_src_desc": "This plugin menu does not provide an embeddable page URL." + "empty_src_desc": "This plugin menu does not provide a page URL that can be opened." }, "system_info": { "title": "System Info", @@ -3199,13 +3272,13 @@ "not_loaded": "Not Loaded", "seconds_ago": "seconds ago", "models_title": "Available Models", - "models_desc": "Shows the /models response and uses saved API keys for auth automatically.", + "models_desc": "Shows the models available from the current service and automatically uses saved API keys.", "models_loading": "Loading available models...", "models_empty": "No models returned by /models", "models_error": "Failed to load model list", "models_count": "{{count}} available models", "version_check_title": "Update Check", - "version_check_desc": "Call the /latest-version endpoint to compare with the server version and see if an update is available.", + "version_check_desc": "Check the server version and show whether an update is available.", "version_current_label": "Current version", "version_latest_label": "Latest version", "version_check_button": "Check for updates", @@ -3280,6 +3353,7 @@ "openai_test_model_required": "Please select a model to test", "data_refreshed": "Data refreshed successfully", "connection_required": "Please establish connection first", + "load_failed": "Load failed", "refresh_failed": "Refresh failed", "save_failed": "Save failed", "update_failed": "Update failed", @@ -3331,11 +3405,11 @@ "accountPolicy": { "section_title": "Account Processing Policy", "section_hint": "Only affects new request-monitoring events. Queued or running tasks continue to finish.", - "runtime_note": "Unlocked switches are saved to this Manager Server and take effect without restarting. Environment variables still lock their matching switches.", + "runtime_note": "Switches not locked by the startup environment are saved to this Manager Server and take effect without restarting. Environment-locked switches still need service environment changes.", "group_quota_title": "Quota Handling", - "group_quota_description": "Controls how auth files are cooled down and recovered after request monitoring detects quota exhaustion.", + "group_quota_description": "Controls how login files are temporarily paused and restored after request monitoring detects exhausted quota.", "group_auth_issues_title": "Auth Issue Handling", - "group_auth_issues_description": "Controls how revoked tokens, unavailable auth, and deactivated workspaces enter the pending handling flow.", + "group_auth_issues_description": "Controls how expired logins, unavailable accounts, and deactivated workspaces are recorded and handled.", "refresh": "Refresh", "load_failed_title": "Load failed", "load_failed": "Load failed: {{message}}", @@ -3358,55 +3432,55 @@ "source_env": "Using environment variable", "source_database": "Using UI setting", "codexQuotaCooldown_title": "Codex Quota Cooldown Handling", - "codexQuotaCooldown_summary": "Temporarily disables a Codex auth file after confirmed short-window usage exhaustion, then recovers it after reset.", + "codexQuotaCooldown_summary": "Temporarily pauses the matching Codex login file after short-term quota runs out, then enables it again after quota recovers.", "codexQuotaCooldown_toggle": "Enable quota cooldown handling", - "codexQuotaCooldown_description": "When request monitoring confirms a Codex short-window usage limit, it temporarily disables the auth file and auto-recovers after the reset time.", + "codexQuotaCooldown_description": "When request monitoring confirms short-term Codex quota is exhausted, it temporarily pauses the matching login file and enables it again after quota recovers.", "codexQuotaCooldown_behavior": [ "Handles Codex only", - "Handles only 429 + usage_limit_reached", - "Requires resets_at or resets_in_seconds", - "Temporarily disables the auth file", + "Handles only clear quota-exhausted errors", + "Requires a known recovery time", + "Temporarily pauses the login file", "Auto-recovers after the reset time", - "Recovers only CPAMP-owned disables", - "Does not recover manually disabled auth files" + "Restores only accounts paused by CPAMP", + "Does not restore login files you disabled manually" ], - "authIssueQueue_title": "Auth Issue Pending Queue", - "authIssueQueue_summary": "Records high-confidence auth issues as candidates for manual handling on the Auth Issues page.", - "authIssueQueue_toggle": "Record auth issue candidates", - "authIssueQueue_description": "When request monitoring detects a revoked token, unavailable auth, or a deactivated workspace, it records a candidate here for manual handling.", + "authIssueQueue_title": "Auth Issue To-Do List", + "authIssueQueue_summary": "Records clear login or account problems so you can handle them on the Auth Issues page.", + "authIssueQueue_toggle": "Record auth issues", + "authIssueQueue_description": "When request monitoring detects an expired login, unavailable account, or deactivated workspace, it records it for manual handling.", "authIssueQueue_behavior": [ - "Records candidates on auth issues", + "Records clear auth issues", "Handled manually on the Auth Issues page", - "Does not record new candidates when disabled", - "Existing pending candidates can still be processed" + "Does not record new issues when disabled", + "Existing pending issues can still be processed" ], "authIssueAutoDisable_title": "Auto-Disable Problem Auth Files", - "authIssueAutoDisable_summary": "Automatically disables high-confidence delete / reauth candidates first, while keeping the manual review flow.", - "authIssueAutoDisable_toggle": "Auto-disable high-confidence problem auth files", - "authIssueAutoDisable_description": "When enabled, high-confidence candidates auto-disable the auth file first and still require your manual handling.", + "authIssueAutoDisable_summary": "Automatically pauses login files for accounts that are clearly invalid or need re-login, while keeping manual confirmation.", + "authIssueAutoDisable_toggle": "Auto-pause clearly problematic login files", + "authIssueAutoDisable_description": "When enabled, clearly problematic accounts first pause the matching login file and still require your manual handling.", "authIssueAutoDisable_behavior": [ - "Auto-disables only high-confidence delete / reauth candidates", - "Does not auto-disable review", - "Ignores ordinary payment/quota 402", - "Re-reads auth files and verifies identity before disabling", + "Auto-pauses only accounts that clearly need deletion or re-login", + "Does not auto-pause review-only accounts", + "Ignores ordinary payment or quota errors", + "Re-reads login files and verifies the account before pausing", "Only disables, never deletes", "Never auto-recovers", "Still requires your manual handling" ], - "authIssueAutoDisable_dependency_note": "Enable the Auth Issue Pending Queue first.", - "authIssueAutoDisable_configured_dependency_note": "Enabled, pending the Auth Issue Pending Queue.", - "authIssueAutoDisable_confirm_title": "Enable problem auth file auto-disable?", - "authIssueAutoDisable_confirm_body": "When enabled, Manager Server disables matching auth files after request monitoring detects high-confidence auth issues.", - "authIssueAutoDisable_confirm_disable_only": "It only disables matching auth files and never deletes them.", - "authIssueAutoDisable_confirm_no_recovery": "It never auto-recovers them; you still handle the candidate on the Auth Issues page.", - "authIssueAutoDisable_confirm_requires_queue": "This capability depends on the Auth Issue Pending Queue being effective.", + "authIssueAutoDisable_dependency_note": "Enable the Auth Issue To-Do List first.", + "authIssueAutoDisable_configured_dependency_note": "Enabled, pending the Auth Issue To-Do List.", + "authIssueAutoDisable_confirm_title": "Enable auto-pause for problem login files?", + "authIssueAutoDisable_confirm_body": "When enabled, Manager Server pauses the matching login file after request monitoring detects a clear auth issue.", + "authIssueAutoDisable_confirm_disable_only": "It only pauses matching login files and never deletes them.", + "authIssueAutoDisable_confirm_no_recovery": "It never restores them automatically; you still handle the issue on the Auth Issues page.", + "authIssueAutoDisable_confirm_requires_queue": "This capability depends on the Auth Issue To-Do List being effective.", "authIssueAutoDisable_confirm_button": "Enable auto-disable", - "authIssueAutoDisable_confirm": "Enable auth issue auto-disable? It only disables matching auth files, never deletes them, never auto-recovers them, and still requires manual handling.", + "authIssueAutoDisable_confirm": "Enable auth issue auto-pause? It only pauses matching login files, never deletes them, never restores them automatically, and still requires manual handling.", "open_auth_issues": "Open Auth Issues", "effective_label": "Effective", "advanced_summary": "Details", - "env_locked_hint": "This switch is locked by an environment variable. Update the service environment variable and restart to change it.", + "env_locked_hint": "This switch is locked by the service startup environment. Update the environment variable and restart to change it.", "env_locked_reason": "Locked by the {{envKey}} environment variable. Update it and restart the service to change this switch.", - "gating_note": "Disabling a switch only stops new request-monitoring events from being processed. Tasks already queued or in progress will continue to finish." + "gating_note": "Disabling a switch only affects new problems found later. Tasks already queued or in progress will continue to finish." } } diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json index 4f34010c6..1b132148f 100644 --- a/apps/web/src/i18n/locales/ru.json +++ b/apps/web/src/i18n/locales/ru.json @@ -12,6 +12,7 @@ "edit": "Редактировать", "add": "Добавить", "update": "Обновить", + "reset": "Сбросить", "refresh": "Обновить", "retry": "Повторить", "close": "Закрыть", @@ -49,6 +50,7 @@ "copy": "Копировать", "expand": "Развернуть", "collapse": "Свернуть", + "navigation": "Навигация", "status": "Статус", "action": "Действие", "custom_headers_label": "Пользовательские заголовки", @@ -85,7 +87,7 @@ "custom_connection_hint": "По умолчанию используется текущий URL. При необходимости замените его.", "cpa_connection_label": "URL подключения CPA", "cpa_connection_placeholder": "Напр.: http://127.0.0.1:8317", - "cpa_connection_hint": "Manager Server использует этот URL для доступа к CPA Management API и очереди RESP usage.", + "cpa_connection_hint": "Manager Server использует этот адрес для подключения к CPA и чтения данных о запросах.", "cpa_address_required": "Введите URL CPA", "usage_service_mode_hint": "Эта панель размещена через Manager Server. Введите URL CPA API.", "usage_service_configured_hint": "Эта панель размещена через настроенный Manager Server. После входа используется CPA-подключение, сохранённое на сервере.", @@ -100,7 +102,7 @@ "migration_detected_hint": "Обнаружены исторические данные. Сервер сохранит их и автоматически мигрирует старые настройки.", "cpa_management_key_label": "CPA Management Key", "cpa_management_key_placeholder": "Введите CPA Management Key", - "cpa_management_key_hint": "Используется только Manager Server для проверки и управления upstream CPA.", + "cpa_management_key_hint": "Используется только Manager Server для подключения к CPA и управления им.", "cpa_management_key_required": "Введите CPA Management Key", "setup_title": "Мастер настройки", "setup_steps": "Шаги настройки", @@ -113,10 +115,10 @@ "step_polling": "Интервал опроса", "step_review": "Проверка", "request_monitoring_enabled": "Включить мониторинг запросов", - "request_monitoring_enabled_hint": "При сохранении CPA usage statistics будут включены автоматически, а сборщик Manager Server будет запущен.", - "request_monitoring_disabled_hint": "Подключение CPA будет сохранено, но CPA usage statistics и сборщик не будут включены. Если CPA usage statistics включены в другом месте, повторное включение сборщика в пределах окна хранения очереди может собрать сохранённые события.", - "poll_interval_label": "Интервал опроса сборщика (мс)", - "poll_interval_hint": "Должен быть не больше окна хранения очереди CPA usage. При сохранении CPA usage statistics будут включены автоматически.", + "request_monitoring_enabled_hint": "При сохранении CPA начнёт записывать статистику, а Manager Server начнёт читать записи запросов.", + "request_monitoring_disabled_hint": "Подключение CPA будет сохранено, но новые данные мониторинга записываться не будут. Если CPA уже записывает статистику в другом месте, при быстром повторном включении здесь могут появиться недавно сохранённые записи.", + "poll_interval_label": "Интервал чтения (мс)", + "poll_interval_hint": "Интервал не должен быть больше времени, в течение которого CPA хранит данные о запросах. При сохранении статистика CPA будет включена.", "poll_interval_invalid": "Введите корректный интервал сбора", "show_key": "Показать ключ", "hide_key": "Скрыть ключ", @@ -182,27 +184,28 @@ "account_actions_short": "Проблемы", "config_management": "Панель конфигурации", "config_management_short": "Конфиг", + "system_config": "Системная конфигурация", "logs": "Просмотр логов", "logs_short": "Логи", "system_info": "Информация системы", "system_info_short": "Система" }, "account_actions": { - "eyebrow": "Auth Issue Queue", + "eyebrow": "Проблемы аккаунтов", "title": "Обработка проблем аутентификации", "back_to_monitoring": "Вернуться к мониторингу", - "description": "Когда мониторинг запросов обнаруживает отозванный OAuth token, недоступную аутентификацию или деактивированное рабочее пространство, CPA Manager Plus создаёт запись о проблеме аутентификации для подтверждения удаления, повторного включения, игнорирования или отметки как обработанной.", + "description": "Здесь собраны проблемы входа и аккаунтов, найденные мониторингом запросов: истёкший вход, ошибка авторизации или отключённое рабочее пространство. Вы можете удалить файл, включить его снова, проигнорировать проблему или отметить её обработанной.", "pending_count": "Ожидают", "visible_count": "В списке", "status_filter": "Фильтр статуса", - "search_placeholder": "Поиск аккаунта, файла, причины, ошибки Header или trace", + "search_placeholder": "Поиск аккаунта, файла, причины или текста ошибки", "filter_pending": "Ожидают", "filter_all": "Все", "filter_ignored": "Игнорируются", "filter_resolved": "Обработаны", "filter_deleted": "Удалены", "col_account": "Аккаунт", - "col_file": "Auth file", + "col_file": "Файл входа", "col_action": "Рекомендация", "col_reason": "Причина", "col_seen": "Первое / последнее", @@ -225,17 +228,17 @@ "evidence_title": "Данные сбоя", "no_evidence": "Нет данных для отображения.", "empty_title": "Нет проблем аутентификации", - "empty_desc": "Проблемы аутентификации появятся здесь, когда мониторинг запросов поймает token_revoked, invalidated oauth token или похожие ошибки аутентификации.", + "empty_desc": "Проблемы входа или авторизации, найденные мониторингом запросов, появятся здесь.", "load_failed_title": "Ошибка загрузки", "load_failed": "Не удалось загрузить проблемы аутентификации: {{message}}", "action_failed": "Действие не выполнено: {{message}}", "ignore_success": "Аккаунт проигнорирован", "resolve_success": "Отмечено как обработанное", - "enable_success": "Auth file снова включен", - "delete_success": "Auth file удален", - "confirm_delete_title": "Удалить auth file?", - "confirm_delete_message": "Будет удален auth file \"{{file}}\". Это действие нельзя отменить. Подтверждайте только если аккаунт точно нельзя восстановить.", - "confirm_delete_button": "Удалить auth file" + "enable_success": "Файл входа снова включён", + "delete_success": "Файл входа удалён", + "confirm_delete_title": "Удалить файл входа?", + "confirm_delete_message": "Будет удалён файл входа \"{{file}}\". Это действие нельзя отменить. Подтверждайте только если аккаунт точно нельзя восстановить.", + "confirm_delete_button": "Удалить файл входа" }, "dashboard": { "title": "Панель управления", @@ -288,14 +291,14 @@ "token_mix_cache_creation": "Создание кэша", "model_cost_rank": "Рейтинг стоимости моделей", "no_traffic_data": "Трафика пока нет", - "collector_status_title": "Статус сборщика", - "collector_events": "События", - "collector_dead_letters": "Dead letters", + "collector_status_title": "Чтение записей запросов", + "collector_events": "Записи", + "collector_dead_letters": "Проблемные записи", "collector_mode": "Режим", - "collector_queue": "Очередь", - "collector_last_inserted": "Последняя запись", + "collector_queue": "Источник данных", + "collector_last_inserted": "Последняя запись в базе", "collector_last_consumed": "Последнее чтение", - "collector_total_inserted": "Всего записано", + "collector_total_inserted": "Всего сохранено", "collector_total_skipped": "Всего пропущено", "collector_last_error": "Последняя ошибка", "collector_unavailable": "Недоступно", @@ -315,7 +318,7 @@ "health_status_problem": "Проблема", "health_status_disabled": "Отключено", "health_status_warning": "Предупреждение", - "health_queue_status": "Состояние очереди", + "health_queue_status": "Состояние чтения данных", "health_error_log_count": "{{count}} файлов", "today_overview_usage_service": "Обзор за сегодня (Manager Server)", "metric_failure_count": "Ошибок: {{value}}", @@ -414,6 +417,7 @@ "gemini_add_modal_title": "Добавление API-ключа Gemini", "gemini_add_modal_key_label": "API-ключи:", "gemini_add_modal_key_placeholder": "Введите API-ключ Gemini", + "gemini_key_required": "Введите API-ключ Gemini", "gemini_add_modal_key_hint": "Добавляйте ключи по одному и при необходимости указывайте базовый URL.", "gemini_keys_add_btn": "Добавить ключ", "gemini_base_url_label": "Базовый URL (необязательно):", @@ -425,7 +429,7 @@ "gemini_models_add_btn": "Добавить модель", "gemini_models_fetch_button": "Получить через /v1beta/models", "gemini_models_fetch_title": "Выбор моделей из Gemini /v1beta/models", - "gemini_models_fetch_hint": "Вызывает GET /v1beta/models по спецификации Gemini API. По умолчанию отправляется x-goog-api-key из поля API-ключа, объединённый с вашими пользовательскими заголовками.", + "gemini_models_fetch_hint": "Использует указанный выше адрес и ключ Gemini, чтобы загрузить список моделей. Дополнительные заголовки будут добавлены, если они настроены.", "gemini_models_fetch_url_label": "URL запроса", "gemini_models_fetch_refresh": "Обновить", "gemini_models_fetch_loading": "Получение моделей из Gemini /v1beta/models...", @@ -442,6 +446,7 @@ "gemini_models_count": "Количество моделей", "gemini_edit_modal_title": "Редактирование API-ключа Gemini", "gemini_edit_modal_key_label": "API-ключ:", + "gemini_delete_title": "Удалить ключ Gemini", "gemini_delete_confirm": "Удалить этот ключ Gemini?", "excluded_models_label": "Исключённые модели (необязательно):", "excluded_models_placeholder": "Через запятую или с новой строки, например gemini-1.5-pro, gemini-1.5-flash", @@ -453,7 +458,7 @@ "priority_label": "Приоритет (необязательно):", "priority_hint": "Чем больше значение, тем выше приоритет. Оставьте пустым для значения по умолчанию (0).", "websockets_label": "WebSockets", - "websockets_hint": "Включает websocket-транспорт Responses API для этого auth-файла.", + "websockets_hint": "Включает WebSocket-подключение Responses API для этого файла входа.", "config_toggle_label": "Включено", "config_disabled_badge": "Отключено", "codex_title": "Конфигурация Codex API", @@ -466,6 +471,7 @@ "codex_add_modal_title": "Добавление конфигурации Codex API", "codex_add_modal_key_label": "API-ключ:", "codex_add_modal_key_placeholder": "Введите API-ключ Codex", + "codex_key_required": "Введите API-ключ Codex", "codex_add_modal_url_label": "Базовый URL (обязательно):", "codex_add_modal_url_placeholder": "например: https://api.example.com", "codex_add_modal_proxy_label": "URL прокси (необязательно):", @@ -477,7 +483,7 @@ "codex_models_add_btn": "Добавить модель", "codex_models_fetch_button": "Получить через /v1/models", "codex_models_fetch_title": "Выбор моделей из Codex /v1/models", - "codex_models_fetch_hint": "Вызывает GET /v1/models по спецификации OpenAI. По умолчанию отправляет API-ключ как Bearer (если указан) и объединяет его с пользовательскими заголовками.", + "codex_models_fetch_hint": "Использует указанные выше адрес и ключ, чтобы загрузить список моделей. Дополнительные заголовки будут добавлены, если они настроены.", "codex_models_fetch_url_label": "URL запроса", "codex_models_fetch_refresh": "Обновить", "codex_models_fetch_loading": "Получение моделей из /v1/models...", @@ -489,7 +495,7 @@ "codex_models_search_empty": "Модели по запросу не найдены. Попробуйте другой запрос.", "codex_models_fetch_added": "Добавлено новых моделей: {{count}}", "codex_test_title": "Проверка подключения", - "codex_test_hint": "Отправьте тестовый запрос к /v1/responses, чтобы проверить доступность текущих настроек Codex.", + "codex_test_hint": "Отправляет один тестовый запрос, чтобы проверить, работают ли текущие настройки Codex.", "codex_test_model_label": "Тестовая модель", "codex_test_select_placeholder": "Выберите или введите модель для проверки", "codex_test_select_empty": "Сначала добавьте модель, затем запустите проверку", @@ -499,7 +505,7 @@ "codex_test_failed": "Проверка подключения Codex не удалась", "codex_test_endpoint_invalid": "Сначала введите корректный Base URL", "codex_test_model_required": "Сначала укажите тестовую модель", - "codex_test_key_required": "Укажите API Key, заголовок Authorization или auth_index", + "codex_test_key_required": "Добавьте API-ключ или выберите аккаунт, через который уже выполнен вход.", "codex_models_count": "Количество моделей", "codex_edit_modal_title": "Редактирование конфигурации Codex API", "codex_edit_modal_key_label": "API-ключ:", @@ -514,7 +520,9 @@ "claude_add_modal_title": "Добавление конфигурации Claude API", "claude_add_modal_key_label": "API-ключ:", "claude_add_modal_key_placeholder": "Введите API-ключ Claude", + "claude_key_required": "Введите API-ключ Claude", "claude_add_modal_url_label": "Базовый URL (необязательно):", + "claude_base_url_required": "Введите базовый URL Claude", "claude_add_modal_url_placeholder": "например: https://api.anthropic.com", "claude_add_modal_proxy_label": "URL прокси (необязательно):", "claude_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080", @@ -529,7 +537,7 @@ "claude_models_count": "Количество моделей", "claude_models_fetch_button": "Получить через /v1/models", "claude_models_fetch_title": "Выбор моделей из Claude /v1/models", - "claude_models_fetch_hint": "Вызывает GET /v1/models по спецификации Anthropic. По умолчанию отправляются x-api-key и anthropic-version: 2023-06-01, объединённые с вашими пользовательскими заголовками.", + "claude_models_fetch_hint": "Использует указанные выше адрес и ключ Claude, чтобы загрузить список моделей. Дополнительные заголовки будут добавлены, если они настроены.", "claude_models_fetch_url_label": "URL запроса", "claude_models_fetch_refresh": "Обновить", "claude_models_fetch_loading": "Получение моделей из Claude /v1/models...", @@ -541,7 +549,7 @@ "claude_models_search_empty": "Модели по запросу не найдены. Попробуйте другой ключ.", "claude_models_fetch_added": "Добавлено новых моделей: {{count}}", "claude_test_title": "Тест подключения", - "claude_test_hint": "Отправляет тестовый запрос в /v1/messages по спецификации Anthropic, чтобы проверить текущую конфигурацию.", + "claude_test_hint": "Отправляет один тестовый запрос, чтобы проверить, работает ли текущая настройка Claude.", "claude_test_select_placeholder": "Выберите из текущих моделей", "claude_test_select_empty": "Модели не настроены. Сначала добавьте модели", "claude_test_action": "Тест", @@ -551,7 +559,7 @@ "claude_test_failed": "Тест не выполнен", "claude_test_key_required": "Укажите Claude API-ключ или задайте x-api-key в пользовательских заголовках", "claude_test_model_required": "Выберите модель для теста", - "claude_test_endpoint_invalid": "Не удалось сформировать корректный endpoint Claude /v1/messages", + "claude_test_endpoint_invalid": "Не удалось получить корректный адрес для проверки Claude", "claude_cloak_title": "Маскировка запросов (необязательно):", "claude_cloak_toggle_label": "Включить", "claude_cloak_toggle_aria": "Переключить маскировку запросов", @@ -575,7 +583,9 @@ "vertex_add_modal_title": "Добавление конфигурации Vertex API", "vertex_add_modal_key_label": "API-ключ:", "vertex_add_modal_key_placeholder": "Введите API-ключ Vertex", + "vertex_key_required": "Введите API-ключ Vertex", "vertex_add_modal_url_label": "Базовый URL:", + "vertex_base_url_required": "Введите базовый URL Vertex", "vertex_add_modal_url_placeholder": "например: https://example.com/api", "vertex_add_modal_proxy_label": "URL прокси (необязательно):", "vertex_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080", @@ -583,6 +593,7 @@ "vertex_edit_modal_key_label": "API-ключ:", "vertex_edit_modal_url_label": "Базовый URL:", "vertex_edit_modal_proxy_label": "URL прокси (необязательно):", + "vertex_delete_title": "Удалить конфигурацию Vertex", "vertex_delete_confirm": "Удалить эту конфигурацию Vertex?", "vertex_models_label": "Псевдонимы моделей:", "vertex_models_add_btn": "Добавить сопоставление", @@ -609,6 +620,7 @@ "openai_add_modal_url_placeholder": "например: https://openrouter.ai/api/v1", "openai_add_modal_keys_label": "API-ключи", "openai_edit_modal_keys_label": "API-ключи", + "openai_key_required": "Добавьте хотя бы один API-ключ", "openai_keys_hint": "Добавляйте каждый ключ отдельно с необязательным URL прокси для удобства.", "openai_keys_add_btn": "Добавить ключ", "openai_key_placeholder": "ключ вида sk-...", @@ -620,11 +632,11 @@ "openai_models_add_btn": "Добавить модель", "openai_models_fetch_button": "Получить через /models", "openai_models_fetch_title": "Выбор моделей из /models", - "openai_models_fetch_hint": "Вызовите эндпоинт /models, используя указанный выше базовый URL, отправив первый API-ключ как Bearer с дополнительными заголовками.", + "openai_models_fetch_hint": "Использует указанный выше адрес и первый ключ, чтобы загрузить список моделей. Дополнительные заголовки будут добавлены, если они настроены.", "openai_models_fetch_url_label": "URL запроса", "openai_models_fetch_refresh": "Обновить", "openai_models_fetch_loading": "Получение моделей из /models...", - "openai_models_fetch_empty": "Модели не вернулись. Проверьте эндпоинт или авторизацию.", + "openai_models_fetch_empty": "Модели не вернулись. Проверьте адрес, ключ или заголовки.", "openai_models_fetch_error": "Не удалось получить модели", "openai_models_fetch_back": "Вернуться к редактированию", "openai_models_fetch_apply": "Добавить выбранные модели", @@ -637,12 +649,14 @@ "openai_edit_modal_name_label": "Имя провайдера:", "openai_edit_modal_url_label": "Базовый URL:", "openai_edit_modal_models_label": "Список моделей (name[, alias] по строкам):", + "openai_delete_title": "Удалить провайдера OpenAI", "openai_delete_confirm": "Удалить этого провайдера OpenAI?", "openai_keys_count": "Количество ключей", "openai_models_count": "Количество моделей", "openai_test_title": "Тест подключения", "openai_test_hint": "Отправьте запрос /chat/completions с текущими настройками, чтобы проверить доступность.", "openai_test_model_placeholder": "Модель для теста", + "openai_test_model_required": "Выберите модель для теста", "openai_test_action": "Запустить тест", "openai_test_running": "Отправка тестового запроса...", "openai_test_timeout": "Тестовый запрос превысил тайм-аут {{seconds}} с", @@ -669,6 +683,9 @@ "table_col_models": "Модели", "table_col_recent": "Недавние запросы", "table_col_actions": "Действия", + "priority_decrease": "Понизить приоритет", + "priority_edit": "Изменить приоритет", + "priority_increase": "Повысить приоритет", "filter_all": "Все", "filter_kind_aria": "Фильтр по типу провайдера", "search_placeholder": "Поиск по имени / адресу / ключу", @@ -706,20 +723,20 @@ "health_check_apply_results": "Применить результаты", "health_check_apply_success": "Результаты проверки применены", "health_check_no_changes": "Изменения конфигурации не требуются", - "health_check_auth_index_label": "auth-index: {{index}}", + "health_check_auth_index_label": "Индекс входа: {{index}}", "health_check_configured_credential": "Учётные данные настроены", "health_check_key_index": "Ключ #{{index}}", "health_check_no_credential": "Учётные данные не настроены", "health_check_no_key_entries": "Записи ключей не настроены", - "health_check_error_missing_credential": "Отсутствует API-ключ или auth-index", + "health_check_error_missing_credential": "Отсутствует API-ключ или выбранный аккаунт", "health_check_error_empty_models": "Список моделей не возвращён", - "health_check_error_vertex_standard_models": "Стандартный endpoint обнаружения моделей не ответил{{reason}}", + "health_check_error_vertex_standard_models": "Стандартный адрес получения моделей не ответил{{reason}}", "health_check_error_provider_missing": "Конфигурация провайдера больше не существует" }, "auth_files": { "title": "Управление файлами авторизации", "title_section": "Файлы авторизации", - "description": "Управляйте всеми JSON-файлами авторизации CLI Proxy (например, Qwen, Gemini, Vertex). Загрузка учётных данных сразу включает соответствующую интеграцию AI.", + "description": "Управляйте файлами входа для сервисов AI, таких как Qwen, Gemini и Vertex. После загрузки соответствующий сервис можно использовать из панели.", "upload_button": "Загрузить файл", "paste_button": "Вставить JSON", "paste_title": "Вставить JSON авторизации", @@ -733,9 +750,9 @@ "paste_cpa_placeholder": "{\n \"type\": \"codex\",\n \"access_token\": \"...\"\n}", "paste_session_placeholder": "{\n \"user\": { \"email\": \"...\" },\n \"account\": { \"id\": \"...\" },\n \"accessToken\": \"...\",\n \"sessionToken\": \"...\"\n}", "paste_sub2api_placeholder": "{\n \"exported_at\": \"...\",\n \"proxies\": [],\n \"accounts\": [\n {\n \"platform\": \"openai\",\n \"type\": \"oauth\",\n \"credentials\": { \"access_token\": \"...\" }\n }\n ]\n}", - "paste_cpa_hint": "JSON будет сохранён как есть после проверки, что это объект JSON.", - "paste_session_hint": "ChatGPT Web session JSON преобразуется в файл CPA Codex auth перед загрузкой. Токены являются чувствительными учётными данными.", - "paste_sub2api_hint": "Official sub2api account export JSON is converted by extracting OpenAI OAuth accounts into CPA Codex auth files. Tokens are sensitive credentials.", + "paste_cpa_hint": "Содержимое будет сохранено после проверки, что это корректный JSON.", + "paste_session_hint": "Данные входа ChatGPT Web будут преобразованы в файл входа Codex, совместимый с CPA. Токены являются чувствительными данными; не передавайте их другим.", + "paste_sub2api_hint": "Экспорт аккаунтов sub2api будет преобразован в файлы входа Codex, совместимые с CPA. Токены являются чувствительными данными; не передавайте их другим.", "paste_error_file_name": "Имя файла обязательно", "paste_error_file_extension": "Имя файла должно заканчиваться на .json", "paste_error_file_name_invalid": "Укажите имя файла .json без разделителей пути и служебных символов", @@ -793,7 +810,7 @@ "pagination_next": "Следующая", "pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов", "search_label": "Поиск учётных данных", - "search_placeholder": "Поиск по имени, типу, индексу auth, ID аккаунта, плану или статусу ошибки. Поддерживается wildcard *", + "search_placeholder": "Поиск по имени, типу, аккаунту, плану или статусу ошибки. Можно использовать * как шаблон.", "problem_filter_label": "Фильтр проблем", "problem_filter_only": "Показывать только проблемные учётные данные", "disabled_filter_only": "Показывать только отключённые учётные данные", @@ -875,16 +892,18 @@ "codex_status_badge_monthly_limited_title": "Месячное окно квоты Codex достигло лимита и обычно требует ожидания восстановления.", "codex_status_badge_disabled_reset": "Восст. {{reset}}", "codex_status_badge_disabled_reset_title": "У этого отключённого аккаунта Codex есть известное время восстановления квоты: {{reset}}.", - "codex_status_badge_observed_quota": "Квота из usage header", - "codex_status_badge_observed_quota_title": "Последние response headers usage зафиксировали данные квоты или восстановления. Это исторические данные запроса, не live-запрос квоты.", - "codex_status_badge_observed_error": "Ошибка из usage header", - "codex_status_badge_observed_error_title": "Последние response headers usage зафиксировали ошибки auth, квоты или rate limit. Это исторические данные для диагностики.", + "codex_status_badge_observed_quota": "Квота из недавних запросов", + "codex_status_badge_observed_quota_title": "В недавних запросах были данные о квоте или времени восстановления. Это история запросов, а не новая проверка квоты.", + "codex_status_badge_observed_error": "Ошибка из недавних запросов", + "codex_status_badge_observed_error_title": "В недавних запросах были ошибки входа, квоты или лимита скорости. Это история запросов только для диагностики.", "status_toggle_label": "Включено", "status_enabled_success": "\"{{name}}\" включён", "status_disabled_success": "\"{{name}}\" отключён", "batch_status_success": "{{count}} файлов обновлено", "batch_status_partial": "{{success}} обновлено, {{failed}} не удалось", "batch_delete_title": "Удалить выбранные файлы", + "delete_title": "Удалить файл", + "delete_all_title": "Удалить все файлы", "batch_delete_confirm": "Удалить {{count}} файлов?", "batch_selected": "{{count}} выбрано", "batch_select_all": "Выбрать все", @@ -908,7 +927,7 @@ "auth_field_editor_title": "Просмотр / редактирование файла авторизации - {{name}}", "prefix_proxy_loading": "Загрузка файла авторизации...", "prefix_proxy_info_label": "Информация о файле авторизации (info)", - "prefix_proxy_source_label": "JSON файла авторизации (предпросмотр)", + "prefix_proxy_source_label": "Содержимое файла входа (предпросмотр)", "prefix_proxy_invalid_content_label": "Скачанное содержимое (сокращено)", "prefix_label": "Префикс (prefix)", "proxy_url_label": "URL прокси (proxy_url)", @@ -917,6 +936,8 @@ "priority_label": "Приоритет (priority)", "priority_placeholder": "например: 10 или -1", "priority_hint": "Только целые числа. Некорректные значения игнорируются. Чем больше число, тем выше приоритет.", + "websockets_label": "WebSockets", + "websockets_hint": "Включить websocket-транспорт Responses API для этого файла авторизации.", "excluded_models_label": "Исключённые модели (excluded_models)", "excluded_models_placeholder": "Через запятую или с новой строки, например: model-a, gpt-5-*, *-preview", "excluded_models_hint": "Сохраняется как массив; значения trim/нижний регистр/без дублей/с сортировкой.", @@ -927,8 +948,14 @@ "note_placeholder": "Введите заметку, например: аккаунт Ивана", "note_hint": "Необязательно. Используется для описания назначения или владельца учётных данных; оставьте пустым, чтобы не записывать.", "note_display": "Заметка", - "prefix_proxy_invalid_json": "Этот файл авторизации не является JSON-объектом, поэтому поля нельзя редактировать.", - "prefix_proxy_html_challenge": "Скачанное содержимое является HTML-страницей проверки, а не JSON-объектом авторизации. Повторно авторизуйтесь или замените файл перед редактированием полей.", + "headers_label": "Пользовательские заголовки (headers)", + "headers_placeholder": "{\n \"Header-Name\": \"value\"\n}", + "headers_hint": "Введите дополнительные заголовки запроса в формате JSON, например {\"X-My-Header\": \"value\"}", + "headers_invalid_json": "Пользовательские заголовки должны быть корректным JSON.", + "headers_invalid_object": "Пользовательские заголовки должны быть JSON-объектом.", + "headers_invalid_value": "Значение каждого пользовательского заголовка должно быть строкой.", + "prefix_proxy_invalid_json": "Этот файл входа не содержит редактируемый JSON.", + "prefix_proxy_html_challenge": "Скачанное содержимое похоже на страницу проверки, а не на файл входа. Войдите заново или замените файл перед редактированием.", "prefix_proxy_saved_success": "Файл авторизации \"{{name}}\" успешно обновлён", "card_tools_title": "Инструменты", "quota_refresh_single": "Обновить квоту", @@ -936,7 +963,7 @@ "quota_refresh_success": "Квота для \"{{name}}\" обновлена", "quota_refresh_failed": "Не удалось обновить квоту для \"{{name}}\": {{message}}", "quota_cooldown_badge": "Охлаждение до {{recoverAt}}", - "quota_cooldown_badge_title": "Этот auth file находится в квотном охлаждении под управлением CPAMP и будет восстановлен автоматически. Это не является нативным отключённым состоянием CPA. Владелец: {{owner}}. Ожидаемое восстановление: {{recoverAt}}." + "quota_cooldown_badge_title": "Этот аккаунт временно отключён CPAMP из-за исчерпанной квоты. Он должен автоматически восстановиться в {{recoverAt}}; это не ручное отключение. Источник: {{owner}}." }, "antigravity_quota": { "title": "Квота Antigravity", @@ -947,7 +974,22 @@ "load_failed": "Не удалось загрузить квоту: {{message}}", "missing_auth_index": "В файле авторизации отсутствует auth_index", "empty_models": "Данные по квоте отсутствуют", + "plan_label": "План", + "group_gemini_models": "Модели Gemini", + "group_claude_gpt_models": "Модели Claude и GPT", + "group_models_description": "Модели в этой группе: {{models}}", + "weekly_limit": "Недельный лимит", + "daily_limit": "Дневной лимит", + "five_hour_limit": "Лимит на 5 часов", + "monthly_limit": "Месячный лимит", + "quota_available": "Квота доступна", + "remaining_percent": "Осталось {{percent}}%", + "refreshes_in": "Обновится через {{duration}}", "refresh_available": "Доступно обновление", + "duration_day_hour": "{{days}} д. {{hours}} ч.", + "duration_hour_minute": "{{hours}} ч. {{minutes}} мин.", + "duration_minute": "{{minutes}} мин.", + "duration_less_than_minute": "меньше 1 мин.", "refresh_button": "Обновить квоту", "fetch_all": "Получить все" }, @@ -1019,6 +1061,7 @@ "reset_credits_unknown": "Не записано", "reset_credits_earliest_expiry": "Ближайшее истечение {{time}}", "reset_credits_expiry_label": "Срок действия сбросов квоты", + "reset_credits_invalid_payload": "Endpoint сбросов квоты вернул неверный payload", "reset_credit_expiry_item": "Сброс {{index}}", "active_limit_label": "Активный лимит", "credits_label": "Credits", @@ -1029,8 +1072,8 @@ "primary_over_secondary_limit_label": "Primary/secondary", "tooltip_label": "Описание {{label}}", "tooltip_source_label": "Источник", - "tooltip_source_header": "Последний usage response header", - "tooltip_source_api": "API endpoint квоты", + "tooltip_source_header": "Последний ответ сервиса", + "tooltip_source_api": "API квоты", "tooltip_recorded_at_label": "Время записи", "tooltip_fetched_at_label": "Время получения", "tooltip_used_label": "Использовано", @@ -1051,7 +1094,7 @@ "additional_generic_window": "{{name}}: лимит {{duration}}", "window_usage": "Использовано {{used}} / {{total}} ч", "window_usage_duration": "Использовано {{used}} / {{total}}", - "observed_window": "Последний usage header", + "observed_window": "Последний ответ сервиса", "plan_label": "Тариф", "plan_plus": "Plus", "plan_team": "Team", @@ -1087,9 +1130,12 @@ "refresh_button": "Обновить квоту", "fetch_all": "Получить все", "monthly_limit": "Месячный лимит биллинга", + "monthly_credits": "Месячные кредиты", "plan_label": "План", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", + "pay_as_you_go_label": "Pay-as-you-go", + "pay_as_you_go_disabled": "Отключено", "on_demand_cap": "Лимит on-demand", "usage_amount": "Осталось {{remaining}} / {{limit}}", "reset_unknown": "сброс неизвестен" @@ -1135,6 +1181,7 @@ "save_success": "Отключение моделей обновлено", "save_failed": "Не удалось обновить отключение моделей", "delete": "Удалить провайдера", + "delete_title": "Удалить исключение", "delete_confirm": "Удалить отключение моделей для {{provider}}?", "delete_success": "Отключение моделей провайдера удалено", "delete_failed": "Не удалось удалить отключение моделей", @@ -1171,6 +1218,7 @@ "save_success": "Псевдонимы моделей обновлены", "save_failed": "Не удалось обновить псевдонимы моделей", "delete": "Удалить провайдера", + "delete_title": "Удалить сопоставления", "delete_confirm": "Удалить псевдонимы моделей для {{provider}}?", "delete_link_title": "Убрать сопоставление", "delete_link_confirm": "Удалить сопоставление из {{sourceModel}} ({{provider}}) к псевдониму {{alias}}?", @@ -1284,6 +1332,17 @@ "xai_callback_hint": "Grok иногда показывает только код на странице. Вставьте этот код напрямую, приложение автоматически отправит его как http://127.0.0.1:56121/callback?...", "xai_callback_required": "Сначала вставьте код со страницы Grok или полный callback URL.", "xai_callback_state_missing": "Отсутствует state для этого входа xAI. Запустите вход xAI заново и затем отправьте код.", + "qwen_oauth_title": "Qwen OAuth", + "qwen_oauth_button": "Начать вход Qwen", + "qwen_oauth_hint": "Войдите в Qwen через авторизацию устройства, затем файл входа будет получен и сохранён автоматически.", + "qwen_oauth_url_label": "URL авторизации:", + "qwen_open_link": "Открыть ссылку", + "qwen_copy_link": "Скопировать ссылку", + "qwen_oauth_status_waiting": "Ожидание аутентификации...", + "qwen_oauth_status_success": "Аутентификация успешна!", + "qwen_oauth_status_error": "Ошибка аутентификации:", + "qwen_oauth_start_error": "Не удалось запустить Qwen OAuth:", + "qwen_oauth_polling_error": "Не удалось проверить статус аутентификации:", "oauth_callback_label": "Callback URL", "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", "oauth_callback_hint": "Режим удалённого браузера: после перенаправления провайдера на http://localhost:... скопируйте полный URL и отправьте его здесь.", @@ -1308,46 +1367,63 @@ "iflow_oauth_status_error": "Ошибка аутентификации:", "iflow_oauth_start_error": "Не удалось запустить iFlow OAuth:", "iflow_oauth_polling_error": "Не удалось проверить статус аутентификации:", + "iflow_cookie_title": "Вход iFlow через Cookie", + "iflow_cookie_label": "Содержимое Cookie:", + "iflow_cookie_placeholder": "Введите значение BXAuth, начиная с BXAuth=", + "iflow_cookie_hint": "Отправьте Cookie напрямую, чтобы выполнить вход без открытия ссылки авторизации. Сервер автоматически сохранит учётные данные.", + "iflow_cookie_key_hint": "Подсказка: сначала создайте Key на платформе.", + "iflow_cookie_button": "Отправить Cookie для входа", + "iflow_cookie_status_success": "Вход через Cookie выполнен, учётные данные сохранены.", + "iflow_cookie_status_error": "Ошибка входа через Cookie:", + "iflow_cookie_status_duplicate": "Дублирующаяся конфигурация:", + "iflow_cookie_start_error": "Не удалось отправить вход через Cookie:", + "iflow_cookie_config_duplicate": "Обнаружен уже существующий файл конфигурации. Если нужно сохранить заново, сначала удалите старый файл.", + "iflow_cookie_required": "Сначала заполните Cookie", + "iflow_cookie_result_title": "Результат входа через Cookie", + "iflow_cookie_result_email": "Аккаунт", + "iflow_cookie_result_expired": "Истекает", + "iflow_cookie_result_path": "Путь сохранения", + "iflow_cookie_result_type": "Тип", "remote_access_disabled": "Этот способ входа недоступен при удалённом доступе. Подключитесь с localhost." }, "model_prices": { - "eyebrow": "Model Pricing", - "title": "Model Price Management", - "subtitle": "Maintain model pricing in Manager Server, sync high-confidence prices automatically, and confirm similar candidates manually.", - "back_to_monitoring": "Back to Monitoring", - "summary": "Model pricing summary", - "summary_total": "Total models", - "summary_saved": "Saved prices", - "summary_missing": "Missing prices", - "summary_candidates": "Pending confirmation", - "sync_title": "Price Sync", - "sync_idle": "Sync currently used models from LiteLLM, OpenRouter, and other pricing metadata sources. Ambiguous matches will be listed for confirmation.", + "eyebrow": "Цены моделей", + "title": "Управление ценами моделей", + "subtitle": "Храните цены моделей в Manager Server, автоматически подтягивайте надёжные совпадения и вручную подтверждайте похожие варианты.", + "back_to_monitoring": "Назад к мониторингу", + "summary": "Сводка цен моделей", + "summary_total": "Всего моделей", + "summary_saved": "Цены сохранены", + "summary_missing": "Цены не заданы", + "summary_candidates": "Ждут подтверждения", + "sync_title": "Синхронизация цен", + "sync_idle": "Синхронизируйте используемые модели с LiteLLM, OpenRouter и другими источниками цен. Неочевидные совпадения появятся в списке для ручного подтверждения.", "sync_result": "Sources {{sources}}, imported {{imported}}, skipped {{skipped}}, {{proxy}}.", - "sync_success_detail": "Sync complete: imported {{imported}}, pending {{candidates}}, unmatched {{unmatched}}.", + "sync_success_detail": "Синхронизация завершена: импортировано {{imported}}, ждут подтверждения {{candidates}}, без совпадений {{unmatched}}.", "source_result_ok": "{{models}} models, skipped {{skipped}}", - "source_result_failed": "fetch failed", - "proxy_used": "CPA global proxy used", - "proxy_not_used": "no proxy used", - "usage_service_ready": "Manager Server connected", - "usage_service_required": "Manager Server required", - "sync_model_count": "{{count}} models in sync scope", - "manual_title": "Manual Pricing", - "manual_hint": "Saved prices use the local model name as the key; manual saves are marked manual, while confirmed candidates keep their actual source and source model ID.", - "search_placeholder": "Search models or sources", - "filter_all": "All", - "filter_missing": "Missing", - "filter_candidates": "Pending", - "filter_saved": "Saved", - "calls": "Calls", - "source": "Source", - "needs_confirmation": "Candidate confirmation needed", - "no_price": "No price set", - "candidate_select": "Select candidate price", - "confirm_candidate": "Confirm", - "candidate_confirmed": "Candidate price confirmed", - "add_manual": "Add manually", - "unmatched_title": "Unmatched models", - "empty": "No matching model prices" + "source_result_failed": "Не удалось получить данные", + "proxy_used": "Использован глобальный прокси CPA", + "proxy_not_used": "Прокси не использовался", + "usage_service_ready": "Manager Server подключён", + "usage_service_required": "Требуется Manager Server", + "sync_model_count": "В синхронизации {{count}} моделей", + "manual_title": "Ручная настройка цен", + "manual_hint": "Сохранённые цены привязаны к локальному имени модели. Ручные цены помечаются отдельно, а подтверждённые варианты сохраняют исходный источник и ID модели.", + "search_placeholder": "Поиск моделей или источников", + "filter_all": "Все", + "filter_missing": "Нет цены", + "filter_candidates": "Ждут подтверждения", + "filter_saved": "Сохранённые", + "calls": "Вызовы", + "source": "Источник", + "needs_confirmation": "Нужно подтвердить цену", + "no_price": "Цена не задана", + "candidate_select": "Выберите цену", + "confirm_candidate": "Подтвердить", + "candidate_confirmed": "Цена подтверждена", + "add_manual": "Добавить вручную", + "unmatched_title": "Модели без совпадений", + "empty": "Подходящих цен моделей нет" }, "usage_stats": { "title": "Статистика использования", @@ -1444,10 +1520,10 @@ "request_events_title": "События запросов", "request_events_filter_model": "Модель", "request_events_filter_source": "Источник", - "request_events_filter_auth_index": "Auth Index", + "request_events_filter_auth_index": "Индекс входа", "request_events_timestamp": "Время", "request_events_source": "Источник", - "request_events_auth_index": "Auth Index", + "request_events_auth_index": "Индекс входа", "request_events_result": "Результат", "time": "Задержка", "avg_time": "Средняя задержка", @@ -1484,16 +1560,16 @@ "newest": "Новые" }, "monitoring": { - "eyebrow": "Runtime Overview", + "eyebrow": "Обзор работы", "title": "Мониторинг запросов", - "subtitle": "Единая операционная панель для вызовов моделей, upstream-каналов, auth credential и оценочной стоимости. KPI по умолчанию считают model-level calls, а task bucket показываются отдельно, чтобы usage.total_requests не путался с бизнес-запросами.", + "subtitle": "Здесь можно отслеживать запросы к моделям, аккаунты, каналы и примерную стоимость. Запросы к моделям и бизнес-задачи считаются отдельно, поэтому значения не смешиваются.", "connection_status": "Подключение", "usage_switch": "Статистика использования", "logging_switch": "Логирование в файл", "request_log_switch": "Журнал запросов", "toolbar_title": "Фильтры и обновление", - "realtime_console_eyebrow": "Realtime Runtime Monitor", - "console_subtitle": "Отслеживайте объём вызовов, успехи и ошибки, структуру token и расходы по аккаунтам в выбранном диапазоне времени, с живой таблицей в стиле request log.", + "realtime_console_eyebrow": "Мониторинг в реальном времени", + "console_subtitle": "Следите за количеством вызовов, успехами, ошибками, токенами и расходами по аккаунтам за выбранный период.", "range_today": "Сегодня", "range_7d": "7д", "range_14d": "14д", @@ -1504,7 +1580,7 @@ "custom_range_end": "Конец", "custom_range_required": "Укажите время начала и окончания.", "custom_range_invalid": "Время окончания не может быть раньше времени начала.", - "search_placeholder": "Поиск по аккаунту, метке, auth index, модели, каналу, источнику, credential, API key или endpoint", + "search_placeholder": "Поиск по аккаунту, метке, модели, каналу, источнику, API-ключу или адресу запроса", "auto_refresh": "Автообновление", "auto_refresh_short": "Авто", "auto_refresh_off": "Выкл", @@ -1519,7 +1595,7 @@ "filter_channel": "Канал", "filter_api_key": "Ключ клиента", "filter_status": "Статус", - "filter_header_trace": "Header Trace", + "filter_header_trace": "След заголовков", "filter_all_accounts": "Все аккаунты", "filter_all_accounts_short": "Аккаунты", "filter_all_providers": "Все провайдеры", @@ -1532,8 +1608,8 @@ "filter_all_api_keys_short": "Ключи", "filter_all_statuses": "Все статусы", "filter_all_statuses_short": "Статусы", - "filter_all_header_traces": "Все Header Trace", - "filter_all_header_traces_short": "Trace", + "filter_all_header_traces": "Все следы заголовков", + "filter_all_header_traces_short": "Следы", "filter_status_success": "Только успешные", "filter_status_success_short": "Успех", "filter_status_failed": "Только ошибки", @@ -1545,14 +1621,14 @@ "open_logs": "Открыть логи", "open_system": "Открыть сведения о системе", "open_manager_config": "Открыть конфигурацию CPA Manager Plus", - "active_credentials": "Активные credentials", - "unavailable_auth_meta": "{{count}} credentials сейчас недоступны", - "auth_health_meta": "Метаданные credential сопоставлены с usage details", + "active_credentials": "Активные учётные данные", + "unavailable_auth_meta": "Сейчас недоступно учётных данных: {{count}}", + "auth_health_meta": "Состояние учётных данных сопоставлено с деталями запросов", "active_channels": "Включённые каналы", - "channel_health_meta": "Основано на openai-compatibility inventory", + "channel_health_meta": "Основано на текущем списке совместимых каналов", "configured_models": "Настроенные модели", - "plan_mix_meta": "Plan mix: {{value}}", - "plan_mix_empty": "Unknown", + "plan_mix_meta": "Планы: {{value}}", + "plan_mix_empty": "Неизвестно", "integrity_index": "Индекс целостности", "top_model": "Топ-модель", "top_channel": "Топ-канал", @@ -1560,10 +1636,10 @@ "usage_disabled_body": "Мониторинг запросов зависит от Manager Server или совместимых данных использования. Включите статистику и убедитесь, что Manager Server запущен.", "request_monitoring_disabled_title": "Мониторинг запросов отключён", "request_monitoring_unavailable_title": "Сервис мониторинга запросов недоступен", - "request_monitoring_disabled_body": "Сборщик запросов CPA Manager Plus сейчас не запущен. Включите мониторинг запросов в конфигурации CPA Manager Plus, если нужна эта страница.", - "request_monitoring_service_unavailable_body": "Этот Manager Server не может предоставить мониторинг запросов. Проверьте настройку, состояние сборщика и привязанный CPA.", + "request_monitoring_disabled_body": "CPA Manager Plus сейчас не читает записи запросов. Включите мониторинг запросов в конфигурации CPA Manager Plus, если нужна эта страница.", + "request_monitoring_service_unavailable_body": "Этот Manager Server сейчас не может показать мониторинг запросов. Проверьте настройку, состояние чтения данных и привязанный CPA.", "request_monitoring_not_configured_body": "Мониторинг запросов доступен только в панели, размещённой Manager Server, после настройки. Режим панели CPA не поддерживает функции статистики использования.", - "empty_diagnostics_body": "Если CPA уже обрабатывал запросы, а этот экран всё ещё пуст, включите публикацию usage, проверьте Manager Server /status и убедитесь, что одну очередь CPA usage потребляет только один Manager Server.", + "empty_diagnostics_body": "Если CPA уже обрабатывал запросы, а этот экран всё ещё пуст, включите статистику использования, проверьте состояние Manager Server и убедитесь, что данные CPA читает только один Manager Server.", "empty_diagnostics_link": "Почему здесь пусто?", "zero_token_notice_title": "Обнаружены модели с нулевыми токенами", "zero_token_notice_body": "В текущем диапазоне есть {{count}} вызовов моделей с нулевыми токенами ({{models}}). Такие модели обычно не тарифицируются только по токенам, поэтому стоимость остаётся частичной оценкой.", @@ -1615,20 +1691,20 @@ "executor_type_short": "Исполнитель", "fail_status_code": "Код ошибки", "fail_status_code_short": "HTTP", - "header_error": "Ошибка header", - "header_trace": "Trace", - "header_quota": "Квота header", + "header_error": "Ошибка из заголовка", + "header_trace": "След", + "header_quota": "Квота из заголовка", "header_recover_at": "восстановление", "header_response": "Ответ", "header_routing": "Маршрут", - "header_provider": "Provider", + "header_provider": "Провайдер", "fail_body": "Тело ошибки", "of_token_mix": "от токенной структуры", "of_input_tokens": "от входных токенов", "cache_reuse_rate": "Доля попаданий", "cache_hit_rate": "Доля попаданий", "reasoning_share": "Доля рассуждения", - "hot_endpoint": "Активный endpoint", + "hot_endpoint": "Активный адрес запроса", "accounts_suffix": "аккаунтов", "groups_suffix": "групп", "success_suffix": "успешно", @@ -1636,7 +1712,7 @@ "approx_task_suffix": "прибл. задач", "recent_window_suffix": "окно последних 30 минут", "failure_suffix": "ошибок", - "metric_scope_note": "Верхние KPI используют семантику model calls в выбранном окне. Метрики задач агрегируются приблизительно по timestamp + source + credential.", + "metric_scope_note": "Основные показатели считаются по запросам к моделям за выбранный период. Количество задач оценивается приблизительно по времени, источнику и аккаунту.", "last_sync": "Последняя синхронизация", "last_sync_short": "Синх.", "requests_trend_title": "Тренд вызовов и токенов", @@ -1644,20 +1720,20 @@ "granularity_hour": "часу", "granularity_day": "дню", "model_mix_title": "Структура моделей", - "model_mix_desc": "Показывает доминирующие модели в текущем фильтре, чтобы быстро увидеть hotspots.", + "model_mix_desc": "Показывает основные модели в текущем фильтре, чтобы быстро увидеть, куда уходит нагрузка.", "hourly_requests_title": "Почасовое распределение запросов", "hourly_requests_desc": "Показывает, в какие часы концентрируется нагрузка.", "hourly_tokens_title": "Почасовое распределение токенов", "hourly_tokens_desc": "Показывает, на какие часы приходится основной объём токенов для анализа ёмкости и расходов.", "channel_table_title": "Панель каналов", - "channel_table_desc": "Объединяет usage, openai-compatibility и auth-files для просмотра throughput, success rate и latency по каналам.", + "channel_table_desc": "Показывает объём запросов, успешность и задержку по каналам.", "model_table_title": "Рейтинг производительности моделей", - "model_table_desc": "Отслеживает объём запросов, success rate, token usage и latency по моделям.", + "model_table_desc": "Отслеживает объём запросов, успешность, расход токенов и задержку по моделям.", "failure_table_title": "Анализ источников ошибок", - "failure_table_desc": "Показывает самые шумные failing sources и channels, чтобы проблемы аккаунтов и маршрутов были видны сразу.", + "failure_table_desc": "Показывает источники и каналы с наибольшим числом ошибок, чтобы проблемы аккаунтов и маршрутов были видны сразу.", "failure_spotlight_title": "Фокус по сбоям", - "realtime_health_title": "Панель realtime health", - "realtime_health_desc": "Статус соединения, включённость credential и последние сигналы ошибок обновляются вместе с текущим фильтром.", + "realtime_health_title": "Состояние в реальном времени", + "realtime_health_desc": "Статус соединения, доступность учётных данных и последние ошибки обновляются вместе с текущим фильтром.", "account_models_title": "Расходы моделей по аккаунту", "account_models_desc": "Распределение вызовов и расходов по моделям для {{account}} в текущей области.", "restore_account_scope": "Восстановить предыдущую область", @@ -1667,15 +1743,15 @@ "data_tab_accounts_short": "Аккаунты", "data_tab_api_keys": "Ключи клиентов", "data_tab_api_keys_short": "Ключи", - "data_tab_realtime": "Realtime", - "data_tab_realtime_short": "Realtime", + "data_tab_realtime": "Реальное время", + "data_tab_realtime_short": "Реалтайм", "data_tab_accounts_badge_title": "Аккаунтов: {{count}}", "data_tab_api_keys_badge_title": "Ключей клиентов: {{count}}", "data_tab_realtime_badge_title": "{{failed}} ошибок / всего {{total}} запросов", "account_overview_title": "Сводка по аккаунтам", "api_key_summary_title": "Сводка по ключам клиентов", "api_key_summary_desc": "Агрегирует вызовы, успехи/ошибки, общее число токенов и расчётную стоимость по ключам клиентов в текущей области фильтра. Разверните строку, чтобы увидеть структуру токенов и использование моделей.", - "api_key_summary_description": "Здесь ключи означают caller/client API keys. При наличии показывается alias; в развёрнутом виде доступны input, output, cached tokens, а также вызовы и расходы по моделям для ключа.", + "api_key_summary_description": "Здесь ключи означают API-ключи клиентов. Если задан псевдоним, он будет показан; в развёрнутой строке доступны входные, выходные и кэшированные токены, а также вызовы и расходы по моделям.", "api_key_summary_col_key": "Ключ клиента", "api_key_summary_col_key_short": "Ключ", "api_key_summary_keys_count": "Ключей клиентов: {{count}}", @@ -1690,7 +1766,7 @@ "account_overview_account_display_full": "Полностью", "account_overview_show_full_accounts_hint": "Показать полные идентификаторы аккаунтов", "account_overview_show_masked_accounts_hint": "Показать скрытые идентификаторы аккаунтов", - "account_overview_col_account": "Аккаунт / Provider", + "account_overview_col_account": "Аккаунт / провайдер", "account_overview_col_account_short": "Аккаунт", "account_overview_col_success": "Успехи", "account_overview_col_failure": "Ошибки", @@ -1712,7 +1788,7 @@ "account_overview_linked_files": "Связанных файлов авторизации: {{count}}", "account_overview_health_label": "Состояние", "account_overview_health_hint": "На основе статистики запросов в текущей области фильтра.", - "account_overview_status_hint": "Текущий статус включения аккаунта, рассчитанный по связанным auth-файлам.", + "account_overview_status_hint": "Текущий статус аккаунта, рассчитанный по связанным файлам входа.", "account_overview_success_rate_hint": "Доля успешных вызовов среди всех вызовов в текущей области фильтра.", "account_overview_scope_current_filters": "Область: текущие фильтры", "account_overview_scope_range": "Область: {{range}}", @@ -1767,7 +1843,7 @@ "codex_inspection_logs_collapsed": "Логи свернуты, всего {{count}} записей.", "codex_inspection_results_title": "Результаты проверки", "codex_inspection_results_desc": "Показывает статус аккаунтов, ошибки невалидных аккаунтов и предлагаемые действия. Поддерживает пакетное и построчное выполнение.", - "codex_inspection_observed_header_evidence": "Последний usage header", + "codex_inspection_observed_header_evidence": "Последний ответ сервиса", "codex_inspection_pending_actions": "Ожидающие действия", "codex_inspection_preview_title": "Предпросмотр действий", "codex_inspection_sampled_hint": "Включена случайная выборка, поэтому предлагаемые действия формируются только для выбранных аккаунтов.", @@ -1847,7 +1923,7 @@ "codex_inspection_settings_invalid_threshold": "{{field}} должно быть числом от 0 до 100", "codex_inspection_settings_target_type_required": "Целевой тип обязателен", "codex_inspection_settings_sample_size_hint": "Значение 0 проверяет все аккаунты. Значения больше 0 включают только случайную выборку и не меняют политику действий.", - "codex_inspection_settings_threshold_hint": "Аккаунты с таким или большим usage считаются превысившими порог. Значение 100 по умолчанию предлагает отключение только при исчерпании квоты.", + "codex_inspection_settings_threshold_hint": "Аккаунты с таким или большим использованием считаются превысившими порог. Значение 100 по умолчанию предлагает отключение только при исчерпании квоты.", "codex_inspection_settings_group_strategy": "Стратегия проверки", "codex_inspection_settings_group_concurrency": "Параллелизм и таймаут", "codex_inspection_settings_group_auto": "Автовыполнение", @@ -2003,8 +2079,8 @@ "server_codex_inspection_action_status_failed": "Ошибка выполнения", "server_codex_inspection_action_status_skipped": "Пропущено", "server_codex_inspection_action_status_needs_review": "Требует проверки", - "server_codex_inspection_file_level_action_hint": "Файлы auth с одинаковым именем объединены в одно действие на уровне файла", - "server_codex_inspection_action_needs_review_hint": "Разрешите этот конфликт в управлении файлами auth", + "server_codex_inspection_file_level_action_hint": "Файлы входа с одинаковым именем объединены в одно действие", + "server_codex_inspection_action_needs_review_hint": "Разрешите этот конфликт в управлении файлами входа", "server_codex_inspection_logs_desc": "Сохраненные логи этой серверной проверки. Детали ошибок сохраняются полностью.", "server_codex_inspection_service_unavailable": "Серверная проверка Codex доступна только в панели, размещённой Manager Server.", "server_codex_inspection_connection_required": "Подключитесь с management key перед просмотром серверной проверки.", @@ -2017,24 +2093,24 @@ "account_quota_retry_button": "Повторить", "account_quota_reset_at": "Сброс", "account_quota_observed_used": "Записанное использование", - "realtime_table_title": "Таблица realtime monitor", - "realtime_table_desc": "Показывает отдельные записи вызовов в realtime с моделью, каналом, статусом, задержкой, usage и cost, чтобы изменения запросов были видны сразу.", - "model_pricing_desc": "Управляйте ценами моделей прямо на странице мониторинга, чтобы расходы по аккаунтам, моделям и live log обновлялись мгновенно.", + "realtime_table_title": "Таблица мониторинга", + "realtime_table_desc": "Показывает отдельные вызовы с моделью, каналом, статусом, задержкой, расходом токенов и стоимостью, чтобы изменения были видны сразу.", + "model_pricing_desc": "Управляйте ценами моделей прямо на странице мониторинга, чтобы расходы по аккаунтам, моделям и журналу обновлялись сразу.", "price_storage_hint": "Цены сохраняются в SQLite, если доступно; старые цены из local storage переносятся автоматически.", "workbench_title": "Рабочее место запросов", - "workbench_desc": "Группирует недавние вызовы в approximate task buckets, чтобы вместе смотреть модели, результат, стоимость и endpoint paths.", - "workbench_rows": "Task Buckets", + "workbench_desc": "Группирует недавние вызовы в примерные задачи, чтобы вместе смотреть модели, результат, стоимость и адреса запросов.", + "workbench_rows": "Примерные задачи", "recent_failures": "Последние сбои", "recent_failures_short": "Сбои", "filtering_hint": "Фильтр по “{{query}}”, найдено {{count}} вызовов моделей.", "all_data_hint": "{{count}} вызовов моделей в текущем окне.", "other_models": "Другие модели", "account_label": "Аккаунт", - "auth_label": "Auth Label", - "auth_index": "Auth Index", - "auth_indexes": "Auth Indexes", + "auth_label": "Метка входа", + "auth_index": "Индекс входа", + "auth_indexes": "Индексы входа", "column_type": "Тип", - "column_source_api_key": "Источник / API Key", + "column_source_api_key": "Источник / API-ключ", "column_source_api_key_short": "Источник", "column_model": "Модель", "column_status": "Статус", @@ -2049,9 +2125,9 @@ "focus_account": "Фокус на аккаунте", "clear_focus": "Снять фокус", "account_focused": "В фокусе", - "status_credentials": "Состояние credentials", + "status_credentials": "Состояние учётных данных", "status_channels": "Включение каналов", - "status_runtime_only": "Runtime-only credentials", + "status_runtime_only": "Только временные учётные данные", "status_models": "Каталог моделей", "column_channel": "Канал", "column_host": "Host", @@ -2070,7 +2146,7 @@ "column_failure_rate": "Доля ошибок", "column_last_seen": "Последний раз", "column_result": "Результат", - "column_endpoints": "Endpoints", + "column_endpoints": "Адреса запросов", "this_call_usage": "Расход за вызов", "this_call_usage_short": "Токены", "this_call_cost": "Стоимость вызова", @@ -2086,10 +2162,10 @@ "no_filtered_data": "Текущий поисковый фильтр ничего не нашёл.", "source": "Источник", "calls": "Вызовы", - "log_rows": "Log Rows", + "log_rows": "Строки журнала", "log_rows_short": "Строки", - "realtime_api_key_label": "API Key", - "realtime_api_key_hash": "Хэш API Key", + "realtime_api_key_label": "API-ключ", + "realtime_api_key_hash": "Хэш API-ключа", "realtime_api_key_masked": "Маскированный ключ", "pagination_prev": "Назад", "pagination_prev_short": "Назад", @@ -2114,7 +2190,7 @@ "download_button": "Скачать журналы", "error_log_button": "Выбрать журнал ошибок", "error_logs_modal_title": "Журналы ошибок запросов", - "error_logs_description": "Здесь показаны журналы ошибок запросов, записанные на стороне CPA. Обычно они помогают разбирать ответы 4xx/5xx, ошибки аутентификации, лимиты, ошибки upstream и похожие проблемы. Скачайте файл, чтобы увидеть точную причину.", + "error_logs_description": "Здесь показаны журналы ошибок запросов, записанные на стороне CPA. Обычно они помогают разбирать ответы 4xx/5xx, ошибки аутентификации, лимиты, ошибки внешнего сервиса и похожие проблемы. Скачайте файл, чтобы увидеть точную причину.", "error_logs_request_log_enabled": "Журналирование запросов включено, поэтому по контракту API CPA этот отдельный список журналов ошибок остаётся пустым. Отключите журналирование запросов и обновите список, чтобы увидеть отдельные файлы журналов ошибок.", "error_logs_empty": "Файлы журнала ошибок запросов не найдены", "error_logs_load_error": "Не удалось загрузить список журналов ошибок", @@ -2130,6 +2206,7 @@ "log_content": "Содержимое журнала", "loading": "Загрузка журналов...", "load_error": "Не удалось загрузить журналы", + "clear_confirm_title": "Очистить журналы", "clear_confirm": "Очистить все журналы? Действие нельзя отменить!", "clear_success": "Журналы успешно очищены", "download_success": "Журналы успешно скачаны", @@ -2218,12 +2295,12 @@ "manager": { "title": "Конфигурация CPA Manager Plus", "description": "Управляйте настройками CPA Manager Plus отдельно от конфигурации CPA.", - "boundary_hint": "Эти настройки сохраняются для самого CPAM. CPA usage statistics и хранение очереди остаются частью конфигурации CPA.", + "boundary_hint": "Эти настройки сохраняются в CPAM. Статистика использования CPA и время хранения данных по-прежнему настраиваются в CPA.", "load_failed": "Не удалось загрузить конфигурацию CPA Manager Plus", "save_success": "Конфигурация CPA Manager Plus сохранена", "number_invalid": "{{label}} должен быть положительным целым числом", "service_base_required": "Не удалось определить URL Manager Server. Откройте панель, размещённую Manager Server.", - "poll_interval_retention_error": "Интервал опроса сборщика должен быть не больше окна хранения очереди CPA usage", + "poll_interval_retention_error": "Интервал чтения не должен быть больше времени хранения данных CPA", "runtime_title": "Среда выполнения", "runtime_embedded": "Встроенный Manager Server на том же origin", "runtime_external": "Режим панели CPA", @@ -2256,14 +2333,14 @@ "cpa_management_key_hide": "Скрыть ключ", "cpa_management_key_clear": "Очистить", "cpa_management_key_saving": "Проверка нового ключа через CPA…", - "cpa_connection_risk_inline": "Изменение URL CPA или management key влияет на проксирование, сбор usage и данные мониторинга. Сохраняйте только после проверки целевого CPA.", + "cpa_connection_risk_inline": "Изменение URL CPA или management key влияет на проксирование, сбор статистики и данные мониторинга. Сохраняйте только после проверки целевого CPA.", "cpa_connection_risk_title": "Подтвердите изменение подключения CPA", "cpa_connection_risk_message": "Вы меняете подключение CPA для Manager Server. Текущий CPA: {{currentBase}}; CPA после сохранения: {{nextBase}}. Это влияет на проксирование, сбор статистики использования и данные мониторинга. Перед продолжением убедитесь, что URL CPA и Management Key полностью верны.", "cpa_connection_risk_confirm": "Подтвердить и принять риск", "current_cpa_base": "Текущий URL CPA", "bound_cpa_base": "CPA, привязанный к Manager Server", "not_bound": "Не привязан", - "binding_matched": "Manager Server привязан к текущему CPA. Данные мониторинга и usage относятся к этой панели.", + "binding_matched": "Manager Server привязан к текущему CPA. Данные мониторинга и статистика относятся к этой панели.", "binding_unconfigured": "Manager Server ещё не привязан к CPA. Сохранение привяжет его к текущему CPA.", "binding_external_disabled": "Режим панели CPA не включает функции Manager Server. Откройте панель, размещённую Manager Server.", "binding_mismatched": "Этот Manager Server уже привязан к другому CPA. Сбросьте настройку Manager Server перед привязкой другого CPA.", @@ -2276,25 +2353,25 @@ "status_binding_required": "Требуется сброс настройки Manager Server", "status_binding_required_short": "Нужен сброс", "request_monitoring_title": "Мониторинг запросов", - "request_monitoring_hint": "При включении CPA usage statistics включаются, а сборщик запускается. При отключении сборщик CPAM останавливается, но CPA usage statistics и очередь CPA не очищаются.", + "request_monitoring_hint": "При включении CPA начнёт записывать статистику, а CPAM начнёт читать записи запросов. При отключении чтение остановится, но уже сохранённые данные CPA не очищаются.", "request_monitoring_enabled": "Включить мониторинг запросов", "request_monitoring_dependency": "Завершите настройку Manager Server и сохраните привязку CPA перед включением мониторинга запросов.", - "request_monitoring_queue_note": "Если CPA usage statistics остаются включёнными, повторное включение мониторинга запросов в пределах окна хранения очереди CPA может собрать данные, сохранённые во время остановки сборщика CPAM.", + "request_monitoring_queue_note": "Если CPA продолжает записывать статистику, повторное включение мониторинга вскоре после остановки может показать данные, сохранённые за это время.", "collector_mode": "Режим сбора", "collector_mode_auto": "Авто", "collector_mode_http": "HTTP", "collector_mode_resp": "RESP", "collector_mode_subscribe": "RESP Pub/Sub", - "poll_interval_ms": "Интервал опроса сборщика (мс)", - "poll_interval_hint": "Должен быть не больше окна хранения очереди CPA usage. Текущая проверка использует {{seconds}} сек.", + "poll_interval_ms": "Интервал чтения (мс)", + "poll_interval_hint": "Не должен быть больше времени хранения данных CPA. Текущее значение проверки: {{seconds}} сек.", "batch_size": "Максимум элементов в пакете", - "query_limit": "Лимит запроса usage", + "query_limit": "Лимит записей за один запрос", "config_source": "Источник конфигурации", "config_source_env": "Переменные окружения", "config_source_db": "SQLite", "config_source_none": "Не сохранено", - "cpa_usage_enabled": "CPA Usage Statistics", - "cpa_retention": "Хранение очереди CPA", + "cpa_usage_enabled": "Статистика использования CPA", + "cpa_retention": "Хранение данных CPA", "cpa_retention_value": "{{seconds}} с" }, "visual": { @@ -2343,7 +2420,7 @@ "commercial_mode": "Коммерческий режим", "commercial_mode_desc": "Отключить тяжёлое промежуточное ПО для поддержки высокой нагрузки", "usage_statistics_enabled": "Включить статистику использования", - "usage_statistics_enabled_desc": "Разрешить CPA публиковать события использования для постоянного потребления Manager Server", + "usage_statistics_enabled_desc": "Разрешить CPA записывать данные использования, чтобы Manager Server мог показывать мониторинг", "logging_to_file": "Журналировать в файл", "logging_to_file_desc": "Сохранять журналы в файлы", "plugins_enabled": "Включить систему плагинов", @@ -2353,8 +2430,8 @@ "plugin_store_sources": "Источники магазина плагинов", "plugin_store_sources_desc": "Один URL manifest магазина плагинов на строку. Записывается в массив CPA plugins.store-sources.", "plugin_store_auth": "Правила аутентификации магазина плагинов", - "plugin_store_auth_desc": "Настраивает заголовки аутентификации для приватных registry, metadata GitHub Release или URL загрузки.", - "plugin_store_auth_hint": "Указывайте только имена переменных окружения, а не реальные token. Backend прочитает значения из своей среды выполнения.", + "plugin_store_auth_desc": "Настраивает заголовки доступа для приватных источников плагинов, GitHub Release или ссылок загрузки.", + "plugin_store_auth_hint": "Указывайте только имена переменных окружения, а не реальные токены. Сервис сам прочитает значения из своей среды запуска.", "store_auth_empty": "Правила аутентификации не настроены. Публичные источники плагинов не требуют правил.", "store_auth_rule": "Правило аутентификации", "store_auth_add": "Добавить правило аутентификации", @@ -2362,24 +2439,24 @@ "store_auth_type": "Тип аутентификации", "store_auth_apply_to": "Применять к", "store_auth_apply_to_hint": "Если ничего не выбрано, правило применяется ко всем типам запросов.", - "store_auth_apply_registry": "Registry", + "store_auth_apply_registry": "Список плагинов", "store_auth_apply_metadata": "Metadata", "store_auth_apply_artifact": "Artifact", "store_auth_type_none": "Без аутентификации", "store_auth_type_bearer": "Bearer", "store_auth_type_basic": "Basic", - "store_auth_type_header": "Пользовательский Header", + "store_auth_type_header": "Пользовательский заголовок", "store_auth_type_github_token": "GitHub Token", "store_auth_token_env": "Переменная окружения token", "store_auth_username_env": "Переменная окружения username", "store_auth_password_env": "Переменная окружения password", - "store_auth_header_name": "Имя Header", - "store_auth_header_value_env": "Переменная окружения значения Header", + "store_auth_header_name": "Имя заголовка", + "store_auth_header_value_env": "Переменная окружения значения заголовка", "store_auth_allow_insecure": "Разрешить небезопасные HTTP URL", "logs_max_size": "Максимальный размер файла журнала (МБ)", "error_logs_max_files": "Файлов журнала ошибок", - "redis_usage_queue_retention": "Хранение очереди использования (сек)", - "redis_usage_queue_retention_hint": "По умолчанию CPA использует 60 секунд, максимум 3600 секунд. Оставьте пустым для значения по умолчанию. Держите Manager Server запущенным постоянно.", + "redis_usage_queue_retention": "Хранение данных использования (сек)", + "redis_usage_queue_retention_hint": "По умолчанию CPA хранит данные 60 секунд, максимум 3600 секунд. Оставьте пустым для значения по умолчанию. Лучше держать Manager Server запущенным постоянно.", "antigravity_signature_cache": "Включить кэш подписей Antigravity", "antigravity_signature_cache_desc": "Предпочитать и проверять кэшированные подписи thinking-блоков", "antigravity_signature_strict": "Строгая проверка bypass-подписи", @@ -2413,7 +2490,7 @@ "auth_auto_refresh_workers": "Workers автообновления auth", "auth_auto_refresh_workers_hint": "Если больше 0, переопределяет число workers по умолчанию (16)", "disable_image_generation": "Отключить генерацию изображений", - "disable_image_generation_hint": "false включает; true отключает везде; chat отключает только инъекцию на non-image endpoints", + "disable_image_generation_hint": "false включает; true отключает везде; chat отключает только добавление изображений в обычные chat-запросы", "disable_image_generation_false": "false (включено)", "disable_image_generation_true": "true (отключено везде)", "disable_image_generation_chat": "chat (отключить только chat-инъекцию)", @@ -2424,8 +2501,8 @@ "session_affinity_ttl": "TTL привязки сессии", "force_model_prefix": "Принудительный префикс модели", "force_model_prefix_desc": "Запросы к моделям без префикса используют только учётные данные без префикса", - "passthrough_headers": "Передавать upstream-заголовки", - "passthrough_headers_desc": "Передавать отфильтрованные upstream-заголовки ответа downstream-клиентам", + "passthrough_headers": "Передавать заголовки ответа", + "passthrough_headers_desc": "Передавать клиентам разрешённые заголовки из ответа внешнего сервиса", "disable_cooling": "Отключить cooldown", "disable_cooling_desc": "Глобально отключить окна охлаждения auth/model после ошибок", "session_affinity": "Маршрутизация с привязкой к сессии", @@ -2577,10 +2654,10 @@ "cpa_connection_required_for_monitoring": "URL CPA и ключ управления требуются, когда включён мониторинг запросов.", "management_api_validation_failed": "Проверка CPA Management API не удалась. Проверьте URL CPA и ключ управления.", "management_api_config_failed": "Не удалось прочитать конфигурацию CPA через Management API.", - "cpa_usage_retention_invalid": "Время хранения очереди CPA usage должно быть больше 0.", - "poll_interval_exceeds_retention": "Интервал опроса сборщика должен быть не больше окна хранения очереди CPA usage.", + "cpa_usage_retention_invalid": "Время хранения данных CPA должно быть больше 0.", + "poll_interval_exceeds_retention": "Интервал чтения не должен быть больше времени хранения данных CPA.", "invalid_time_zone": "Не удалось сохранить часовой пояс. Проверьте название часового пояса или убедитесь, что tzdata установлен в среде выполнения.", - "enable_cpa_usage_statistics_failed": "Не удалось включить CPA usage statistics.", + "enable_cpa_usage_statistics_failed": "Не удалось включить статистику использования CPA.", "setup_env_managed": "Настройка управляется переменными окружения.", "invalid_existing_management_key": "Существующая настройка отклонила этот ключ управления.", "invalid_management_key": "Неверный ключ управления.", @@ -2590,7 +2667,7 @@ "model_price_sync_failed": "Синхронизация цен моделей не удалась.", "api_key_aliases_required": "Требуются псевдонимы API-ключей.", "api_key_alias_duplicate": "Псевдоним API-ключа уже существует.", - "method_not_allowed": "Эта операция не поддерживается текущим endpoint." + "method_not_allowed": "Эта операция не поддерживается текущим адресом сервиса." }, "usage_analytics": { "title": "Аналитика использования", @@ -2615,41 +2692,41 @@ "recent_empty": "Недавних представлений пока нет.", "updated_at": "Обновлено в {{time}}", "overview_updated_at": "Последнее обновление", - "overview_selected_bucket": "Выбранный bucket", + "overview_selected_bucket": "Выбранный период", "overview_no_bucket_selected": "Не выбран", "overview_active_filters": "Активных фильтров: {{count}}", "view_daily_board": "Дневная панель", "view_daily_board_desc": "Обзор дневного использования, стоимости и здоровья.", "view_high_cost_key": "Монитор дорогих клиентских ключей", "view_high_cost_key_desc": "Отслеживайте концентрацию клиентских ключей и аномальные расходы.", - "view_prod_credentials": "Панель production credentials", - "view_prod_credentials_desc": "Контроль активных credentials, здоровья provider и статуса квот.", + "view_prod_credentials": "Панель рабочих учётных данных", + "view_prod_credentials_desc": "Контроль активных учётных данных, состояния провайдеров и статуса квот.", "view_team_weekly": "Недельная панель команды", "view_team_weekly_desc": "Недельный тренд для командного обзора использования.", "analysis_entry_trends": "Анализ трендов", - "analysis_entry_trends_desc": "Проверьте изменения использования, стоимости, tokens и здоровья.", + "analysis_entry_trends_desc": "Проверьте изменения использования, стоимости, токенов и состояния.", "analysis_entry_models": "Анализ моделей", - "analysis_entry_models_desc": "Сравните стоимость, tokens и вклад моделей.", + "analysis_entry_models_desc": "Сравните стоимость, токены и вклад моделей.", "analysis_entry_api_keys": "Анализ клиентских ключей", "analysis_entry_api_keys_desc": "Найдите сконцентрированные, ошибочные или аномальные клиентские ключи.", - "analysis_entry_credentials": "Анализ credentials", - "analysis_entry_credentials_desc": "Проверьте auth files, здоровье provider и оценку квот.", + "analysis_entry_credentials": "Анализ учётных данных", + "analysis_entry_credentials_desc": "Проверьте файлы входа, состояние провайдеров и оценку квот.", "analysis_entry_heatmap": "Тепловая карта", "analysis_entry_heatmap_desc": "Найдите пиковые периоды, окна стоимости и аномалии доли ошибок.", "filter_metric": "Метрика", "filter_dimension": "Измерение", "trend_metric_requestCount": "Запросы", - "trend_metric_totalTokens": "Tokens", + "trend_metric_totalTokens": "Токены", "trend_metric_estimatedCost": "Стоимость", "matrix_dimension_apiKeyModel": "Клиентский ключ x модель", - "matrix_dimension_authFileModel": "Auth file x модель", - "matrix_dimension_providerModel": "Provider x модель", + "matrix_dimension_authFileModel": "Файл входа x модель", + "matrix_dimension_providerModel": "Провайдер x модель", "matrix_metric_requestCount": "Запросы", - "matrix_metric_totalTokens": "Tokens", + "matrix_metric_totalTokens": "Токены", "matrix_metric_estimatedCost": "Стоимость", "matrix_metric_failureRate": "Доля ошибок", "heatmap_metric_requestCount": "Запросы", - "heatmap_metric_totalTokens": "Tokens", + "heatmap_metric_totalTokens": "Токены", "heatmap_metric_estimatedCost": "Стоимость", "heatmap_metric_failureRate": "Доля ошибок", "heatmap_scale_label": "Режим цвета", @@ -2657,40 +2734,40 @@ "heatmap_scale_byWeekday": "По дню недели", "heatmap_scale_byHour": "По часу", "heatmap_color_value": "Значение цвета", - "token_structure_title": "Структура tokens", - "token_structure_hint": "Input, output, cached и reasoning tokens по временным bucket.", - "entity_trend_title": "Тренд сущностей", - "entity_trend_hint": "Кривые top-сущностей аппроксимированы по их общей доле — это не реальные ряды по каждой сущности.", + "token_structure_title": "Структура токенов", + "token_structure_hint": "Входные, выходные, кэшированные и reasoning-токены по временным периодам.", + "entity_trend_title": "Тренд основных элементов", + "entity_trend_hint": "Тренды основных элементов оценены по общей доле. Это не точные почасовые данные по каждому элементу.", "selected_credential_trend_title": "Тренд выбранной учётной записи", "selected_credential_trend_hint": "Показывает только реальный временной ряд выбранной учётной записи без оценки по доле.", - "insights_title": "Usage insights", + "insights_title": "Наблюдения по использованию", "insights_hint": "Практические наблюдения по текущему срезу аналитики.", "insights_empty": "В этом диапазоне явных рисков нет.", "insight_model_cost_high": "Стоимость модели сконцентрирована", "insight_model_cost_high_body": "Ведущая модель занимает большую долю стоимости. Проверьте, можно ли часть нагрузки перевести на более дешёвые модели.", "insight_key_long_tail": "Использование ключей сконцентрировано", "insight_key_long_tail_body": "Небольшой набор ключей создаёт большую часть трафика. Проверьте владельца и ожидаемый объём автоматизации.", - "insight_credential_success_drop": "Credential health требует внимания", - "insight_credential_success_drop_body": "Один или несколько credentials показывают ошибки или снижение успешности.", - "insight_low_cost_room": "Long-tail модели активны", + "insight_credential_success_drop": "Учётные данные требуют внимания", + "insight_credential_success_drop_body": "Один или несколько аккаунтов показывают ошибки или снижение успешности.", + "insight_low_cost_room": "Недорогие модели уже используются", "insight_low_cost_room_body": "Недорогие модели уже получают трафик. Рассмотрите маршрутизацию дополнительных подходящих запросов.", - "insight_provider_concentration": "Provider usage сконцентрирован", - "insight_provider_concentration_body": "Один provider несёт большую часть запросов. Проверьте резервную ёмкость и failover.", + "insight_provider_concentration": "Использование провайдера сконцентрировано", + "insight_provider_concentration_body": "Один провайдер обрабатывает большую часть запросов. Проверьте резервную ёмкость и запасной маршрут.", "insight_cache_room": "Есть потенциал кэша", - "insight_cache_room_body": "Доля cache-read низкая. Стабильные prompt prefixes могут снизить стоимость input tokens.", + "insight_cache_room_body": "Доля чтения из кэша низкая. Более стабильные префиксы промптов могут снизить стоимость входных токенов.", "anomaly_reason_cost_spike": "Высокая доля стоимости", "anomaly_reason_error_rate": "Повышенная доля ошибок", "anomaly_reason_usage_skew": "Перекос использования", "active_credential_hint": "{{active}} активных / {{total}} всего", - "provider_usage_share_title": "Доля provider usage", - "provider_health_title": "Здоровье provider", + "provider_usage_share_title": "Доля использования провайдера", + "provider_health_title": "Состояние провайдера", "quota_status_title": "Статус квот", - "quota_status_hint": "Оценка по недавнему использованию, если upstream quota data недоступна.", + "quota_status_hint": "Оценка по недавнему использованию, если провайдер не вернул данные квоты.", "quota_status_normal": "Норма", "quota_status_warning": "Предупреждение", "quota_status_exhausted": "Исчерпано", - "heatmap_matrix_title": "Матрица entity / model", - "heatmap_matrix_hint": "Сравните использование API Key, auth file или provider с моделями.", + "heatmap_matrix_title": "Матрица элементов / моделей", + "heatmap_matrix_hint": "Сравните использование API-ключей, файлов входа или провайдеров с моделями.", "hot_combinations_title": "Горячие сочетания", "abnormal_time_points_title": "Аномальные точки", "view_exception_combinations": "Показать сочетания", @@ -2702,7 +2779,7 @@ "col_used_quota": "Использовано", "col_remaining_quota": "Осталось", "col_reset_at": "Сброс в", - "metric_reasoning_tokens": "Reasoning tokens", + "metric_reasoning_tokens": "Reasoning-токены", "metric_average_cost_per_call": "Сред. стоимость / вызов", "metric_failure_count": "Ошибки", "metric_p95_ttft": "P95 TTFT", @@ -2720,9 +2797,9 @@ "granularity_auto": "Авто", "granularity_hour": "По часам", "granularity_day": "По дням", - "resolved_granularity": "Текущий bucket: {{granularity}}", - "filter_model": "Model", - "filter_api_key": "API Key", + "resolved_granularity": "Текущий шаг: {{granularity}}", + "filter_model": "Модель", + "filter_api_key": "API-ключ", "filter_status": "Статус", "status_all": "Все", "status_success": "Успешные", @@ -2732,11 +2809,11 @@ "apply_custom_range": "Применить", "show_advanced_filters": "Показать расширенные фильтры", "hide_advanced_filters": "Скрыть расширенные фильтры", - "filter_provider": "Provider", - "filter_auth_file": "Auth file", + "filter_provider": "Провайдер", + "filter_auth_file": "Файл входа", "filter_all_auth_files": "Все файлы авторизации", "filter_search": "Поиск записей использования", - "filter_search_placeholder": "Искать Request ID, model, API key, credential, provider, endpoint или ошибку", + "filter_search_placeholder": "Искать ID запроса, модель, API-ключ, аккаунт, провайдера, адрес запроса или ошибку", "filter_search_clear": "Очистить поиск", "filter_latency": "Задержка", "latency_all": "Любая задержка", @@ -2752,7 +2829,7 @@ "cache_status_miss": "Без кэша", "filter_cache_hit": "Попадание в кэш", "filter_error_code": "Код ошибки", - "filter_request_id": "Request ID", + "filter_request_id": "ID запроса", "not_available_yet": "Пока недоступно", "clear_all": "Очистить все", "error_title": "Не удалось загрузить аналитику", @@ -2762,9 +2839,9 @@ "summary_cost_meta": "Оценка по ценам моделей", "summary_vs_previous": "к пред. периоду", "trend_title": "Анализ трендов", - "trend_hint": "Нажмите на временной bucket, чтобы изучить аномалию и перейти к деталям запросов.", - "trend_peak_request_bucket": "Пиковый bucket запросов", - "trend_average_bucket_requests": "Сред. запросов на bucket", + "trend_hint": "Нажмите на временной период, чтобы изучить аномалию и перейти к деталям запросов.", + "trend_peak_request_bucket": "Пиковый период запросов", + "trend_average_bucket_requests": "Сред. запросов за период", "trend_request_change": "Изменение запросов", "trend_token_change": "Изменение токенов", "trend_cost_change": "Изменение стоимости", @@ -2773,7 +2850,7 @@ "trend_entity_compare_title": "Сравнение трендов сущностей", "chart_normalized_scale": "Левая ось показывает {{metric}}. Остальные метрики нормализованы по собственному пику.", "chart_compare_scale": "График сравнения нормализует каждую метрику по её пику. Tooltip показывает исходные значения.", - "anomaly_title": "Анализ аномального bucket", + "anomaly_title": "Анализ аномального периода", "anomaly_none": "Явных аномалий нет", "anomaly_request_spike": "Рост запросов", "anomaly_cost_spike": "Рост стоимости", @@ -2783,19 +2860,19 @@ "anomaly_latency_spike": "Рост средней задержки", "anomaly_unknown": "Аномальная метрика", "possible_causes": "Возможные причины", - "cause_traffic": "В этом bucket вырос трафик или batch-нагрузка.", - "cause_batch": "Prompt, модель или клиент изменили расход токенов.", - "cause_cache": "Изменилось использование кэша, выросли некэшированные input tokens.", + "cause_traffic": "В этом периоде вырос трафик или пакетная нагрузка.", + "cause_batch": "Промпт, модель или клиент изменили расход токенов.", + "cause_cache": "Изменилось использование кэша, выросли входные токены без кэша.", "cause_request_spike": "Количество запросов резко выросло. Сначала проверьте batch-задачи, автоматизацию или всплеск клиентского трафика.", - "cause_request_drop": "Количество запросов резко снизилось. Клиент мог остановить вызовы, очередь могла опустеть или сработал upstream throttling.", + "cause_request_drop": "Количество запросов резко снизилось. Клиент мог остановить вызовы, данные могли закончиться или внешний сервис мог ограничить скорость.", "cause_cost_spike": "Стоимость заметно выросла. Проверьте дорогие модели, длинный контекст или концентрированные повторы.", "cause_cost_drop": "Стоимость резко снизилась. Запросов могло стать меньше, могла включиться более дешёвая модель или сократился output.", - "cause_token_per_request_spike": "Среднее число токенов на запрос выросло. Проверьте prompts, сборку контекста и настройки max_tokens.", - "cause_cache_hit_drop": "Доля попаданий в кэш снизилась. Возможно, изменилась структура prompt или нестабилен cache prefix.", - "cause_cache_growth": "Cached tokens выросли. В этом bucket выше повторное использование кэша; сравните, снизилась ли стоимость.", - "cause_failure_rate_spike": "Доля ошибок резко выросла. Проверьте upstream-ошибки, throttling или настройки авторизации.", + "cause_token_per_request_spike": "Среднее число токенов на запрос выросло. Проверьте промпты, сборку контекста и настройки max_tokens.", + "cause_cache_hit_drop": "Доля попаданий в кэш снизилась. Возможно, изменилась структура промпта или префикс кэша стал нестабильным.", + "cause_cache_growth": "Кэшированных токенов стало больше. В этом периоде выше повторное использование кэша; сравните, снизилась ли стоимость.", + "cause_failure_rate_spike": "Доля ошибок резко выросла. Проверьте ошибки внешнего сервиса, ограничения скорости или настройки авторизации.", "cause_latency_spike": "Средняя задержка резко выросла. Возможно, модель отвечает медленнее, сеть нестабильна или выросла очередь запросов.", - "cause_no_clear_anomaly": "Этот bucket близок к предыдущему. Откройте детали запросов, чтобы проверить отдельные выбросы.", + "cause_no_clear_anomaly": "Этот период близок к предыдущему. Откройте детали запросов, чтобы проверить отдельные выбросы.", "common_views_title": "Частые представления", "common_view_7d": "Обзор за 7 дней", "common_view_cost": "Разбор стоимости", @@ -2805,16 +2882,16 @@ "overview_trend_title": "Обзор тренда использования", "overview_trend_hint": "Запросы, токены и стоимость показаны вместе для выбранного периода.", "health_trend_title": "Тренд здоровья запросов", - "anomaly_points_title": "Аномальные временные bucket", - "anomaly_points_hint": "Bucket, найденные backend по изменениям запросов, стоимости, токенов, кэша, ошибок и latency.", + "anomaly_points_title": "Аномальные периоды", + "anomaly_points_hint": "Периоды, найденные по изменениям запросов, стоимости, токенов, кэша, ошибок и задержки.", "drilldown_preview_title": "Предпросмотр запросов", - "drilldown_preview_hint": "После выбора временного bucket здесь показываются реальные примеры запросов из него.", - "drilldown_preview_empty": "Нажмите временной bucket на графике, чтобы увидеть примеры запросов.", + "drilldown_preview_hint": "После выбора временного периода здесь показываются реальные примеры запросов из него.", + "drilldown_preview_empty": "Нажмите временной период на графике, чтобы увидеть примеры запросов.", "model_overview_title": "Обзор расхода по моделям", "api_key_overview_title": "Обзор расхода по клиентским ключам", "credential_overview_title": "Обзор расхода по учётным данным", "provider_overview_title": "Обзор расхода по провайдерам", - "provider_overview_hint": "Стоимость, доля запросов, health, cache reuse и ведущая модель по provider.", + "provider_overview_hint": "Стоимость, доля запросов, состояние, повторное использование кэша и ведущая модель по провайдеру.", "provider_request_share": "Доля запросов", "provider_cost_share": "Доля стоимости", "provider_top_model": "Top model", @@ -2842,7 +2919,7 @@ "api_key_warning_title": "Предупреждения по клиентским ключам", "credential_rank_title": "Рейтинг учётных данных", "credential_warning_title": "Предупреждения по учётным данным", - "credential_warning_summary": "{{count}} аномальных учётных данных, {{high}} высокого риска, минимальный success {{lowest}}.", + "credential_warning_summary": "Аномальных учётных данных: {{count}}, высокого риска: {{high}}, минимальная успешность: {{lowest}}.", "credential_warning_selected_only": "Только текущие учётные данные", "heatmap_title": "Тепловая карта использования", "heatmap_hint": "Текущая метрика агрегируется по дню недели и часу, чтобы найти пики, стоимость и окна с ошибками.", @@ -2863,12 +2940,12 @@ "heatmap_compare_below": "Ниже среднего", "heatmap_compare_even": "На уровне", "heatmap_rank": "Ранг метрики", - "heatmap_contributors_title": "Top contributors", + "heatmap_contributors_title": "Основные источники", "heatmap_contributor_models": "Модели", - "heatmap_contributor_api_keys": "API Keys", - "heatmap_contributor_providers": "Providers", - "heatmap_contributor_meta": "{{share}} / {{tokens}} tokens / {{cost}}", - "heatmap_contributor_empty": "Нет данных contributors", + "heatmap_contributor_api_keys": "API-ключи", + "heatmap_contributor_providers": "Провайдеры", + "heatmap_contributor_meta": "{{share}} / {{tokens}} токенов / {{cost}}", + "heatmap_contributor_empty": "Нет данных по источникам", "heatmap_focus_title": "Фокус тепловой карты", "heatmap_focus_hint": "Агрегация в {{timeZone}}: окна высокого риска по запросам, стоимости и ошибкам.", "heatmap_peak_requests": "Пики запросов", @@ -2891,12 +2968,12 @@ "col_action": "Действие", "metric_request_count": "Запросы", "metric_total_tokens": "Всего токенов", - "metric_input_tokens": "Input tokens", - "metric_output_tokens": "Output tokens", - "metric_cached_tokens": "Cached tokens", - "metric_cache_creation_tokens": "Cache creation tokens", + "metric_input_tokens": "Входные токены", + "metric_output_tokens": "Выходные токены", + "metric_cached_tokens": "Кэшированные токены", + "metric_cache_creation_tokens": "Токены создания кэша", "metric_estimated_cost": "Оценочная стоимость", - "metric_p95_latency": "P95 latency", + "metric_p95_latency": "P95 задержка", "metric_average_latency": "Средняя задержка", "success_rate": "Успешность", "failure_rate": "Доля ошибок", @@ -2908,13 +2985,13 @@ "client_key_hash": "Клиентский ключ", "api_key_context_title": "Связанные контексты выполнения", "api_key_context_empty": "Для этого клиентского ключа нет связанных контекстов выполнения.", - "api_key_context_source": "Source", - "api_key_context_source_hash": "Source Hash", + "api_key_context_source": "Источник", + "api_key_context_source_hash": "Хэш источника", "api_key_context_last_seen": "Последняя активность", - "credential_identity_provider": "Provider", + "credential_identity_provider": "Провайдер", "credential_identity_account": "Аккаунт", - "credential_identity_auth_file": "Auth File", - "credential_identity_auth_index": "Auth Index", + "credential_identity_auth_file": "Файл входа", + "credential_identity_auth_index": "Индекс входа", "credential_identity_project_id": "Project ID", "credential_last_seen": "Последняя активность", "credential_last_seen_meta": "Время последнего запроса", @@ -2952,8 +3029,8 @@ "account_display_full": "Полностью", "show_full_credentials_hint": "Показать полные идентификаторы учётных данных", "show_masked_credentials_hint": "Показать скрытые идентификаторы учётных данных", - "observed_from_usage_headers": "Историческая запись из последних response headers usage, не live-запрос квоты.", - "observed_from_usage_headers_at": "Историческая запись из последних response headers usage, время записи: {{time}}." + "observed_from_usage_headers": "Историческая запись из недавних ответов сервиса, а не новая проверка квоты.", + "observed_from_usage_headers_at": "Историческая запись из недавних ответов сервиса, время записи: {{time}}." }, "codex_reauth": { "title": "Повторный вход Codex", @@ -2983,7 +3060,7 @@ }, "plugin_management": { "title": "Управление плагинами", - "description": "Просматривайте найденные и зарегистрированные плагины, управляйте переключателями экземпляров, полями конфигурации и ссылками на ресурсы.", + "description": "Просматривайте установленные и доступные плагины, управляйте включением, настройками и входами.", "refresh": "Обновить", "install_plugin": "Установить плагин", "tabs_aria_label": "Представления управления плагинами", @@ -2991,12 +3068,12 @@ "tab_store": "Магазин плагинов", "load_failed": "Не удалось загрузить плагины", "config_load_failed": "Не удалось прочитать конфигурацию плагина", - "config_not_found": "Не удалось прочитать конфигурацию плагина, потому что backend не нашел этот плагин.", - "unsupported_backend": "Текущий backend не предоставляет API управления плагинами. Используйте более новую сборку backend с endpoint'ами управления плагинами и перезапустите сервис.", + "config_not_found": "Не удалось прочитать конфигурацию плагина: текущий сервис не нашёл этот плагин.", + "unsupported_backend": "Текущая версия сервиса не поддерживает управление плагинами. Обновите CPA или Manager Server и перезапустите сервис.", "global_status": "Глобальный статус", "global_enabled": "Включено", "global_disabled": "Отключено", - "global_disabled_hint": "plugins.enabled имеет значение false, поэтому включенные экземпляры плагинов не станут активными.", + "global_disabled_hint": "Общий переключатель плагинов выключен, поэтому отдельные включённые плагины не будут работать.", "plugins_dir": "Каталог плагинов", "installed_count": "Установлено", "effective_count": "Активно", @@ -3010,18 +3087,18 @@ "meta_version": "Версия", "meta_author": "Автор", "meta_path": "Путь", - "discovered": "Найдено", - "effective": "Активно", + "discovered": "Обнаружено", + "effective": "Работает", "search_placeholder": "Искать ID, имя, автора или ресурс плагина...", "search_label": "Поиск плагинов", "no_plugins": "Плагинов нет", - "no_plugins_desc": "В каталоге плагинов и реестре времени выполнения плагины не найдены.", + "no_plugins_desc": "В каталоге плагинов сейчас нет доступных плагинов.", "configured": "Настроен", "not_configured": "Не настроен", "registered": "Зарегистрирован", "not_registered": "Не зарегистрирован", "oauth": "OAuth", - "status_effective": "Активен", + "status_effective": "Работает", "status_global_disabled": "Глобально отключен", "status_disabled": "Отключен", "status_inactive": "Неактивен", @@ -3053,14 +3130,14 @@ "config_title": "Настроить {{name}}", "base_settings": "Базовые настройки", "enabled": "Включено", - "enabled_hint": "Управляет полем enabled для этого экземпляра плагина в plugins.configs.", + "enabled_hint": "Управляет тем, включён ли этот плагин.", "priority": "Приоритет", - "config_fields": "Поля конфигурации", - "no_config_fields": "Этот плагин не объявляет визуальные поля конфигурации.", + "config_fields": "Настройки", + "no_config_fields": "У этого плагина нет настроек, которые можно редактировать в панели.", "select_placeholder": "Выберите", - "array_item_placeholder": "Введите элемент массива", - "add_array_item": "Добавить элемент массива", - "remove_array_item": "Удалить элемент массива", + "array_item_placeholder": "Введите значение", + "add_array_item": "Добавить значение", + "remove_array_item": "Удалить значение", "toggle_success": "Статус плагина обновлен", "toggle_failed": "Не удалось обновить статус плагина", "save_success": "Конфигурация плагина сохранена", @@ -3070,20 +3147,20 @@ "invalid_number": "Введите корректное число", "invalid_integer": "Введите корректное целое число", "invalid_json": "Введите корректный JSON", - "expected_array": "Введите JSON-массив", + "expected_array": "Введите JSON-список", "expected_object": "Введите JSON-объект", - "invalid_enum": "Выберите одно из объявленных значений enum" + "invalid_enum": "Выберите один из доступных вариантов" }, "plugin_store": { "title": "Магазин плагинов", - "description": "Просматривайте реестр плагинов, устанавливайте и обновляйте плагины для текущего backend.", + "description": "Просматривайте доступные плагины, устанавливайте и обновляйте их для текущего сервиса.", "refresh": "Обновить", "retry": "Повторить", "load_failed": "Не удалось загрузить магазин плагинов", "loading_title": "Загрузка магазина плагинов", "loading_desc": "Чтение источников плагинов и статуса установки", - "unsupported_backend": "Текущий backend не предоставляет API магазина плагинов. Используйте новую сборку backend с поддержкой магазина плагинов и перезапустите службу.", - "registry_failed": "Не удалось обратиться к реестру плагинов", + "unsupported_backend": "Текущая версия сервиса не поддерживает магазин плагинов. Обновите CPA или Manager Server и перезапустите сервис.", + "registry_failed": "Не удалось обратиться к источнику плагинов", "global_status": "Глобальный статус", "global_enabled": "Включено", "global_disabled": "Отключено", @@ -3099,7 +3176,7 @@ "meta_platforms": "Платформы", "source_name": "Источник: {{source}}", "cli_proxy_api_source": "Источник CLIProxyAPI", - "source_errors_title": "Предупреждения источников", + "source_errors_title": "Предупреждения источников плагинов", "unknown_source": "Неизвестный источник", "source_required": "Этот плагин есть в нескольких источниках магазина. Используйте карточку плагина с конкретным источником и повторите попытку.", "search_placeholder": "Поиск по ID, названию, автору или тегу...", @@ -3118,20 +3195,20 @@ "platforms": "{{platforms}}", "auth_required": "Требуется аутентификация", "auth_configured": "Аутентификация настроена", - "auth_required_hint": "Настройте plugins.store-auth перед установкой из этого источника или URL артефакта.", + "auth_required_hint": "Перед установкой настройте данные доступа к этому источнику плагинов.", "install": "Установить", "update": "Обновить", "manage": "Управлять", "reinstall_plugin": "Переустановить", "delete_plugin": "Удалить", "install_confirm_title": "Установка плагина", - "install_confirm_message": "Скачать {{target}} из реестра плагинов и установить в локальный каталог плагинов?", - "install_security_warning_title": "Сторонние плагины запускаются с полными правами backend", + "install_confirm_message": "Скачать {{target}} и установить в локальный каталог плагинов?", + "install_security_warning_title": "Сторонние плагины могут получать доступ к данным сервиса", "install_security_warning_message": "Плагины выполняют код внутри прокси-сервиса и могут читать ваши учетные данные и трафик. Устанавливайте только плагины, которым доверяете, и особенно осторожно относитесь к плагинам, опубликованным не официальной организацией router-for-me.", "update_confirm_title": "Обновление плагина", "update_confirm_message": "Скачать и установить последнюю версию {{target}}?", "install_version_label": "Версия (необязательно)", - "install_version_hint": "Оставьте пустым, чтобы использовать backend-версию по умолчанию/последнюю. Версия в списке: {{version}}.", + "install_version_hint": "Оставьте пустым, чтобы использовать версию по умолчанию или последнюю. Версия в списке: {{version}}.", "install_version_latest": "Последняя версия", "reinstall_confirm_title": "Переустановить плагин", "reinstall_confirm_message": "{{target}} будет сначала удален, а затем установлен заново из магазина плагинов. Если удаление пройдет успешно, а установка завершится ошибкой, плагин останется удаленным. Продолжить?", @@ -3151,15 +3228,15 @@ "restart_required_banner": "Перезапустите службу, чтобы применить новую версию: {{plugins}}", "open_repository": "Открыть репозиторий", "open_homepage": "Открыть сайт", - "no_plugins": "Реестр пуст", - "no_plugins_desc": "Реестр плагинов не вернул ни одного плагина.", + "no_plugins": "Плагинов пока нет", + "no_plugins_desc": "Источник плагинов не вернул ни одного плагина.", "no_matches": "Нет подходящих плагинов", "no_matches_desc": "Нет плагинов, соответствующих текущему поиску или фильтру.", "clear_filters": "Сбросить фильтры", "gate_title": "Установить {{name}}", "gate_title_update": "Обновить {{name}}", - "gate_warning": "Сторонние плагины запускаются с полным доступом к backend.", - "gate_effect_runs_code": "Этот плагин будет выполнять сторонний код внутри proxy backend и может получить доступ к учетным данным, запросам и ответам.", + "gate_warning": "Сторонние плагины могут получать доступ к данным сервиса.", + "gate_effect_runs_code": "Этот плагин будет выполнять сторонний код внутри прокси-сервиса и может получить доступ к учётным данным, запросам и ответам.", "gate_effect_no_review": "Он не опубликован и не проверен официальной организацией router-for-me. Поведение будущих версий может измениться.", "gate_effect_restart": "Установка изменяет локальный каталог плагинов и может потребовать перезапуска службы.", "gate_untrusted_alert": "Продолжайте только если полностью доверяете автору, репозиторию и источнику этого плагина.", @@ -3176,10 +3253,10 @@ "title": "Страница плагина", "page_count": "{{count}} страниц", "load_failed": "Не удалось загрузить страницу плагина", - "unsupported_backend": "Текущий backend не предоставляет API страниц плагинов. Используйте более новую сборку backend с endpoint'ами управления плагинами и перезапустите сервис.", + "unsupported_backend": "Текущая версия сервиса не поддерживает страницы плагинов. Обновите CPA или Manager Server и перезапустите сервис.", "unavailable": "Страница плагина недоступна", "not_found": "Страница плагина не найдена", - "not_found_desc": "Эта страница плагина не объявлена текущим списком плагинов backend.", + "not_found_desc": "Текущий сервис не объявил эту страницу плагина.", "empty_src": "URL страницы плагина пуст", "empty_src_desc": "Это меню плагина не предоставляет URL страницы для встраивания." }, @@ -3276,6 +3353,7 @@ "openai_test_model_required": "Выберите модель для теста", "data_refreshed": "Данные успешно обновлены", "connection_required": "Сначала установите подключение", + "load_failed": "Ошибка загрузки", "refresh_failed": "Не удалось обновить", "save_failed": "Не удалось сохранить", "update_failed": "Не удалось обновить", @@ -3326,12 +3404,12 @@ }, "accountPolicy": { "section_title": "Политика обработки аккаунтов", - "section_hint": "Влияет только на новые события мониторинга запросов. Задачи в очереди или выполнении продолжат завершаться.", - "runtime_note": "Незаблокированные переключатели сохраняются в этом Manager Server и применяются без перезапуска. Переменные окружения по-прежнему блокируют соответствующие переключатели.", + "section_hint": "Влияет только на новые проблемы, которые найдёт мониторинг запросов. Уже поставленные или выполняющиеся задачи продолжат работу.", + "runtime_note": "Переключатели, не зафиксированные при запуске сервиса, сохраняются в этом Manager Server и применяются без перезапуска. Переменные окружения по-прежнему блокируют соответствующие настройки.", "group_quota_title": "Обработка квоты", - "group_quota_description": "Управляет охлаждением и восстановлением auth-файлов после обнаружения исчерпания квоты мониторингом запросов.", + "group_quota_description": "Управляет временным отключением и восстановлением файлов входа, когда мониторинг видит исчерпанную квоту.", "group_auth_issues_title": "Обработка проблем аутентификации", - "group_auth_issues_description": "Управляет обработкой отозванных токенов, недоступной аутентификации и деактивированных рабочих пространств.", + "group_auth_issues_description": "Управляет тем, как записываются и обрабатываются проблемы входа, недоступные аккаунты и отключённые рабочие пространства.", "refresh": "Обновить", "load_failed_title": "Ошибка загрузки", "load_failed": "Ошибка загрузки: {{message}}", @@ -3354,55 +3432,55 @@ "source_env": "Используется переменная окружения", "source_database": "Используется настройка UI", "codexQuotaCooldown_title": "Обработка ограничения квоты Codex", - "codexQuotaCooldown_summary": "После подтверждённого кратковременного исчерпания лимита Codex временно отключает auth-файл и восстанавливает его после сброса.", + "codexQuotaCooldown_summary": "Когда кратковременная квота Codex закончилась, временно отключает соответствующий файл входа и включает его снова после восстановления квоты.", "codexQuotaCooldown_toggle": "Включить обработку охлаждения квоты", - "codexQuotaCooldown_description": "Когда мониторинг подтверждает кратковременный usage-limit Codex, файл аутентификации временно отключается и автоматически восстанавливается после времени сброса.", + "codexQuotaCooldown_description": "Когда мониторинг подтверждает, что кратковременная квота Codex закончилась, файл входа временно отключается и автоматически включается после восстановления квоты.", "codexQuotaCooldown_behavior": [ "Только Codex", - "Только 429 + usage_limit_reached", - "Требуется resets_at или resets_in_seconds", - "Временно отключает файл аутентификации", - "Автоматически восстанавливается после времени сброса", - "Восстанавливает только отключения, принадлежащие CPAMP", - "Не восстанавливает файлы, отключенные вручную" + "Только явная ошибка исчерпанной квоты", + "Нужно определить время восстановления", + "Временно отключает файл входа", + "Автоматически включает после восстановления квоты", + "Восстанавливает только аккаунты, отключённые CPAMP", + "Не восстанавливает файлы входа, отключённые вручную" ], - "authIssueQueue_title": "Очередь проблем аутентификации", - "authIssueQueue_summary": "Записывает высокодостоверные проблемы аутентификации как кандидатов для ручной обработки.", - "authIssueQueue_toggle": "Записывать кандидатов проблем аутентификации", - "authIssueQueue_description": "Когда мониторинг обнаруживает отозванный токен, недоступную аутентификацию или деактивированное рабочее пространство, здесь создаётся кандидат для ручной обработки.", + "authIssueQueue_title": "Список проблем аутентификации", + "authIssueQueue_summary": "Записывает явные проблемы входа или аккаунта, чтобы вы могли обработать их вручную.", + "authIssueQueue_toggle": "Записывать проблемы аутентификации", + "authIssueQueue_description": "Когда мониторинг обнаруживает сбой входа, недоступный аккаунт или отключённое рабочее пространство, проблема сохраняется для ручной обработки.", "authIssueQueue_behavior": [ - "Записывает кандидатов при проблемах аутентификации", + "Записывает найденные проблемы аутентификации", "Обрабатывается вручную на странице проблем аутентификации", - "Не создаёт новых кандидатов, когда отключено", - "Существующие кандиденты по-прежнему можно обрабатывать" + "Не записывает новые проблемы, когда отключено", + "Существующие проблемы по-прежнему можно обрабатывать" ], - "authIssueAutoDisable_title": "Автоотключение проблемных auth-файлов", - "authIssueAutoDisable_summary": "Сначала автоматически отключает высокодостоверных кандидатов delete / reauth, сохраняя ручную проверку.", - "authIssueAutoDisable_toggle": "Автоотключать высокодостоверные проблемные auth-файлы", - "authIssueAutoDisable_description": "Если включено, высокодостоверные кандидаты сначала автоматически отключают файл аутентификации, но всё равно требуют ручной обработки.", + "authIssueAutoDisable_title": "Автоотключение проблемных файлов входа", + "authIssueAutoDisable_summary": "Для явных проблем, где аккаунт нужно удалить или войти заново, сначала автоматически отключает файл входа, но оставляет ручное подтверждение.", + "authIssueAutoDisable_toggle": "Автоотключать явно проблемные файлы входа", + "authIssueAutoDisable_description": "Если включено, аккаунты с явными проблемами сначала автоматически отключают файл входа, но всё равно требуют ручной обработки.", "authIssueAutoDisable_behavior": [ - "Автоотключает только высокодостоверные кандиденты delete / reauth", - "Не отключает review автоматически", - "Игнорирует обычные payment/quota 402", - "Перечитывает файлы аутентификации и проверяет личность перед отключением", + "Автоотключает только аккаунты, которые явно нужно удалить или авторизовать заново", + "Аккаунты, требующие проверки, не отключаются автоматически", + "Обычные ошибки оплаты или квоты не обрабатываются", + "Перед отключением заново читает файл входа и проверяет аккаунт", "Только отключает, никогда не удаляет", "Никогда не восстанавливает автоматически", "Всё равно требует ручной обработки" ], - "authIssueAutoDisable_dependency_note": "Сначала включите «Очередь проблем аутентификации».", - "authIssueAutoDisable_configured_dependency_note": "Включено, ожидает включения очереди проблем аутентификации.", - "authIssueAutoDisable_confirm_title": "Включить автоотключение проблемных auth-файлов?", - "authIssueAutoDisable_confirm_body": "Когда включено, Manager Server отключает подходящие auth-файлы после обнаружения высокодостоверных проблем аутентификации мониторингом запросов.", - "authIssueAutoDisable_confirm_disable_only": "Оно только отключает подходящие auth-файлы и никогда не удаляет их.", - "authIssueAutoDisable_confirm_no_recovery": "Оно никогда не восстанавливает их автоматически; кандидат всё равно обрабатывается на странице проблем аутентификации.", - "authIssueAutoDisable_confirm_requires_queue": "Эта возможность зависит от действующей очереди проблем аутентификации.", + "authIssueAutoDisable_dependency_note": "Сначала включите «Список проблем аутентификации».", + "authIssueAutoDisable_configured_dependency_note": "Включено, но начнёт работать только после включения списка проблем аутентификации.", + "authIssueAutoDisable_confirm_title": "Включить автоотключение проблемных файлов входа?", + "authIssueAutoDisable_confirm_body": "Когда включено, Manager Server отключает подходящие файлы входа после обнаружения явных проблем аутентификации мониторингом запросов.", + "authIssueAutoDisable_confirm_disable_only": "Оно только отключает подходящие файлы входа и никогда не удаляет их.", + "authIssueAutoDisable_confirm_no_recovery": "Оно не включает их автоматически; проблема всё равно обрабатывается вручную на странице проблем аутентификации.", + "authIssueAutoDisable_confirm_requires_queue": "Эта возможность зависит от включённого списка проблем аутентификации.", "authIssueAutoDisable_confirm_button": "Включить автоотключение", - "authIssueAutoDisable_confirm": "Включить автоотключение проблем аутентификации? Оно только отключает подходящие auth-файлы, никогда не удаляет и не восстанавливает их автоматически; ручная обработка всё равно нужна.", + "authIssueAutoDisable_confirm": "Включить автоотключение проблем аутентификации? Оно только отключает подходящие файлы входа, никогда не удаляет и не включает их автоматически; ручная обработка всё равно нужна.", "open_auth_issues": "Открыть проблемы аутентификации", "effective_label": "Действует", "advanced_summary": "Подробнее", "env_locked_hint": "Этот переключатель зафиксирован переменной окружения. Измените переменную окружения сервиса и перезапустите его, чтобы изменить значение.", "env_locked_reason": "Зафиксировано переменной окружения {{envKey}}. Измените её и перезапустите сервис, чтобы изменить этот переключатель.", - "gating_note": "Отключение переключателя останавливает обработку только новых событий мониторинга запросов. Задачи, уже поставленные в очередь или находящиеся в выполнении, продолжат завершаться." + "gating_note": "Отключение переключателя влияет только на новые проблемы, найденные мониторингом. Уже поставленные или выполняющиеся задачи продолжат работу." } } diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index 03f7fcc2a..74dc57333 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -50,10 +50,11 @@ "copy": "复制", "expand": "展开", "collapse": "收起", + "navigation": "导航", "status": "状态", "action": "操作", "custom_headers_label": "自定义请求头", - "custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。", + "custom_headers_hint": "可选。需要额外发送给服务的请求头可以填在这里,名称和值都要填写。", "custom_headers_add": "添加请求头", "custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header", "custom_headers_value_placeholder": "Header 值", @@ -86,7 +87,7 @@ "custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。", "cpa_connection_label": "CPA 连接地址", "cpa_connection_placeholder": "例如: http://127.0.0.1:8317", - "cpa_connection_hint": "Manager Server 会用该地址连接 CPA Management API 和 RESP 用量队列。", + "cpa_connection_hint": "Manager Server 会通过这个地址连接 CPA,并读取请求用量数据。", "cpa_address_required": "请输入 CPA 地址", "usage_service_mode_hint": "当前面板由 Manager Server 托管,请填写 CPA API 地址。", "usage_service_configured_hint": "当前面板由已配置的 Manager Server 托管,登录后会使用服务端保存的 CPA 连接。", @@ -101,7 +102,7 @@ "migration_detected_hint": "检测到历史数据,服务端会保留数据并自动迁移旧配置。", "cpa_management_key_label": "CPA Management Key", "cpa_management_key_placeholder": "请输入 CPA Management Key", - "cpa_management_key_hint": "仅供 Manager Server 校验和管理 CPA 上游使用。", + "cpa_management_key_hint": "仅供 Manager Server 连接和管理 CPA 使用。", "cpa_management_key_required": "请输入 CPA Management Key", "setup_title": "初始化向导", "setup_steps": "初始化步骤", @@ -114,10 +115,10 @@ "step_polling": "采集间隔", "step_review": "确认提交", "request_monitoring_enabled": "启用请求监控", - "request_monitoring_enabled_hint": "保存时会自动启用 CPA 用量统计,并启动 Manager Server 采集器。", - "request_monitoring_disabled_hint": "关闭后仍会保存 CPA 连接,但不会启用 CPA 用量统计或启动采集器。若 CPA 已在其他位置开启用量统计,队列保留时间内再次启用采集器可能仍会采集到期间数据。", - "poll_interval_label": "采集轮询间隔 (毫秒)", - "poll_interval_hint": "必须小于等于 CPA 用量队列保留时间;保存时会自动启用 CPA 用量统计。", + "request_monitoring_enabled_hint": "保存时会自动开启 CPA 用量统计,并开始读取请求记录。", + "request_monitoring_disabled_hint": "关闭后仍会保存 CPA 连接,但不会记录新的监控数据。若 CPA 已在其他位置记录用量,短时间内重新开启后,可能会看到之前保留的一小段数据。", + "poll_interval_label": "读取间隔 (毫秒)", + "poll_interval_hint": "读取间隔不能超过 CPA 保存用量数据的时间;保存时会自动开启 CPA 用量统计。", "poll_interval_invalid": "请输入有效的采集间隔", "show_key": "显示密钥", "hide_key": "隐藏密钥", @@ -140,8 +141,8 @@ "error_forbidden": "访问被拒绝,权限不足", "error_not_found": "服务器地址无效或管理接口未启用", "error_server": "服务器内部错误,请稍后重试", - "error_backend_detail": "后端详情", - "error_cors": "跨域请求被阻止,请检查服务器配置", + "error_backend_detail": "服务返回详情", + "error_cors": "浏览器拦截了这次连接,请检查服务器跨域设置", "error_ssl": "SSL/TLS 证书验证失败" }, "header": { @@ -158,34 +159,46 @@ }, "nav": { "dashboard": "仪表盘", + "dashboard_short": "仪表盘", "basic_settings": "基础设置", "api_keys": "API 密钥", "ai_providers": "AI 提供商", + "ai_providers_short": "AI", "auth_files": "认证文件", + "auth_files_short": "认证", "oauth": "OAuth 登录", + "oauth_short": "OAuth", "quota_management": "配额管理", + "quota_management_short": "配额", "plugins": "插件管理", "plugins_short": "插件管理", "plugin_store": "插件商店", "plugin_store_short": "商店", "codex_inspection": "Codex 账号巡检", + "codex_inspection_short": "Codex 巡检", "server_codex_inspection": "服务端巡检", "usage_analytics": "用量分析", "monitoring_center": "请求监控", + "monitoring_center_short": "监控", "account_actions": "认证异常", + "account_actions_short": "异常", "config_management": "配置面板", + "config_management_short": "配置", + "system_config": "系统配置", "logs": "日志查看", - "system_info": "系统信息" + "logs_short": "日志", + "system_info": "系统信息", + "system_info_short": "系统" }, "account_actions": { - "eyebrow": "Auth Issue Queue", + "eyebrow": "账号异常", "title": "认证异常处理", "back_to_monitoring": "返回请求监控", - "description": "请求监控发现 OAuth token 被撤销、认证不可用或工作区停用时,会记录为认证异常候选,并在这里等待你确认删除、重新启用、忽略或标记为已处理。", + "description": "这里列出请求监控发现的登录或账号异常,例如账号已失效、无法认证或工作区被停用。你可以确认删除、重新启用、忽略或标记为已处理。", "pending_count": "待处理", "visible_count": "当前列表", "status_filter": "状态筛选", - "search_placeholder": "搜索账号、文件、原因、Header 错误或 trace", + "search_placeholder": "搜索账号、文件、原因或错误信息", "filter_pending": "待处理", "filter_all": "全部", "filter_ignored": "已忽略", @@ -215,7 +228,7 @@ "evidence_title": "失败证据", "no_evidence": "没有可展示的证据。", "empty_title": "当前没有认证异常", - "empty_desc": "当请求监控捕获到 token_revoked 或 invalidated oauth token 等认证失败时,会自动出现在这里。", + "empty_desc": "当请求监控发现账号登录失效、认证失败等问题时,会自动出现在这里。", "load_failed_title": "加载失败", "load_failed": "加载认证异常失败:{{message}}", "action_failed": "操作失败:{{message}}", @@ -278,14 +291,14 @@ "token_mix_cache_creation": "缓存创建", "model_cost_rank": "模型成本排行", "no_traffic_data": "暂无流量", - "collector_status_title": "采集器状态", - "collector_events": "事件数", - "collector_dead_letters": "死信", + "collector_status_title": "请求记录读取状态", + "collector_events": "记录数", + "collector_dead_letters": "异常记录", "collector_mode": "模式", - "collector_queue": "队列", - "collector_last_inserted": "最近入库", - "collector_last_consumed": "最近消费", - "collector_total_inserted": "累计入库", + "collector_queue": "数据来源", + "collector_last_inserted": "最近写入", + "collector_last_consumed": "最近读取", + "collector_total_inserted": "累计写入", "collector_total_skipped": "累计跳过", "collector_last_error": "最近错误", "collector_unavailable": "不可用", @@ -305,7 +318,7 @@ "health_status_problem": "异常", "health_status_disabled": "已停用", "health_status_warning": "警告", - "health_queue_status": "队列状态", + "health_queue_status": "数据读取状态", "health_error_log_count": "{{count}} 个文件", "today_overview_usage_service": "今日概览(基于 Manager Server)", "metric_failure_count": "失败 {{value}}", @@ -404,6 +417,7 @@ "gemini_add_modal_title": "添加Gemini API密钥", "gemini_add_modal_key_label": "API密钥", "gemini_add_modal_key_placeholder": "输入 Gemini API 密钥", + "gemini_key_required": "请输入 Gemini API 密钥", "gemini_add_modal_key_hint": "逐条输入密钥,可同时指定可选 Base URL。", "gemini_keys_add_btn": "添加密钥", "gemini_base_url_label": "Base URL (可选)", @@ -415,7 +429,7 @@ "gemini_models_add_btn": "添加模型", "gemini_models_fetch_button": "从 /v1beta/models 获取", "gemini_models_fetch_title": "从 Gemini /v1beta/models 选择模型", - "gemini_models_fetch_hint": "按 Gemini API 规范请求 GET /v1beta/models;默认附带 x-goog-api-key(来自 API Key 字段)并合并你配置的自定义请求头。", + "gemini_models_fetch_hint": "会使用上方地址和 Gemini 密钥读取模型列表;如配置了额外请求头,也会一起带上。", "gemini_models_fetch_url_label": "请求地址", "gemini_models_fetch_refresh": "重新获取", "gemini_models_fetch_loading": "正在从 Gemini /v1beta/models 获取模型列表...", @@ -432,6 +446,7 @@ "gemini_models_count": "模型数量", "gemini_edit_modal_title": "编辑Gemini API密钥", "gemini_edit_modal_key_label": "API密钥:", + "gemini_delete_title": "删除 Gemini 密钥", "gemini_delete_confirm": "确定要删除这个Gemini密钥吗?", "excluded_models_label": "排除的模型 (可选):", "excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash", @@ -456,6 +471,7 @@ "codex_add_modal_title": "添加Codex API配置", "codex_add_modal_key_label": "API密钥:", "codex_add_modal_key_placeholder": "请输入Codex API密钥", + "codex_key_required": "请输入 Codex API 密钥", "codex_add_modal_url_label": "Base URL (必填):", "codex_add_modal_url_placeholder": "例如: https://api.example.com", "codex_add_modal_proxy_label": "代理 URL (可选):", @@ -467,11 +483,11 @@ "codex_models_add_btn": "添加模型", "codex_models_fetch_button": "从 /v1/models 获取", "codex_models_fetch_title": "从 /v1/models 选择模型", - "codex_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带 API Key(Bearer)与自定义请求头。", + "codex_models_fetch_hint": "会使用上方地址和密钥读取模型列表;如配置了额外请求头,也会一起带上。", "codex_models_fetch_url_label": "请求地址", "codex_models_fetch_refresh": "重新获取", "codex_models_fetch_loading": "正在从 /v1/models 获取模型列表...", - "codex_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。", + "codex_models_fetch_empty": "未获取到模型,请检查地址、密钥或请求头。", "codex_models_fetch_error": "获取模型失败", "codex_models_fetch_apply": "添加所选模型", "codex_models_search_label": "搜索模型", @@ -479,7 +495,7 @@ "codex_models_search_empty": "没有匹配的模型,请更换关键字试试。", "codex_models_fetch_added": "已添加 {{count}} 个新模型", "codex_test_title": "连通性测试", - "codex_test_hint": "向 /v1/responses 发送测试请求,验证当前 Codex 配置是否可用。", + "codex_test_hint": "发送一次测试请求,确认当前 Codex 配置是否可用。", "codex_test_model_label": "测试模型", "codex_test_select_placeholder": "选择或输入要测试的模型", "codex_test_select_empty": "请先添加模型再进行测试", @@ -489,7 +505,7 @@ "codex_test_failed": "Codex 连通性测试失败", "codex_test_endpoint_invalid": "请先填写有效的 Base URL", "codex_test_model_required": "请先填写测试模型", - "codex_test_key_required": "请提供 API Key、Authorization 请求头或 auth_index", + "codex_test_key_required": "请先填写 API Key,或选择一个已登录账号。", "codex_models_count": "模型数量", "codex_edit_modal_title": "编辑Codex API配置", "codex_edit_modal_key_label": "API密钥:", @@ -504,7 +520,9 @@ "claude_add_modal_title": "添加Claude API配置", "claude_add_modal_key_label": "API密钥:", "claude_add_modal_key_placeholder": "请输入Claude API密钥", + "claude_key_required": "请输入 Claude API 密钥", "claude_add_modal_url_label": "Base URL (可选):", + "claude_base_url_required": "请输入 Claude Base URL", "claude_add_modal_url_placeholder": "例如: https://api.anthropic.com", "claude_add_modal_proxy_label": "代理 URL (可选):", "claude_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080", @@ -519,7 +537,7 @@ "claude_models_count": "模型数量", "claude_models_fetch_button": "从 /v1/models 获取", "claude_models_fetch_title": "从 Claude /v1/models 选择模型", - "claude_models_fetch_hint": "按 Anthropic 规范请求 GET /v1/models,默认附带 x-api-key 与 anthropic-version: 2023-06-01;也会合并你配置的自定义请求头。", + "claude_models_fetch_hint": "会使用上方地址和 Claude 密钥读取模型列表;如配置了额外请求头,也会一起带上。", "claude_models_fetch_url_label": "请求地址", "claude_models_fetch_refresh": "重新获取", "claude_models_fetch_loading": "正在从 Claude /v1/models 获取模型列表...", @@ -531,7 +549,7 @@ "claude_models_search_empty": "没有匹配的模型,请更换关键字试试。", "claude_models_fetch_added": "已添加 {{count}} 个新模型", "claude_test_title": "连通性测试", - "claude_test_hint": "按 Anthropic 规范向 /v1/messages 发送测试请求,验证当前配置是否可用。", + "claude_test_hint": "发送一次测试请求,确认当前 Claude 配置是否可用。", "claude_test_select_placeholder": "从当前模型列表选择", "claude_test_select_empty": "当前未配置模型,请先添加模型", "claude_test_action": "测试", @@ -565,7 +583,9 @@ "vertex_add_modal_title": "添加Vertex API配置", "vertex_add_modal_key_label": "API密钥:", "vertex_add_modal_key_placeholder": "请输入Vertex API密钥", + "vertex_key_required": "请输入 Vertex API 密钥", "vertex_add_modal_url_label": "Base URL:", + "vertex_base_url_required": "请输入 Vertex Base URL", "vertex_add_modal_url_placeholder": "例如: https://example.com/api", "vertex_add_modal_proxy_label": "代理 URL (可选):", "vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080", @@ -573,6 +593,7 @@ "vertex_edit_modal_key_label": "API密钥:", "vertex_edit_modal_url_label": "Base URL:", "vertex_edit_modal_proxy_label": "代理 URL (可选):", + "vertex_delete_title": "删除 Vertex 配置", "vertex_delete_confirm": "确定要删除这个Vertex配置吗?", "vertex_models_label": "模型别名:", "vertex_models_add_btn": "添加映射", @@ -599,6 +620,7 @@ "openai_add_modal_url_placeholder": "例如: https://openrouter.ai/api/v1", "openai_add_modal_keys_label": "API密钥", "openai_edit_modal_keys_label": "API密钥", + "openai_key_required": "请至少添加一个 API 密钥", "openai_keys_hint": "每个密钥可搭配一个可选代理地址,更便于管理。", "openai_keys_add_btn": "添加密钥", "openai_key_placeholder": "输入 sk- 开头的密钥", @@ -610,11 +632,11 @@ "openai_models_add_btn": "添加模型", "openai_models_fetch_button": "从 /models 获取", "openai_models_fetch_title": "从 /models 选择模型", - "openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API Key(Bearer)与自定义请求头。", + "openai_models_fetch_hint": "会使用上方地址和第一个密钥读取模型列表;如配置了额外请求头,也会一起带上。", "openai_models_fetch_url_label": "请求地址", "openai_models_fetch_refresh": "重新获取", "openai_models_fetch_loading": "正在从 /models 获取模型列表...", - "openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。", + "openai_models_fetch_empty": "未获取到模型,请检查地址、密钥或请求头。", "openai_models_fetch_error": "获取模型失败", "openai_models_fetch_back": "返回编辑", "openai_models_fetch_apply": "添加所选模型", @@ -627,12 +649,14 @@ "openai_edit_modal_name_label": "提供商名称:", "openai_edit_modal_url_label": "Base URL:", "openai_edit_modal_models_label": "模型列表 (name[, alias] 每行一个):", + "openai_delete_title": "删除 OpenAI 提供商", "openai_delete_confirm": "确定要删除这个OpenAI提供商吗?", "openai_keys_count": "密钥数量", "openai_models_count": "模型数量", "openai_test_title": "连通性测试", "openai_test_hint": "使用当前配置向 /chat/completions 请求,验证是否可用。", "openai_test_model_placeholder": "选择或输入要测试的模型", + "openai_test_model_required": "请选择要测试的模型", "openai_test_action": "发送测试", "openai_test_running": "正在发送测试请求...", "openai_test_timeout": "测试请求超时({{seconds}}秒)。", @@ -659,6 +683,9 @@ "table_col_models": "模型", "table_col_recent": "近期请求", "table_col_actions": "操作", + "priority_decrease": "降低优先级", + "priority_edit": "编辑优先级", + "priority_increase": "提高优先级", "filter_all": "全部", "filter_kind_aria": "按提供商类型筛选", "search_placeholder": "搜索名称 / 地址 / 密钥", @@ -709,7 +736,7 @@ "auth_files": { "title": "认证文件管理", "title_section": "认证文件", - "description": "这里集中管理 CLI Proxy 支持的所有 JSON 认证文件(如 Qwen、Gemini、Vertex 等),上传后即可在运行时启用相应的 AI 服务。", + "description": "这里管理各 AI 服务的登录文件,例如 Qwen、Gemini、Vertex。上传后,对应服务就可以在面板中使用。", "upload_button": "上传文件", "paste_button": "粘贴 JSON", "paste_title": "粘贴认证 JSON", @@ -723,9 +750,9 @@ "paste_cpa_placeholder": "{\n \"type\": \"codex\",\n \"access_token\": \"...\"\n}", "paste_session_placeholder": "{\n \"user\": { \"email\": \"...\" },\n \"account\": { \"id\": \"...\" },\n \"accessToken\": \"...\",\n \"sessionToken\": \"...\"\n}", "paste_sub2api_placeholder": "{\n \"exported_at\": \"...\",\n \"proxies\": [],\n \"accounts\": [\n {\n \"platform\": \"openai\",\n \"type\": \"oauth\",\n \"credentials\": { \"access_token\": \"...\" }\n }\n ]\n}", - "paste_cpa_hint": "验证为 JSON 对象后会原样保存。", - "paste_session_hint": "ChatGPT Web session JSON 会先转换为 CPA Codex 认证文件再上传。Token 属于敏感登录凭证。", - "paste_sub2api_hint": "sub2api 官方账号导出 JSON 会提取 OpenAI OAuth 账号并转换为 CPA Codex 认证文件。Token 属于敏感登录凭证。", + "paste_cpa_hint": "确认内容是有效 JSON 后会直接保存。", + "paste_session_hint": "ChatGPT 网页登录信息会先转换成 CPA 可用的 Codex 登录文件再上传。Token 属于敏感登录信息,请勿分享。", + "paste_sub2api_hint": "sub2api 导出的账号 JSON 会提取 OpenAI OAuth 账号,并转换成 CPA 可用的 Codex 登录文件。Token 属于敏感登录信息,请勿分享。", "paste_error_file_name": "文件名不能为空", "paste_error_file_extension": "文件名必须以 .json 结尾", "paste_error_file_name_invalid": "请使用不包含路径分隔符或保留字符的 .json 文件名", @@ -783,7 +810,7 @@ "pagination_next": "下一页", "pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件", "search_label": "搜索凭证", - "search_placeholder": "搜索名称、类型、认证索引、账号 ID、套餐或错误状态,支持 * 通配", + "search_placeholder": "搜索名称、类型、账号、套餐或错误状态,支持 * 通配", "problem_filter_label": "问题筛选", "problem_filter_only": "仅显示有问题凭证", "disabled_filter_only": "仅显示已停用凭证", @@ -865,16 +892,18 @@ "codex_status_badge_monthly_limited_title": "Codex 月额度窗口已达到上限,通常需要等待月额度恢复。", "codex_status_badge_disabled_reset": "恢复 {{reset}}", "codex_status_badge_disabled_reset_title": "这个已停用 Codex 账号有已知额度恢复时间:{{reset}}。", - "codex_status_badge_observed_quota": "用量 Header 额度", - "codex_status_badge_observed_quota_title": "最近用量响应 Header 记录了额度或恢复时间。该信息来自历史请求,不代表实时主动查询结果。", - "codex_status_badge_observed_error": "用量 Header 错误", - "codex_status_badge_observed_error_title": "最近用量响应 Header 记录了认证、额度或限流错误。该信息来自历史请求,用于辅助排查。", + "codex_status_badge_observed_quota": "最近请求中的额度", + "codex_status_badge_observed_quota_title": "最近请求返回过额度或恢复时间。该信息来自历史请求,不代表实时查询结果。", + "codex_status_badge_observed_error": "最近请求中的错误", + "codex_status_badge_observed_error_title": "最近请求返回过登录、额度或限流错误。该信息来自历史请求,仅用于辅助排查。", "status_toggle_label": "启用", "status_enabled_success": "已启用 \"{{name}}\"", "status_disabled_success": "已停用 \"{{name}}\"", "batch_status_success": "已成功更新 {{count}} 个文件", "batch_status_partial": "成功 {{success}} 个,失败 {{failed}} 个", "batch_delete_title": "删除选中文件", + "delete_title": "删除文件", + "delete_all_title": "删除全部文件", "batch_delete_confirm": "确定要删除 {{count}} 个文件吗?", "batch_selected": "已选 {{count}} 项", "batch_select_all": "全选", @@ -894,11 +923,14 @@ "batch_priority_invalid": "请输入整数优先级", "batch_websockets_enable": "启用 WebSockets", "batch_websockets_disable": "禁用 WebSockets", + "card_tools_title": "工具", + "quota_refresh_single": "刷新额度", + "quota_refresh_hint": "仅刷新此凭证的额度", "prefix_proxy_button": "认证文件详情 / 编辑", "auth_field_editor_title": "认证文件详情 / 编辑 - {{name}}", "prefix_proxy_loading": "正在加载认证文件...", "prefix_proxy_info_label": "认证文件信息(info)", - "prefix_proxy_source_label": "认证文件 JSON(预览)", + "prefix_proxy_source_label": "登录文件内容(预览)", "prefix_proxy_invalid_content_label": "下载内容(已截断)", "prefix_label": "前缀(prefix)", "proxy_url_label": "代理 URL(proxy_url)", @@ -907,6 +939,8 @@ "priority_label": "优先级(priority)", "priority_placeholder": "例如: 10 或 -1", "priority_hint": "仅支持整数;非法值会被忽略。数值越大优先级越高。", + "websockets_label": "WebSockets", + "websockets_hint": "为该认证文件开启 Responses API 的 websocket 传输。", "excluded_models_label": "排除模型(excluded_models)", "excluded_models_placeholder": "用逗号或换行分隔,例如: model-a, gpt-5-*, *-preview", "excluded_models_hint": "保存为数组;会自动 trim、小写、去重并排序。", @@ -919,17 +953,17 @@ "note_display": "备注", "headers_label": "自定义请求头(headers)", "headers_placeholder": "{\n \"Header-Name\": \"value\"\n}", - "headers_hint": "以 JSON 对象格式输入自定义 HTTP 请求头,例如:{\"X-My-Header\": \"value\"}", + "headers_hint": "按 JSON 格式填写需要额外发送的请求头,例如:{\"X-My-Header\": \"value\"}", "headers_invalid_json": "自定义请求头必须是有效的 JSON。", "headers_invalid_object": "自定义请求头必须是 JSON 对象。", "headers_invalid_value": "每个自定义请求头的值都必须是字符串。", - "prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。", - "prefix_proxy_html_challenge": "下载到的是 HTML 验证页面,不是认证 JSON 对象。请重新认证或替换该认证文件后再编辑字段。", + "prefix_proxy_invalid_json": "该登录文件不是可编辑的 JSON 内容。", + "prefix_proxy_html_challenge": "下载到的是验证页面,不是登录文件内容。请重新登录或替换文件后再编辑。", "prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"", "quota_refresh_success": "已刷新 \"{{name}}\" 的额度", "quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}", "quota_cooldown_badge": "冷却至 {{recoverAt}}", - "quota_cooldown_badge_title": "此认证文件处于 CPAMP 管理的额度冷却中,将被自动恢复。它不是 CPA 的原生禁用状态。Owner:{{owner}}。预计恢复:{{recoverAt}}。" + "quota_cooldown_badge_title": "此账号因额度用完被 CPAMP 临时停用,预计 {{recoverAt}} 自动恢复;这不是你手动停用的状态。来源:{{owner}}。" }, "antigravity_quota": { "title": "Antigravity 额度", @@ -940,7 +974,22 @@ "load_failed": "额度获取失败:{{message}}", "missing_auth_index": "认证文件缺少 auth_index", "empty_models": "暂无额度数据", + "plan_label": "套餐", + "group_gemini_models": "Gemini 模型", + "group_claude_gpt_models": "Claude 和 GPT 模型", + "group_models_description": "此分组包含:{{models}}", + "weekly_limit": "周限额", + "daily_limit": "日限额", + "five_hour_limit": "5 小时限额", + "monthly_limit": "月限额", + "quota_available": "额度可用", + "remaining_percent": "剩余 {{percent}}%", + "refreshes_in": "{{duration}} 后刷新", "refresh_available": "可刷新", + "duration_day_hour": "{{days}} 天 {{hours}} 小时", + "duration_hour_minute": "{{hours}} 小时 {{minutes}} 分钟", + "duration_minute": "{{minutes}} 分钟", + "duration_less_than_minute": "小于 1 分钟", "refresh_button": "刷新额度", "fetch_all": "获取全部" }, @@ -1012,6 +1061,7 @@ "reset_credits_unknown": "未记录", "reset_credits_earliest_expiry": "最早过期 {{time}}", "reset_credits_expiry_label": "主动重置过期时间", + "reset_credits_invalid_payload": "主动重置次数接口返回格式无效", "reset_credit_expiry_item": "第 {{index}} 次", "active_limit_label": "生效限额", "credits_label": "Credits", @@ -1080,9 +1130,12 @@ "refresh_button": "刷新额度", "fetch_all": "获取全部", "monthly_limit": "月度账单限额", + "monthly_credits": "月度额度", "plan_label": "套餐", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", + "pay_as_you_go_label": "按量付费", + "pay_as_you_go_disabled": "未启用", "on_demand_cap": "按需上限", "usage_amount": "剩余 {{remaining}} / {{limit}}", "reset_unknown": "重置时间未知" @@ -1128,6 +1181,7 @@ "save_success": "模型禁用已更新", "save_failed": "更新模型禁用失败", "delete": "删除提供商", + "delete_title": "删除禁用项", "delete_confirm": "确定要删除 {{provider}} 的模型禁用吗?", "delete_success": "已删除该提供商的模型禁用", "delete_failed": "删除模型禁用失败", @@ -1164,6 +1218,7 @@ "save_success": "模型别名已更新", "save_failed": "更新模型别名失败", "delete": "删除提供商", + "delete_title": "删除映射", "delete_confirm": "确定要删除 {{provider}} 的模型别名吗?", "delete_link_title": "取消链接", "delete_link_confirm": "确定取消 {{sourceModel}}({{provider}})到别名 {{alias}} 的映射?", @@ -1277,6 +1332,17 @@ "xai_callback_hint": "Grok 有时只在页面显示 code。可直接粘贴 code,系统会自动拼接为 http://127.0.0.1:56121/callback?... 后提交。", "xai_callback_required": "请先粘贴 Grok 页面显示的 code 或完整回调 URL。", "xai_callback_state_missing": "缺少本次 xAI 登录的 state,请重新开始 xAI 登录后再提交 code。", + "qwen_oauth_title": "Qwen OAuth", + "qwen_oauth_button": "开始 Qwen 登录", + "qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。", + "qwen_oauth_url_label": "授权链接:", + "qwen_open_link": "打开链接", + "qwen_copy_link": "复制链接", + "qwen_oauth_status_waiting": "等待认证中...", + "qwen_oauth_status_success": "认证成功!", + "qwen_oauth_status_error": "认证失败:", + "qwen_oauth_start_error": "启动 Qwen OAuth 失败:", + "qwen_oauth_polling_error": "检查认证状态失败:", "oauth_callback_label": "回调 URL", "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", "oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。", @@ -1301,12 +1367,29 @@ "iflow_oauth_status_error": "认证失败:", "iflow_oauth_start_error": "启动 iFlow OAuth 失败:", "iflow_oauth_polling_error": "检查认证状态失败:", + "iflow_cookie_title": "iFlow Cookie 登录", + "iflow_cookie_label": "Cookie 内容:", + "iflow_cookie_placeholder": "填入 BXAuth 值,以 BXAuth= 开头", + "iflow_cookie_hint": "直接提交 Cookie 完成登录(无需打开授权链接),服务端会自动保存凭证。", + "iflow_cookie_key_hint": "提示:需在平台上先创建 Key。", + "iflow_cookie_button": "提交 Cookie 登录", + "iflow_cookie_status_success": "Cookie 登录成功,凭证已保存。", + "iflow_cookie_status_error": "Cookie 登录失败:", + "iflow_cookie_status_duplicate": "配置文件重复:", + "iflow_cookie_start_error": "提交 Cookie 登录失败:", + "iflow_cookie_config_duplicate": "检测到配置文件已存在(重复),如需重新保存请先删除原文件后重试。", + "iflow_cookie_required": "请先填写 Cookie 内容", + "iflow_cookie_result_title": "Cookie 登录结果", + "iflow_cookie_result_email": "账号", + "iflow_cookie_result_expired": "到期时间", + "iflow_cookie_result_path": "保存路径", + "iflow_cookie_result_type": "类型", "remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问" }, "model_prices": { "eyebrow": "模型价格", "title": "模型价格管理", - "subtitle": "集中维护 Manager Server 中的模型单价,自动同步高置信价格,并手动确认相似候选。", + "subtitle": "集中维护 Manager Server 中的模型单价,自动同步可靠匹配,并手动确认相似价格。", "back_to_monitoring": "返回请求监控", "summary": "模型价格摘要", "summary_total": "模型总数", @@ -1325,7 +1408,7 @@ "usage_service_required": "需要 Manager Server", "sync_model_count": "{{count}} 个模型参与同步", "manual_title": "手动价格", - "manual_hint": "保存时使用本地模型名作为 key;手动保存标记为 manual,确认候选时会保留实际价格来源和来源模型 ID。", + "manual_hint": "保存时使用本地模型名作为 key;手动保存会单独标记,确认相似价格时会保留实际价格来源和来源模型 ID。", "search_placeholder": "搜索模型或来源", "filter_all": "全部", "filter_missing": "缺价格", @@ -1333,11 +1416,11 @@ "filter_saved": "已保存", "calls": "调用", "source": "来源", - "needs_confirmation": "需要确认候选", + "needs_confirmation": "需要确认价格", "no_price": "未设置价格", - "candidate_select": "选择候选价格", + "candidate_select": "选择价格", "confirm_candidate": "确认", - "candidate_confirmed": "候选价格已确认", + "candidate_confirmed": "价格已确认", "add_manual": "手动添加", "unmatched_title": "未匹配模型", "empty": "没有符合条件的模型价格" @@ -1355,6 +1438,7 @@ "rate_30m": "近30分钟速率", "model_name": "模型名称", "model_price_settings": "模型价格设置", + "model_price_settings_short": "价格", "saved_prices": "已保存的价格", "requests_trend": "请求趋势", "tokens_trend": "Token 使用趋势", @@ -1368,6 +1452,7 @@ "range_7d": "最近7天", "filter_all": "全部", "clear_filters": "清空筛选", + "clear_filters_short": "清空", "refresh": "刷新", "export": "导出数据", "import": "导入数据", @@ -1443,7 +1528,7 @@ "time": "延迟", "avg_time": "平均延迟", "total_time": "总延迟", - "latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。", + "latency_unit_hint": "耗时取自服务返回的 {{field}},按 {{unit}} 解释后再格式化显示。", "duration_unit_d": "天", "duration_unit_h": "时", "duration_unit_m": "分", @@ -1477,7 +1562,7 @@ "monitoring": { "eyebrow": "Runtime Overview", "title": "请求监控", - "subtitle": "围绕模型层调用、上游渠道、认证凭证和估算花费建立统一视图。这里默认以模型调用为统计口径,同时补充近似任务桶,避免把 usage.total_requests 误读成业务请求数。", + "subtitle": "这里统计模型请求、账号、渠道和预估花费。模型请求不等同于业务请求;任务数为系统估算,仅供参考。", "connection_status": "连接状态", "usage_switch": "使用统计开关", "logging_switch": "文件日志开关", @@ -1495,8 +1580,9 @@ "custom_range_end": "结束时间", "custom_range_required": "请选择完整的开始和结束时间。", "custom_range_invalid": "结束时间不能早于开始时间。", - "search_placeholder": "搜索账号、标签、认证索引、模型、渠道、来源、凭证、API Key、接口路径", + "search_placeholder": "搜索账号、标签、模型、渠道、来源、API Key 或接口路径", "auto_refresh": "自动刷新", + "auto_refresh_short": "自动", "auto_refresh_off": "关闭", "auto_refresh_5s": "5 秒", "auto_refresh_10s": "10 秒", @@ -1511,17 +1597,26 @@ "filter_status": "状态", "filter_header_trace": "Header Trace", "filter_all_accounts": "全部账号", + "filter_all_accounts_short": "全部账号", "filter_all_providers": "全部提供方", + "filter_all_providers_short": "全部提供方", "filter_all_models": "全部模型", + "filter_all_models_short": "全部模型", "filter_all_channels": "全部渠道", + "filter_all_channels_short": "全部渠道", "filter_all_api_keys": "全部调用方密钥", + "filter_all_api_keys_short": "全部调用方密钥", "filter_all_statuses": "全部状态", + "filter_all_statuses_short": "全部状态", "filter_all_header_traces": "全部 Header Trace", "filter_all_header_traces_short": "Trace", "filter_status_success": "仅成功", + "filter_status_success_short": "成功", "filter_status_failed": "仅失败", + "filter_status_failed_short": "失败", "active_filters_hint": "已启用 {{count}} 个筛选条件,当前命中 {{rows}} 条调用。", "clear_filters": "清空筛选", + "clear_filters_short": "清空", "open_usage": "打开使用统计", "open_logs": "打开日志查看", "open_system": "打开中心信息", @@ -1538,30 +1633,38 @@ "top_model": "热点模型", "top_channel": "热点渠道", "usage_disabled_title": "使用统计尚未启用", - "usage_disabled_body": "请求监控依赖 Manager Server 或兼容用量数据。请先开启“使用统计”并确认 Manager Server 正在运行,否则这里只能显示连接与配置状态。", + "usage_disabled_body": "请求监控需要 Manager Server 提供用量数据。请先开启“使用统计”,并确认 Manager Server 正在运行。", "request_monitoring_disabled_title": "请求监控未启用", "request_monitoring_unavailable_title": "请求监控服务不可用", - "request_monitoring_disabled_body": "CPA Manager Plus 当前没有启动请求采集器。需要使用监控页时,请到 CPA Manager Plus 配置中启用请求监控。", - "request_monitoring_service_unavailable_body": "当前 Manager Server 无法提供请求监控。请检查初始化、采集器状态和已绑定 CPA 连接。", + "request_monitoring_disabled_body": "CPA Manager Plus 当前没有读取请求记录。需要使用监控页时,请到 CPA Manager Plus 配置中启用请求监控。", + "request_monitoring_service_unavailable_body": "当前 Manager Server 无法提供请求监控。请检查初始化状态、请求记录读取状态和已绑定的 CPA 连接。", "request_monitoring_not_configured_body": "请求监控只在 Manager Server 托管面板完成初始化后可用。CPA 控制面板方案不支持用量统计类功能。", - "empty_diagnostics_body": "如果 CPA 已产生请求但这里仍为空,请确认已启用用量发布,检查 Manager Server /status,并确保同一个 CPA 实例只有一个 Manager Server 消费用量队列。", + "empty_diagnostics_body": "如果 CPA 已有请求但这里没有数据,请确认请求监控已开启、Manager Server 运行正常,并且同一个 CPA 只由一个 Manager Server 读取用量。", "empty_diagnostics_link": "为什么没有数据?", "zero_token_notice_title": "检测到零 Token 模型调用", "zero_token_notice_body": "当前范围内有 {{count}} 次零 Token 模型调用({{models}})。这类模型通常不是纯 Token 计费,当前花费只能做部分估算。", "model_calls": "模型调用数", + "model_calls_short": "调用", "total_calls": "总调用", + "total_calls_short": "调用", "success_rate": "成功率", + "success_rate_short": "成功", "success_calls": "成功总数", + "success_calls_short": "成功", "failure_calls": "失败总数", + "failure_calls_short": "失败", "total_tokens": "总 Tokens", + "total_tokens_short": "Token", "avg_rpm": "平均 RPM", "avg_tpm": "平均 TPM", "avg_daily_requests": "日均调用", "approx_tasks": "近似任务数", "call_success_rate": "调用成功率", + "call_success_rate_short": "成功", "task_success_rate": "近似任务成功率", "avg_latency": "平均耗时", "estimated_cost": "预估花费", + "estimated_cost_short": "花费", "estimated_cost_hint": "已配置单价模型", "estimated_cost_missing": "尚未配置模型单价,花费暂不可用。", "zero_token_model_calls": "零 Token 模型调用", @@ -1569,9 +1672,13 @@ "token_signal_title": "Token 信号", "token_signal_desc": "把输入、输出、推理与缓存复用拆开看,判断真实消耗结构与缓存效率。", "input_tokens": "输入 Tokens", + "input_tokens_short": "输入", "output_tokens": "输出 Tokens", + "output_tokens_short": "输出", "reasoning_tokens": "推理 Tokens", + "reasoning_tokens_short": "推理", "cached_tokens": "缓存 Tokens", + "cached_tokens_short": "缓存", "cache_read_tokens": "缓存读取 Tokens", "cache_creation_tokens": "缓存创建 Tokens", "cache_read_tokens_short": "读取", @@ -1597,7 +1704,7 @@ "cache_reuse_rate": "命中率", "cache_hit_rate": "命中率", "reasoning_share": "推理占比", - "hot_endpoint": "热点接口", + "hot_endpoint": "高频请求路径", "accounts_suffix": "账号", "groups_suffix": "监控组", "success_suffix": "成功", @@ -1605,10 +1712,11 @@ "approx_task_suffix": "近似任务", "recent_window_suffix": "最近 30 分钟窗口", "failure_suffix": "失败", - "metric_scope_note": "首页 KPI 默认采用筛选范围内的模型调用口径,任务指标按 时间戳 + 来源 + 凭证 进行近似聚合。", + "metric_scope_note": "顶部指标统计当前筛选范围内的模型请求。任务数按请求时间、来源和账号近似合并,仅供参考。", "last_sync": "最近刷新", + "last_sync_short": "刷新", "requests_trend_title": "调用与 Token 趋势", - "requests_trend_desc": "按 {{granularity}} 汇总请求量与 Token 体量,用于判断波峰、负载节奏和成本变化。", + "requests_trend_desc": "按 {{granularity}} 汇总请求量和 Token 用量,帮助判断高峰、负载节奏和成本变化。", "granularity_hour": "小时", "granularity_day": "天", "model_mix_title": "模型调用结构", @@ -1618,7 +1726,7 @@ "hourly_tokens_title": "小时 Token 分布", "hourly_tokens_desc": "查看 Token 消耗在哪些小时最集中,辅助容量和费用判断。", "channel_table_title": "渠道执行面板", - "channel_table_desc": "将使用统计与 openai-compatibility、auth-files 合并后,查看各渠道的吞吐、成功率和延迟。", + "channel_table_desc": "按渠道汇总请求量、成功率和延迟,方便判断哪些渠道更稳定。", "model_table_title": "模型性能排行", "model_table_desc": "面向模型维度观察请求量、成功率、Token 与时延。", "failure_table_title": "失败来源分析", @@ -1632,8 +1740,11 @@ "clear_account_focus": "取消账号聚焦", "data_tabs_aria_label": "数据视图切换", "data_tab_accounts": "账号汇总", + "data_tab_accounts_short": "账号", "data_tab_api_keys": "调用方密钥汇总", + "data_tab_api_keys_short": "API Key", "data_tab_realtime": "实时监控", + "data_tab_realtime_short": "实时", "data_tab_accounts_badge_title": "账号数:{{count}}", "data_tab_api_keys_badge_title": "调用方密钥数:{{count}}", "data_tab_realtime_badge_title": "{{failed}} 失败 / 共 {{total}} 次请求", @@ -1642,6 +1753,7 @@ "api_key_summary_desc": "按调用方密钥维度汇总当前筛选范围内的调用次数、成功失败、总 Token 和预计花费,可展开查看 Token 结构与模型使用明细。", "api_key_summary_description": "这里的密钥指调用方 / Client API Key,优先显示别名;展开后可查看输入、输出、缓存 Token,以及该调用方密钥下各模型的调用与花费。", "api_key_summary_col_key": "调用方密钥", + "api_key_summary_col_key_short": "Client Key", "api_key_summary_keys_count": "共 {{count}} 个调用方密钥", "api_key_available": "可用", "api_key_unknown_label": "未知调用方密钥", @@ -1655,9 +1767,11 @@ "account_overview_show_full_accounts_hint": "显示完整账号标识", "account_overview_show_masked_accounts_hint": "显示脱敏账号标识", "account_overview_col_account": "账号 / Provider", + "account_overview_col_account_short": "账号", "account_overview_col_success": "成功", "account_overview_col_failure": "失败", "account_overview_col_cost": "预计花费", + "account_overview_col_cost_short": "花费", "account_overview_row_menu_label": "更多操作", "account_overview_row_menu_refresh_quota": "刷新额度", "account_overview_sort_label": "排序", @@ -1705,7 +1819,7 @@ "codex_inspection_mode_server": "服务端巡检", "codex_inspection_mode_current": "当前方式:{{mode}}", "codex_inspection_mode_local_desc": "在当前浏览器连接的本机流程中执行巡检,依赖当前连接和浏览器会话,适合快速检查当前设备上的账号状态。", - "codex_inspection_mode_server_desc": "提交到 Manager Server 执行巡检,支持定时、后台运行和历史记录,适合批量和统一审计。", + "codex_inspection_mode_server_desc": "交给 Manager Server 检查账号,支持定时、后台运行和历史记录,适合批量检查。", "codex_inspection_execution_mode": "执行方式", "codex_inspection_pause": "暂停", "codex_inspection_resume": "继续巡检", @@ -1791,7 +1905,7 @@ "codex_inspection_config_overview_title": "当前巡检配置", "codex_inspection_config_overview_edit": "编辑配置", "codex_inspection_settings_title": "Codex 巡检配置", - "codex_inspection_settings_desc": "配置 Codex 账号巡检的目标类型、并发、超时和阈值,保存后会直接用于当前浏览器中的后续巡检。", + "codex_inspection_settings_desc": "设置本机巡检要检查哪些账号、同时检查多少个、超时时间和额度阈值。保存后会用于当前浏览器后续巡检。", "codex_inspection_settings_saved": "巡检配置已保存", "codex_inspection_settings_reset": "已恢复默认巡检配置", "codex_inspection_settings_reset_button": "恢复默认", @@ -1872,7 +1986,7 @@ "server_codex_inspection_run_confirm_title": "立即执行服务端巡检", "server_codex_inspection_run_confirm_body": "服务端会按当前配置巡检账号;如启用了自动处理策略,可能会直接禁用或删除账号。确认继续吗?", "server_codex_inspection_config_title": "服务端巡检配置", - "server_codex_inspection_config_desc": "配置后端定时任务的触发方式、探测并发、超时和自动处理策略。", + "server_codex_inspection_config_desc": "配置定时检查的触发方式、检查并发、超时和自动处理策略。", "server_codex_inspection_config_saved": "服务端巡检配置已保存", "server_codex_inspection_config_invalid": "请检查巡检配置,时间点需为 HH:mm,数字项需在有效范围内。", "server_codex_inspection_open_config": "配置", @@ -1984,9 +2098,10 @@ "model_pricing_desc": "在监控页直接维护模型单价,账号、模型和实时日志花费会立即联动更新。", "price_storage_hint": "单价设置会优先写入 SQLite;旧版浏览器本地价格会自动迁移。", "workbench_title": "调用工作台", - "workbench_desc": "按近似任务桶聚合最近调用,便于快速查看一次请求涉及的模型、结果、花费和接口路径。", + "workbench_desc": "把最近的相关调用合并展示,方便查看一次任务涉及的模型、结果、花费和接口路径。", "workbench_rows": "任务桶数", "recent_failures": "最近失败调用", + "recent_failures_short": "失败", "filtering_hint": "当前筛选 “{{query}}”,命中 {{count}} 条模型调用。", "all_data_hint": "当前范围共 {{count}} 条模型调用。", "other_models": "其他模型", @@ -1996,12 +2111,16 @@ "auth_indexes": "认证索引", "column_type": "类型", "column_source_api_key": "来源 / API Key", + "column_source_api_key_short": "API Key", "column_model": "模型", "column_status": "状态", "request_type": "请求类型", "request_status": "请求状态", + "request_status_short": "状态", "recent_status": "最近状态", + "recent_status_short": "状态", "latest_request_time": "最近请求时间", + "latest_request_time_short": "最新", "column_time": "时间", "focus_account": "聚焦账号", "clear_focus": "取消聚焦", @@ -2015,6 +2134,7 @@ "column_auths": "凭证数", "column_models": "模型数", "column_success_rate": "成功率", + "column_success_rate_short": "成功", "column_tokens": "Tokens", "column_cost": "花费", "column_latency": "延迟", @@ -2026,9 +2146,11 @@ "column_failure_rate": "失败率", "column_last_seen": "最近出现", "column_result": "结果", - "column_endpoints": "接口路径", + "column_endpoints": "请求路径", "this_call_usage": "本次用量", + "this_call_usage_short": "用量", "this_call_cost": "本次花费", + "this_call_cost_short": "花费", "state_disabled": "已禁用", "state_active": "活跃", "state_inactive": "未生效", @@ -2041,16 +2163,20 @@ "source": "来源", "calls": "调用数", "log_rows": "日志行", + "log_rows_short": "日志", "realtime_api_key_label": "API Key", "realtime_api_key_hash": "API Key 哈希", "realtime_api_key_masked": "脱敏密钥", "pagination_prev": "上一页", + "pagination_prev_short": "上一页", "pagination_next": "下一页", + "pagination_next_short": "下一页", "pagination_info": "第 {{current}} / {{total}} 页 · 显示 {{start}}-{{end}} / {{count}}", "pagination_jump_prefix": "前往", "pagination_jump_label": "跳转到页码", "pagination_jump_suffix": "页", "page_size_label": "每页", + "page_size_label_short": "每页", "page_size_option": "{{count}} 条/页", "load_more_events": "加载更多事件", "no_more_events": "没有更多事件", @@ -2064,7 +2190,7 @@ "download_button": "下载日志", "error_log_button": "选择错误日志", "error_logs_modal_title": "错误请求日志", - "error_logs_description": "这里展示 CPA 侧记录的请求错误日志,通常用于排查 4xx/5xx、认证失败、限额、上游异常等问题。请选择文件下载后查看具体原因。", + "error_logs_description": "这里展示 CPA 侧记录的请求错误日志,通常用于排查 4xx/5xx、认证失败、限额、外部服务异常等问题。请选择文件下载后查看具体原因。", "error_logs_request_log_enabled": "当前已开启请求日志,按 CPA 接口约定错误请求日志列表会始终为空。如需查看独立错误日志文件,请关闭请求日志后刷新。", "error_logs_empty": "暂无错误请求日志文件", "error_logs_load_error": "加载错误日志列表失败", @@ -2080,6 +2206,7 @@ "log_content": "日志内容", "loading": "正在加载日志...", "load_error": "加载日志失败", + "clear_confirm_title": "清空日志", "clear_confirm": "确定要清空所有日志吗?此操作不可恢复!", "clear_success": "日志已清空", "download_success": "日志下载成功", @@ -2168,12 +2295,12 @@ "manager": { "title": "CPA Manager Plus 配置", "description": "管理 CPA Manager Plus 自身配置,和 CPA 配置分开保存。", - "boundary_hint": "这里保存 CPAM 自身运行配置;CPA 用量统计和队列保留时间仍属于 CPA 配置。", + "boundary_hint": "这里保存 CPAMP 自身配置;CPA 用量统计和数据保留时间仍属于 CPA 配置。", "load_failed": "CPA Manager Plus 配置加载失败", "save_success": "CPA Manager Plus 配置已保存", "number_invalid": "{{label}} 必须是正整数", "service_base_required": "无法解析 Manager Server 地址,请从 Manager Server 托管面板打开。", - "poll_interval_retention_error": "采集轮询间隔必须小于等于 CPA 用量队列保留时间", + "poll_interval_retention_error": "读取间隔不能超过 CPA 保存用量数据的时间", "runtime_title": "运行环境", "runtime_embedded": "同源内置 Manager Server", "runtime_external": "CPA 控制面板模式", @@ -2206,9 +2333,9 @@ "cpa_management_key_hide": "隐藏密钥", "cpa_management_key_clear": "清空输入", "cpa_management_key_saving": "正在用新密钥访问 CPA 验证…", - "cpa_connection_risk_inline": "修改 CPA 链接地址或管理密钥会影响代理、用量采集和监控数据,请只在确认目标 CPA 正确时保存。", + "cpa_connection_risk_inline": "修改 CPA 链接地址或管理密钥会影响代理和监控数据,请确认目标 CPA 正确后再保存。", "cpa_connection_risk_title": "确认修改 CPA 连接", - "cpa_connection_risk_message": "你正在修改 Manager Server 的 CPA 连接。当前 CPA:{{currentBase}};保存后 CPA:{{nextBase}}。这会影响代理、用量采集和监控数据,请确认目标 CPA 地址和 Management Key 完全正确后再继续。", + "cpa_connection_risk_message": "你正在修改 Manager Server 的 CPA 连接。当前 CPA:{{currentBase}};保存后 CPA:{{nextBase}}。这会影响代理和监控数据,请确认目标 CPA 地址和 Management Key 完全正确后再继续。", "cpa_connection_risk_confirm": "确认并承担风险", "current_cpa_base": "当前 CPA 地址", "bound_cpa_base": "Manager Server 已绑定 CPA", @@ -2226,17 +2353,17 @@ "status_binding_required": "需要重置 Manager Server 初始化", "status_binding_required_short": "需重置", "request_monitoring_title": "请求监控", - "request_monitoring_hint": "开启后会启用 CPA 用量统计并启动采集器;关闭后会停止 CPAM 采集器,但不会关闭 CPA 用量统计或清空 CPA 队列。", + "request_monitoring_hint": "开启后会启用 CPA 用量统计,并开始读取请求记录;关闭后会停止读取,但不会关闭 CPA 用量统计,也不会清空 CPA 已保留的数据。", "request_monitoring_enabled": "启用请求监控", "request_monitoring_dependency": "请先完成 Manager Server 初始化并保留已绑定 CPA 连接,再启用请求监控。", - "request_monitoring_queue_note": "如果 CPA 用量统计仍开启,在 CPA 队列保留时间内再次启用请求监控,可能会采集到关闭 CPAM 采集器期间保留的数据。", + "request_monitoring_queue_note": "如果 CPA 用量统计仍开启,短时间内再次启用请求监控,可能会读取到关闭期间 CPA 保留的数据。", "collector_mode": "采集模式", "collector_mode_auto": "自动", "collector_mode_http": "HTTP", "collector_mode_resp": "RESP", "collector_mode_subscribe": "RESP Pub/Sub", - "poll_interval_ms": "采集轮询间隔 (毫秒)", - "poll_interval_hint": "必须小于等于 CPA 用量队列保留时间,当前按 {{seconds}} 秒校验。", + "poll_interval_ms": "读取间隔 (毫秒)", + "poll_interval_hint": "读取间隔不能超过 CPA 保存用量数据的时间,当前按 {{seconds}} 秒校验。", "batch_size": "每批最多拉取条数", "query_limit": "用量查询返回上限", "config_source": "配置来源", @@ -2244,7 +2371,7 @@ "config_source_db": "SQLite", "config_source_none": "未保存", "cpa_usage_enabled": "CPA 用量统计", - "cpa_retention": "CPA 队列保留时间", + "cpa_retention": "CPA 数据保留时间", "cpa_retention_value": "{{seconds}} 秒" }, "visual": { @@ -2293,7 +2420,7 @@ "commercial_mode": "商业模式", "commercial_mode_desc": "禁用高开销中间件以支持高并发", "usage_statistics_enabled": "启用用量统计", - "usage_statistics_enabled_desc": "允许 CPA 发布请求用量事件,供 Manager Server 持续消费", + "usage_statistics_enabled_desc": "允许 CPA 记录请求用量,供 Manager Server 持续读取", "logging_to_file": "写入日志文件", "logging_to_file_desc": "将日志保存到文件", "plugins_enabled": "启用插件系统", @@ -2303,8 +2430,8 @@ "plugin_store_sources": "插件商店来源", "plugin_store_sources_desc": "每行一个插件商店 manifest URL,会写入 CPA 的 plugins.store-sources 数组。", "plugin_store_auth": "插件商店认证规则", - "plugin_store_auth_desc": "为私有 registry、GitHub Release metadata 或下载地址配置认证头。", - "plugin_store_auth_hint": "这里只填写环境变量名,不填写真实 token;后端会从运行环境读取值。", + "plugin_store_auth_desc": "为私有插件来源、GitHub Release 信息或下载地址配置访问头。", + "plugin_store_auth_hint": "这里只填写环境变量名,不填写真实 token;服务会从运行环境读取值。", "store_auth_empty": "暂未配置认证规则。公开插件源不需要配置。", "store_auth_rule": "认证规则", "store_auth_add": "添加认证规则", @@ -2312,7 +2439,7 @@ "store_auth_type": "认证类型", "store_auth_apply_to": "应用到", "store_auth_apply_to_hint": "不选择时应用到所有请求类型。", - "store_auth_apply_registry": "Registry", + "store_auth_apply_registry": "插件列表", "store_auth_apply_metadata": "Metadata", "store_auth_apply_artifact": "Artifact", "store_auth_type_none": "无认证", @@ -2328,8 +2455,8 @@ "store_auth_allow_insecure": "允许 HTTP 非安全地址", "logs_max_size": "日志文件大小限制 (MB)", "error_logs_max_files": "错误日志保留文件数", - "redis_usage_queue_retention": "用量队列保留时间 (秒)", - "redis_usage_queue_retention_hint": "CPA 默认 60 秒,最大 3600 秒;留空使用默认值。请保持 Manager Server 持续运行。", + "redis_usage_queue_retention": "用量数据保留时间 (秒)", + "redis_usage_queue_retention_hint": "CPA 默认保留 60 秒,最大 3600 秒;留空使用默认值。请保持 Manager Server 持续运行。", "antigravity_signature_cache": "启用 Antigravity 签名缓存", "antigravity_signature_cache_desc": "优先使用并校验缓存的 thinking block 签名", "antigravity_signature_strict": "严格校验旁路签名", @@ -2374,8 +2501,8 @@ "session_affinity_ttl": "会话粘性 TTL", "force_model_prefix": "强制模型前缀", "force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据", - "passthrough_headers": "透传上游响应 Header", - "passthrough_headers_desc": "将过滤后的上游响应 Header 转发给下游客户端", + "passthrough_headers": "转发外部服务响应头", + "passthrough_headers_desc": "将过滤后的外部服务响应头转发给客户端", "disable_cooling": "禁用冷却调度", "disable_cooling_desc": "全局禁用认证/模型失败后的冷却窗口", "session_affinity": "会话粘性路由", @@ -2405,7 +2532,7 @@ }, "payload": { "title": "Payload 配置", - "description": "默认值、原始 JSON 规则、覆盖规则与过滤规则", + "description": "设置请求参数的默认值、覆盖值和过滤规则", "default_rules": "默认规则", "default_rules_desc": "当请求中未指定参数时,使用这些默认值", "default_raw_rules": "默认 Raw 规则", @@ -2415,7 +2542,7 @@ "override_raw_rules": "覆盖 Raw 规则", "override_raw_rules_desc": "始终按原始 JSON 片段覆盖参数值,适合 response_format、schema 等复杂字段", "filter_rules": "过滤规则", - "filter_rules_desc": "通过 JSON Path 预过滤上游请求体,自动剔除不合规/冗余参数(Request Sanitization)" + "filter_rules_desc": "按 JSON 路径在转发前移除不需要或不支持的参数" } }, "api_keys": { @@ -2527,8 +2654,8 @@ "cpa_connection_required_for_monitoring": "启用请求监控时必须填写 CPA 地址和管理密钥。", "management_api_validation_failed": "CPA Management API 校验失败,请检查 CPA 地址和管理密钥。", "management_api_config_failed": "读取 CPA 配置失败,请检查 Management API。", - "cpa_usage_retention_invalid": "CPA 用量队列保留时间必须大于 0。", - "poll_interval_exceeds_retention": "采集轮询间隔必须小于等于 CPA 用量队列保留时间。", + "cpa_usage_retention_invalid": "CPA 用量数据保留时间必须大于 0。", + "poll_interval_exceeds_retention": "读取间隔不能超过 CPA 保存用量数据的时间。", "invalid_time_zone": "时区保存失败,请检查时区名称或确认运行环境已安装 tzdata。", "enable_cpa_usage_statistics_failed": "启用 CPA 用量统计失败。", "setup_env_managed": "初始化配置由环境变量管理。", @@ -2565,7 +2692,7 @@ "recent_empty": "暂无最近访问。", "updated_at": "更新时间 {{time}}", "overview_updated_at": "更新时间", - "overview_selected_bucket": "当前时间桶", + "overview_selected_bucket": "当前时间段", "overview_no_bucket_selected": "未选中", "overview_active_filters": "已选 {{count}} 项筛选", "view_daily_board": "每日看板", @@ -2608,11 +2735,11 @@ "heatmap_scale_byHour": "按小时归一化", "heatmap_color_value": "颜色值", "token_structure_title": "Token 结构", - "token_structure_hint": "按时间桶堆叠展示输入、输出、缓存和推理 Token。", + "token_structure_hint": "按时间段展示输入、输出、缓存和推理 Token。", "entity_trend_title": "实体趋势", - "entity_trend_hint": "Top 实体曲线按整体占比近似估算,并非逐实体真实时间序列。", + "entity_trend_hint": "主要项目的趋势按整体占比估算,并非每一项的真实逐时数据。", "selected_credential_trend_title": "选中凭证趋势", - "selected_credential_trend_hint": "仅展示当前选中凭证的真实时间桶趋势,不做整体占比估算。", + "selected_credential_trend_hint": "仅展示当前选中凭证的真实时间段趋势,不做整体占比估算。", "insights_title": "用量洞察", "insights_hint": "基于当前分析切片生成可行动观察。", "insights_empty": "当前范围未发现明显风险。", @@ -2635,7 +2762,7 @@ "provider_usage_share_title": "Provider 用量占比", "provider_health_title": "Provider 健康", "quota_status_title": "配额状态", - "quota_status_hint": "上游无配额数据时,根据近期用量生成估算状态。", + "quota_status_hint": "外部服务没有返回配额数据时,根据近期用量生成估算状态。", "quota_status_normal": "正常", "quota_status_warning": "预警", "quota_status_exhausted": "已耗尽", @@ -2712,7 +2839,7 @@ "summary_cost_meta": "按模型价格估算", "summary_vs_previous": "较上周期", "trend_title": "趋势分析", - "trend_hint": "点击时间桶可查看异常分析,并跳转到请求明细。", + "trend_hint": "点击时间段可查看异常分析,并跳转到请求明细。", "trend_peak_request_bucket": "峰值请求桶", "trend_average_bucket_requests": "平均每桶请求", "trend_request_change": "请求环比", @@ -2733,19 +2860,19 @@ "anomaly_latency_spike": "平均延迟升高", "anomaly_unknown": "异常指标", "possible_causes": "可能原因", - "cause_traffic": "该时间桶内流量或批处理任务增加。", + "cause_traffic": "该时间段内流量或批处理任务增加。", "cause_batch": "Prompt、模型或客户端变更导致 Token 消耗变化。", "cause_cache": "缓存使用变化,导致未命中输入 Token 增加。", "cause_request_spike": "请求数显著上升,优先检查批处理任务、自动化脚本或突发客户端流量。", - "cause_request_drop": "请求数明显下降,可能是客户端停止调用、队列耗尽或上游限流导致。", + "cause_request_drop": "请求数明显下降,可能是客户端停止调用、数据暂时为空或外部服务限流导致。", "cause_cost_spike": "成本增幅较大,检查高价模型、长上下文或失败重试是否集中出现。", "cause_cost_drop": "成本明显下降,可能是请求减少、切换到低价模型或输出长度降低。", "cause_token_per_request_spike": "平均每请求 Token 升高,重点检查 Prompt、上下文拼接和 max_tokens 配置。", "cause_cache_hit_drop": "缓存命中比例下降,可能是 Prompt 结构变化或缓存前缀不稳定。", "cause_cache_growth": "缓存 Token 增长,说明该时间段复用缓存较多,可对比实际成本是否同步下降。", - "cause_failure_rate_spike": "失败率明显升高,重点检查上游错误、限流或认证配置。", + "cause_failure_rate_spike": "失败率明显升高,重点检查外部服务错误、限流或认证配置。", "cause_latency_spike": "平均延迟明显升高,可能是模型响应变慢、网络抖动或请求排队增加。", - "cause_no_clear_anomaly": "该时间桶与前一桶差异不大,可继续查看请求明细确认单条异常。", + "cause_no_clear_anomaly": "该时间段与前一段差异不大,可继续查看请求明细确认单条异常。", "common_views_title": "常用视图", "common_view_7d": "近 7 天总览", "common_view_cost": "成本排查", @@ -2756,10 +2883,10 @@ "overview_trend_hint": "请求、Token 与成本在同一时间范围内联动展示。", "health_trend_title": "请求健康趋势", "anomaly_points_title": "异常时间点", - "anomaly_points_hint": "后端基于请求、成本、Token、缓存、失败率和延迟变化聚合出的异常桶。", - "drilldown_preview_title": "请求下钻预览", - "drilldown_preview_hint": "选中时间桶后展示该桶内的真实请求样本。", - "drilldown_preview_empty": "点击趋势图中的时间桶查看请求样本。", + "anomaly_points_hint": "根据请求、成本、Token、缓存、失败率和延迟变化识别出的异常时段。", + "drilldown_preview_title": "请求样本预览", + "drilldown_preview_hint": "选中时间段后展示该时间段内的真实请求样本。", + "drilldown_preview_empty": "点击趋势图中的时间段查看请求样本。", "model_overview_title": "模型消耗概览", "api_key_overview_title": "客户端 Key 消耗概览", "credential_overview_title": "凭证消耗概览", @@ -2933,7 +3060,7 @@ }, "plugin_management": { "title": "插件管理", - "description": "查看已发现和已注册的插件,管理实例开关、配置字段和资源入口。", + "description": "查看已安装和可用的插件,管理启停、配置和入口。", "refresh": "刷新", "install_plugin": "安装插件", "tabs_aria_label": "插件管理视图", @@ -2941,12 +3068,12 @@ "tab_store": "插件商店", "load_failed": "加载插件失败", "config_load_failed": "读取插件配置失败", - "config_not_found": "无法读取插件配置,后端未找到该插件。", - "unsupported_backend": "当前后端未暴露插件管理 API。请使用包含插件管理接口的新后端构建,并重启服务。", + "config_not_found": "无法读取插件配置,当前服务没有找到该插件。", + "unsupported_backend": "当前服务版本不支持插件管理。请更新 CPA 或 Manager Server 后重启。", "global_status": "全局状态", "global_enabled": "已启用", "global_disabled": "已停用", - "global_disabled_hint": "当前 plugins.enabled 为 false,插件实例即使启用也不会生效。", + "global_disabled_hint": "当前已关闭插件总开关,单个插件即使启用也不会生效。", "plugins_dir": "插件目录", "installed_count": "已安装", "effective_count": "已生效", @@ -2960,18 +3087,18 @@ "meta_version": "版本", "meta_author": "作者", "meta_path": "路径", - "discovered": "发现插件", - "effective": "生效插件", + "discovered": "已发现", + "effective": "正在生效", "search_placeholder": "搜索插件 ID、名称、作者或资源...", "search_label": "搜索插件", "no_plugins": "暂无插件", - "no_plugins_desc": "未从插件目录或运行时注册表中发现插件。", + "no_plugins_desc": "当前插件目录中还没有可用插件。", "configured": "已配置", "not_configured": "未配置", "registered": "已注册", "not_registered": "未注册", "oauth": "OAuth", - "status_effective": "生效中", + "status_effective": "正在生效", "status_global_disabled": "全局停用", "status_disabled": "已停用", "status_inactive": "未生效", @@ -3003,14 +3130,14 @@ "config_title": "配置 {{name}}", "base_settings": "基础设置", "enabled": "启用", - "enabled_hint": "控制 plugins.configs 中该插件实例的 enabled 字段。", + "enabled_hint": "控制这个插件是否启用。", "priority": "优先级", - "config_fields": "配置字段", - "no_config_fields": "该插件没有声明可视化配置字段。", + "config_fields": "配置项", + "no_config_fields": "该插件没有可在界面中编辑的配置项。", "select_placeholder": "请选择", - "array_item_placeholder": "输入数组项", - "add_array_item": "添加数组项", - "remove_array_item": "删除数组项", + "array_item_placeholder": "输入一项内容", + "add_array_item": "添加一项", + "remove_array_item": "删除这一项", "toggle_success": "插件状态已更新", "toggle_failed": "插件状态更新失败", "save_success": "插件配置已保存", @@ -3019,25 +3146,25 @@ "invalid_priority": "请输入整数优先级", "invalid_number": "请输入有效数字", "invalid_integer": "请输入有效整数", - "invalid_json": "请输入有效 JSON", - "expected_array": "请输入 JSON 数组", + "invalid_json": "请输入有效的 JSON 内容", + "expected_array": "请输入 JSON 列表", "expected_object": "请输入 JSON 对象", - "invalid_enum": "请选择声明的枚举值" + "invalid_enum": "请选择列表中的可用选项" }, "plugin_store": { "title": "插件商店", - "description": "浏览插件注册表,为当前后端安装或更新插件。", + "description": "浏览可用插件,并为当前服务安装或更新。", "refresh": "刷新", "retry": "重试", "load_failed": "插件商店加载失败", "loading_title": "正在加载插件商店", "loading_desc": "正在读取插件来源与安装状态", - "unsupported_backend": "当前后端未暴露插件商店 API。请使用包含插件商店接口的新后端构建,并重启服务。", - "registry_failed": "插件注册表请求失败", + "unsupported_backend": "当前服务版本不支持插件商店。请更新 CPA 或 Manager Server 后重启。", + "registry_failed": "插件来源请求失败", "global_status": "全局状态", "global_enabled": "已启用", "global_disabled": "已停用", - "global_disabled_hint": "当前 plugins.enabled 为 false,已安装的插件不会生效。", + "global_disabled_hint": "当前已关闭插件总开关,已安装插件不会生效。", "plugins_dir": "插件目录", "stat_available": "可用插件", "sources": "来源", @@ -3049,7 +3176,7 @@ "meta_platforms": "平台", "source_name": "来源:{{source}}", "cli_proxy_api_source": "CLIProxyAPI 源", - "source_errors_title": "来源警告", + "source_errors_title": "插件来源警告", "unknown_source": "未知来源", "source_required": "该插件存在于多个商店来源中,请使用带来源信息的插件卡片重试。", "search_placeholder": "搜索插件 ID、名称、作者或标签...", @@ -3068,20 +3195,20 @@ "platforms": "{{platforms}}", "auth_required": "需要认证", "auth_configured": "认证已配置", - "auth_required_hint": "该插件源或下载地址需要先配置 plugins.store-auth。", + "auth_required_hint": "这个插件来源需要先配置访问凭据后才能安装。", "install": "安装", "update": "更新", "manage": "管理", "reinstall_plugin": "重新安装", "delete_plugin": "删除", "install_confirm_title": "安装插件", - "install_confirm_message": "将从插件注册表下载 {{target}} 并安装到本地插件目录,是否继续?", - "install_security_warning_title": "第三方插件以后端完整权限运行", - "install_security_warning_message": "插件会在代理服务内部执行代码,可读取你的凭据与流量。请仅安装你信任的插件,尤其要警惕任何并非由官方组织 router-for-me 发布的插件。", + "install_confirm_message": "将下载 {{target}} 并安装到本地插件目录,是否继续?", + "install_security_warning_title": "第三方插件可以访问服务内的数据", + "install_security_warning_message": "插件会在代理服务内部运行,可能读取你的凭据、请求和响应。请只安装你信任的插件,尤其要谨慎安装非官方 router-for-me 组织发布的插件。", "update_confirm_title": "更新插件", "update_confirm_message": "将下载并安装 {{target}} 的最新版本,是否继续?", "install_version_label": "指定版本(可选)", - "install_version_hint": "留空时使用后端默认/最新版本;当前列表版本为 {{version}}。", + "install_version_hint": "留空时使用服务默认或最新版本;当前列表版本为 {{version}}。", "install_version_latest": "最新版本", "reinstall_confirm_title": "重新安装插件", "reinstall_confirm_message": "将先删除 {{target}},再从插件商店重新安装。若删除成功但安装失败,插件会保持已删除状态。是否继续?", @@ -3101,15 +3228,15 @@ "restart_required_banner": "以下插件需重启服务后才能应用新版本:{{plugins}}", "open_repository": "打开仓库", "open_homepage": "打开主页", - "no_plugins": "注册表为空", - "no_plugins_desc": "插件注册表未返回任何插件。", + "no_plugins": "暂无可用插件", + "no_plugins_desc": "当前插件来源没有返回任何插件。", "no_matches": "没有匹配的插件", "no_matches_desc": "当前搜索或筛选条件下没有插件。", "clear_filters": "清除筛选", "gate_title": "安装 {{name}}", "gate_title_update": "更新 {{name}}", - "gate_warning": "第三方插件会以后端完整权限运行。", - "gate_effect_runs_code": "该插件会在代理后端内运行第三方代码,并可能访问凭据、请求和响应。", + "gate_warning": "第三方插件可以访问服务内的数据。", + "gate_effect_runs_code": "该插件会在代理服务内部运行第三方代码,并可能访问凭据、请求和响应。", "gate_effect_no_review": "它并非由官方组织 router-for-me 发布或审核,后续版本的行为可能变化。", "gate_effect_restart": "安装会修改本地插件目录,并可能需要重启服务。", "gate_untrusted_alert": "仅在你完全信任该插件的作者、仓库和来源时继续。", @@ -3126,12 +3253,12 @@ "title": "插件页面", "page_count": "{{count}} 个页面", "load_failed": "加载插件页面失败", - "unsupported_backend": "当前后端未暴露插件页面 API。请使用包含插件管理接口的新后端构建,并重启服务。", + "unsupported_backend": "当前服务版本不支持插件页面。请更新 CPA 或 Manager Server 后重启。", "unavailable": "插件页面不可用", "not_found": "未找到插件页面", - "not_found_desc": "该插件页面未在当前后端插件列表中声明。", + "not_found_desc": "当前服务没有声明这个插件页面。", "empty_src": "插件页面地址为空", - "empty_src_desc": "该插件菜单未提供可嵌入的页面地址。" + "empty_src_desc": "该插件菜单没有提供可打开的页面地址。" }, "system_info": { "title": "系统信息", @@ -3145,13 +3272,13 @@ "not_loaded": "未加载", "seconds_ago": "秒前", "models_title": "可用模型列表", - "models_desc": "展示 /models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。", + "models_desc": "展示当前服务可用的模型,并自动使用已保存的 API Key。", "models_loading": "正在加载可用模型...", "models_empty": "未从 /models 获取到模型数据", "models_error": "获取模型列表失败", "models_count": "可用模型 {{count}} 个", "version_check_title": "版本检查", - "version_check_desc": "调用 /latest-version 接口比对服务器版本,提示是否有可用更新。", + "version_check_desc": "检查服务器版本,并提示是否有可用更新。", "version_current_label": "当前版本", "version_latest_label": "最新版本", "version_check_button": "检查更新", @@ -3226,6 +3353,7 @@ "openai_test_model_required": "请选择要测试的模型", "data_refreshed": "数据刷新成功", "connection_required": "请先建立连接", + "load_failed": "加载失败", "refresh_failed": "刷新失败", "save_failed": "保存失败", "update_failed": "更新失败", @@ -3277,11 +3405,11 @@ "accountPolicy": { "section_title": "账号处理策略", "section_hint": "只影响新进入的请求监控事件,已排队任务会继续完成。", - "runtime_note": "未被环境变量锁定的开关会保存到当前 Manager Server,并且无需重启即可生效。环境变量仍会锁定对应开关。", + "runtime_note": "没有被启动环境锁定的开关会保存到当前 Manager Server,且无需重启即可生效。被环境变量锁定的开关仍需修改服务环境变量。", "group_quota_title": "额度处理", "group_quota_description": "控制请求监控识别到额度耗尽后的认证文件冷却与恢复行为。", "group_auth_issues_title": "认证异常处理", - "group_auth_issues_description": "控制 token 撤销、认证不可用、工作区停用等异常进入待处理队列后的处理方式。", + "group_auth_issues_description": "控制登录失效、账号不可用、工作区停用等异常的记录和处理方式。", "refresh": "刷新", "load_failed_title": "加载失败", "load_failed": "加载失败:{{message}}", @@ -3304,55 +3432,55 @@ "source_env": "使用环境变量", "source_database": "使用 UI 设置", "codexQuotaCooldown_title": "Codex 额度冷却处理", - "codexQuotaCooldown_summary": "确认 Codex 短窗口额度耗尽后,临时禁用对应认证文件,并在重置时间后恢复。", + "codexQuotaCooldown_summary": "确认 Codex 短时间额度用完后,临时停用对应登录文件,并在额度恢复后重新启用。", "codexQuotaCooldown_toggle": "启用额度冷却处理", - "codexQuotaCooldown_description": "请求监控确认 Codex 短窗口 usage-limit 后,会临时禁用对应认证文件,并在重置时间后自动恢复。", + "codexQuotaCooldown_description": "请求监控确认 Codex 短时间额度用完后,会临时停用对应登录文件,并在额度恢复后自动启用。", "codexQuotaCooldown_behavior": [ "只处理 Codex", - "只处理 429 + usage_limit_reached", - "必须带 resets_at 或 resets_in_seconds", - "会临时禁用认证文件", + "只处理明确的额度用完错误", + "必须能判断恢复时间", + "会临时停用登录文件", "重置时间后自动恢复", - "只恢复 CPAMP 自己禁用的", - "不会恢复用户手动禁用的认证文件" + "只恢复 CPAMP 自动停用的账号", + "不会恢复用户手动停用的登录文件" ], - "authIssueQueue_title": "认证异常待处理队列", - "authIssueQueue_summary": "把高置信度认证异常记录为候选项,供你在认证异常页面继续处理。", - "authIssueQueue_toggle": "记录认证异常候选", - "authIssueQueue_description": "请求监控发现 token 被撤销、认证不可用或工作区停用时,会记录为认证异常候选,等待你手动处理。", + "authIssueQueue_title": "认证异常待处理列表", + "authIssueQueue_summary": "把明确的登录或账号异常记录下来,方便你在认证异常页面继续处理。", + "authIssueQueue_toggle": "记录认证异常", + "authIssueQueue_description": "请求监控发现账号登录失效、认证不可用或工作区停用时,会记录下来等待你手动处理。", "authIssueQueue_behavior": [ - "认证异常时记录候选", + "发现认证异常时记录下来", "在认证异常页面手动处理", - "未开启时不记录新候选", - "已有的待处理候选仍可继续处理" + "未开启时不记录新异常", + "已有的待处理异常仍可继续处理" ], "authIssueAutoDisable_title": "自动禁用异常认证文件", - "authIssueAutoDisable_summary": "对高置信度 delete / reauth 候选先自动禁用认证文件,但仍保留人工确认流程。", - "authIssueAutoDisable_toggle": "自动禁用高置信度异常认证文件", - "authIssueAutoDisable_description": "开启后,高置信度异常候选会先自动禁用对应认证文件,仍需你后续手动处理。", + "authIssueAutoDisable_summary": "发现明确的失效或需重新登录账号时,先自动停用对应登录文件,但仍保留人工确认流程。", + "authIssueAutoDisable_toggle": "自动停用明确异常的登录文件", + "authIssueAutoDisable_description": "开启后,明确异常的账号会先自动停用对应登录文件,仍需你后续手动处理。", "authIssueAutoDisable_behavior": [ - "只对高置信度 delete / reauth 候选自动禁用", - "review 不会自动禁用", - "普通 payment/quota 402 不处理", - "自动禁用前重新读取认证文件并校验身份", + "只对明确需要删除或重新登录的账号自动停用", + "仅需检查的账号不会自动停用", + "普通付款或额度错误不处理", + "自动停用前会重新读取登录文件并确认账号", "只禁用,不删除", "不会自动恢复", "需要你后续手动处理" ], - "authIssueAutoDisable_dependency_note": "需先开启「认证异常待处理队列」。", - "authIssueAutoDisable_configured_dependency_note": "已开启,待「认证异常待处理队列」开启后生效。", + "authIssueAutoDisable_dependency_note": "需先开启「认证异常待处理列表」。", + "authIssueAutoDisable_configured_dependency_note": "已开启,待「认证异常待处理列表」开启后生效。", "authIssueAutoDisable_confirm_title": "开启自动禁用异常认证文件?", - "authIssueAutoDisable_confirm_body": "开启后,Manager Server 会在请求监控发现高置信度认证异常时先禁用对应认证文件。", + "authIssueAutoDisable_confirm_body": "开启后,Manager Server 会在请求监控发现明确认证异常时先停用对应登录文件。", "authIssueAutoDisable_confirm_disable_only": "只会禁用匹配的认证文件,不会删除认证文件。", "authIssueAutoDisable_confirm_no_recovery": "不会自动恢复,仍需要你在认证异常页面继续处理。", - "authIssueAutoDisable_confirm_requires_queue": "该能力依赖「认证异常待处理队列」生效。", + "authIssueAutoDisable_confirm_requires_queue": "该能力依赖「认证异常待处理列表」生效。", "authIssueAutoDisable_confirm_button": "开启自动禁用", - "authIssueAutoDisable_confirm": "确认开启认证异常自动禁用?它只会禁用匹配的认证文件,不会删除,也不会自动恢复,仍需要你后续手动处理。", + "authIssueAutoDisable_confirm": "确认开启认证异常自动禁用?它只会禁用匹配的登录文件,不会删除,也不会自动恢复,仍需要你后续手动处理。", "open_auth_issues": "查看认证异常", "effective_label": "当前生效", "advanced_summary": "详情", - "env_locked_hint": "该开关由环境变量锁定,请修改服务环境变量并重启。", + "env_locked_hint": "该开关由服务启动环境锁定,请修改环境变量并重启。", "env_locked_reason": "由环境变量 {{envKey}} 锁定。修改该变量并重启服务后才能更改此开关。", - "gating_note": "关闭某项策略只会阻止后续请求监控事件进入处理流程;已经在队列中或正在执行的任务会继续完成。" + "gating_note": "关闭某项策略只会影响之后发现的新问题;已经排队或正在处理的任务会继续完成。" } } diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index fb170d817..fe566b9ee 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -12,6 +12,7 @@ "edit": "編輯", "add": "新增", "update": "更新", + "reset": "重設", "refresh": "重新整理", "retry": "重試", "close": "關閉", @@ -49,10 +50,11 @@ "copy": "複製", "expand": "展開", "collapse": "收起", + "navigation": "導覽", "status": "狀態", "action": "操作", "custom_headers_label": "自訂請求標頭", - "custom_headers_hint": "選填,設定需要附帶到請求中的 HTTP 標頭,名稱和值均不能為空。", + "custom_headers_hint": "選填。需要額外傳送給服務的請求標頭可以填在這裡,名稱和值都要填寫。", "custom_headers_add": "新增請求標頭", "custom_headers_key_placeholder": "Header 名稱,例如 X-Custom-Header", "custom_headers_value_placeholder": "Header 值", @@ -85,7 +87,7 @@ "custom_connection_hint": "預設使用目前存取位址,若需要可手動輸入其他位址。", "cpa_connection_label": "CPA 連線位址", "cpa_connection_placeholder": "例如: http://127.0.0.1:8317", - "cpa_connection_hint": "Manager Server 會用該位址連線 CPA Management API 和 RESP 用量佇列。", + "cpa_connection_hint": "Manager Server 會透過這個位址連線 CPA,並讀取請求用量資料。", "cpa_address_required": "請輸入 CPA 位址", "usage_service_mode_hint": "目前面板由 Manager Server 託管,請填寫 CPA API 位址。", "usage_service_configured_hint": "目前面板由已設定的 Manager Server 託管,登入後會使用服務端儲存的 CPA 連線。", @@ -100,7 +102,7 @@ "migration_detected_hint": "偵測到歷史資料,服務端會保留資料並自動遷移舊設定。", "cpa_management_key_label": "CPA Management Key", "cpa_management_key_placeholder": "請輸入 CPA Management Key", - "cpa_management_key_hint": "僅供 Manager Server 驗證和管理 CPA 上游使用。", + "cpa_management_key_hint": "僅供 Manager Server 連線和管理 CPA 使用。", "cpa_management_key_required": "請輸入 CPA Management Key", "setup_title": "初始化精靈", "setup_steps": "初始化步驟", @@ -113,10 +115,10 @@ "step_polling": "採集間隔", "step_review": "確認提交", "request_monitoring_enabled": "啟用請求監控", - "request_monitoring_enabled_hint": "儲存時會自動啟用 CPA 用量統計,並啟動 Manager Server 採集器。", - "request_monitoring_disabled_hint": "關閉後仍會儲存 CPA 連線,但不會啟用 CPA 用量統計或啟動採集器。若 CPA 已在其他位置開啟用量統計,佇列保留時間內再次啟用採集器可能仍會採集到期間資料。", - "poll_interval_label": "採集輪詢間隔(毫秒)", - "poll_interval_hint": "必須小於等於 CPA 用量佇列保留時間;儲存時會自動啟用 CPA 用量統計。", + "request_monitoring_enabled_hint": "儲存時會自動開啟 CPA 用量統計,並開始讀取請求記錄。", + "request_monitoring_disabled_hint": "關閉後仍會儲存 CPA 連線,但不會記錄新的監控資料。若 CPA 已在其他位置記錄用量,短時間內重新開啟後,可能會看到之前保留的一小段資料。", + "poll_interval_label": "讀取間隔(毫秒)", + "poll_interval_hint": "讀取間隔不能超過 CPA 保存用量資料的時間;儲存時會自動開啟 CPA 用量統計。", "poll_interval_invalid": "請輸入有效的採集間隔", "show_key": "顯示金鑰", "hide_key": "隱藏金鑰", @@ -139,8 +141,8 @@ "error_forbidden": "存取被拒絕,權限不足", "error_not_found": "伺服器位址無效或管理介面未啟用", "error_server": "伺服器內部錯誤,請稍後再試", - "error_backend_detail": "後端詳情", - "error_cors": "跨域請求被阻擋,請檢查伺服器設定", + "error_backend_detail": "服務回傳詳情", + "error_cors": "瀏覽器攔截了這次連線,請檢查伺服器跨域設定", "error_ssl": "SSL/TLS 憑證驗證失敗" }, "header": { @@ -157,34 +159,46 @@ }, "nav": { "dashboard": "儀表板", + "dashboard_short": "儀表板", "basic_settings": "基本設定", "api_keys": "API 金鑰", "ai_providers": "AI 供應商", + "ai_providers_short": "AI", "auth_files": "驗證檔案", + "auth_files_short": "驗證", "oauth": "OAuth 登入", + "oauth_short": "OAuth", "quota_management": "配額管理", + "quota_management_short": "配額", "plugins": "插件管理", "plugins_short": "插件管理", "plugin_store": "插件商店", "plugin_store_short": "商店", "codex_inspection": "Codex 帳號巡檢", + "codex_inspection_short": "Codex 巡檢", "server_codex_inspection": "服務端巡檢", "usage_analytics": "用量分析", "monitoring_center": "請求監控", + "monitoring_center_short": "監控", "account_actions": "認證異常", + "account_actions_short": "異常", "config_management": "設定面板", + "config_management_short": "設定", + "system_config": "系統設定", "logs": "記錄檢視", - "system_info": "系統資訊" + "logs_short": "記錄", + "system_info": "系統資訊", + "system_info_short": "系統" }, "account_actions": { - "eyebrow": "Auth Issue Queue", + "eyebrow": "帳號異常", "title": "認證異常處理", "back_to_monitoring": "返回請求監控", - "description": "請求監控發現 OAuth token 被撤銷、認證不可用或工作區停用時,會記錄為認證異常候選,並在這裡等待你確認刪除、重新啟用、忽略或標記為已處理。", + "description": "這裡列出請求監控發現的登入或帳號異常,例如帳號已失效、無法認證或工作區被停用。你可以確認刪除、重新啟用、忽略或標記為已處理。", "pending_count": "待處理", "visible_count": "目前列表", "status_filter": "狀態篩選", - "search_placeholder": "搜尋帳號、檔案、原因、Header 錯誤或 trace", + "search_placeholder": "搜尋帳號、檔案、原因或錯誤資訊", "filter_pending": "待處理", "filter_all": "全部", "filter_ignored": "已忽略", @@ -214,7 +228,7 @@ "evidence_title": "失敗證據", "no_evidence": "沒有可顯示的證據。", "empty_title": "目前沒有認證異常", - "empty_desc": "當請求監控捕獲 token_revoked 或 invalidated oauth token 等認證失敗時,會自動出現在這裡。", + "empty_desc": "當請求監控發現帳號登入失效、認證失敗等問題時,會自動出現在這裡。", "load_failed_title": "載入失敗", "load_failed": "載入認證異常失敗:{{message}}", "action_failed": "操作失敗:{{message}}", @@ -277,14 +291,14 @@ "token_mix_cache_creation": "快取建立", "model_cost_rank": "模型成本排行", "no_traffic_data": "暫無流量", - "collector_status_title": "採集器狀態", - "collector_events": "事件數", - "collector_dead_letters": "死信", + "collector_status_title": "請求記錄讀取狀態", + "collector_events": "記錄數", + "collector_dead_letters": "異常記錄", "collector_mode": "模式", - "collector_queue": "佇列", - "collector_last_inserted": "最近入庫", - "collector_last_consumed": "最近消費", - "collector_total_inserted": "累計入庫", + "collector_queue": "資料來源", + "collector_last_inserted": "最近寫入", + "collector_last_consumed": "最近讀取", + "collector_total_inserted": "累計寫入", "collector_total_skipped": "累計跳過", "collector_last_error": "最近錯誤", "collector_unavailable": "不可用", @@ -304,7 +318,7 @@ "health_status_problem": "異常", "health_status_disabled": "已停用", "health_status_warning": "警告", - "health_queue_status": "佇列狀態", + "health_queue_status": "資料讀取狀態", "health_error_log_count": "{{count}} 個檔案", "today_overview_usage_service": "今日概覽(基於 Manager Server)", "metric_failure_count": "失敗 {{value}}", @@ -403,6 +417,7 @@ "gemini_add_modal_title": "新增 Gemini API 金鑰", "gemini_add_modal_key_label": "API 金鑰", "gemini_add_modal_key_placeholder": "輸入 Gemini API 金鑰", + "gemini_key_required": "請輸入 Gemini API 金鑰", "gemini_add_modal_key_hint": "逐條輸入金鑰,可同時指定選填 Base URL。", "gemini_keys_add_btn": "新增金鑰", "gemini_base_url_label": "Base URL(選填)", @@ -414,7 +429,7 @@ "gemini_models_add_btn": "新增模型", "gemini_models_fetch_button": "從 /v1beta/models 取得", "gemini_models_fetch_title": "從 Gemini /v1beta/models 選擇模型", - "gemini_models_fetch_hint": "按 Gemini API 規範請求 GET /v1beta/models;預設附帶 x-goog-api-key(來自 API Key 欄位)並合併你設定的自訂請求標頭。", + "gemini_models_fetch_hint": "會使用上方位址和 Gemini 金鑰讀取模型列表;如設定了額外請求標頭,也會一起帶上。", "gemini_models_fetch_url_label": "請求位址", "gemini_models_fetch_refresh": "重新取得", "gemini_models_fetch_loading": "正在從 Gemini /v1beta/models 取得模型清單...", @@ -431,6 +446,7 @@ "gemini_models_count": "模型數量", "gemini_edit_modal_title": "編輯 Gemini API 金鑰", "gemini_edit_modal_key_label": "API 金鑰:", + "gemini_delete_title": "刪除 Gemini 金鑰", "gemini_delete_confirm": "確定要刪除這個 Gemini 金鑰嗎?", "excluded_models_label": "排除的模型(選填):", "excluded_models_placeholder": "用逗號或換行分隔,例如: gemini-1.5-pro, gemini-1.5-flash", @@ -455,6 +471,7 @@ "codex_add_modal_title": "新增 Codex API 設定", "codex_add_modal_key_label": "API 金鑰:", "codex_add_modal_key_placeholder": "請輸入 Codex API 金鑰", + "codex_key_required": "請輸入 Codex API 金鑰", "codex_add_modal_url_label": "Base URL(必填):", "codex_add_modal_url_placeholder": "例如: https://api.example.com", "codex_add_modal_proxy_label": "代理 URL(選填):", @@ -466,11 +483,11 @@ "codex_models_add_btn": "新增模型", "codex_models_fetch_button": "從 /v1/models 取得", "codex_models_fetch_title": "從 /v1/models 選擇模型", - "codex_models_fetch_hint": "使用上方 Base URL 呼叫 /v1/models 端點,附帶 API Key(Bearer)與自訂請求標頭。", + "codex_models_fetch_hint": "會使用上方位址和金鑰讀取模型列表;如設定了額外請求標頭,也會一起帶上。", "codex_models_fetch_url_label": "請求位址", "codex_models_fetch_refresh": "重新取得", "codex_models_fetch_loading": "正在從 /v1/models 取得模型清單...", - "codex_models_fetch_empty": "未取得到模型,請檢查端點或驗證資訊。", + "codex_models_fetch_empty": "未取得模型,請檢查位址、金鑰或請求標頭。", "codex_models_fetch_error": "取得模型失敗", "codex_models_fetch_apply": "新增已選模型", "codex_models_search_label": "搜尋模型", @@ -478,7 +495,7 @@ "codex_models_search_empty": "沒有符合的模型,請更換關鍵字試試。", "codex_models_fetch_added": "已新增 {{count}} 個新模型", "codex_test_title": "連通性測試", - "codex_test_hint": "向 /v1/responses 發送測試請求,驗證目前 Codex 設定是否可用。", + "codex_test_hint": "發送一次測試請求,確認目前 Codex 設定是否可用。", "codex_test_model_label": "測試模型", "codex_test_select_placeholder": "選擇或輸入要測試的模型", "codex_test_select_empty": "請先新增模型再進行測試", @@ -488,7 +505,7 @@ "codex_test_failed": "Codex 連通性測試失敗", "codex_test_endpoint_invalid": "請先填寫有效的 Base URL", "codex_test_model_required": "請先填寫測試模型", - "codex_test_key_required": "請提供 API Key、Authorization 請求標頭或 auth_index", + "codex_test_key_required": "請先填寫 API Key,或選擇一個已登入帳號。", "codex_models_count": "模型數量", "codex_edit_modal_title": "編輯 Codex API 設定", "codex_edit_modal_key_label": "API 金鑰:", @@ -503,7 +520,9 @@ "claude_add_modal_title": "新增 Claude API 設定", "claude_add_modal_key_label": "API 金鑰:", "claude_add_modal_key_placeholder": "請輸入 Claude API 金鑰", + "claude_key_required": "請輸入 Claude API 金鑰", "claude_add_modal_url_label": "Base URL(選填):", + "claude_base_url_required": "請輸入 Claude Base URL", "claude_add_modal_url_placeholder": "例如: https://api.anthropic.com", "claude_add_modal_proxy_label": "代理 URL(選填):", "claude_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080", @@ -518,7 +537,7 @@ "claude_models_count": "模型數量", "claude_models_fetch_button": "從 /v1/models 取得", "claude_models_fetch_title": "從 Claude /v1/models 選擇模型", - "claude_models_fetch_hint": "按 Anthropic 規範請求 GET /v1/models,預設附帶 x-api-key 與 anthropic-version: 2023-06-01;也會合併你設定的自訂請求標頭。", + "claude_models_fetch_hint": "會使用上方位址和 Claude 金鑰讀取模型列表;如設定了額外請求標頭,也會一起帶上。", "claude_models_fetch_url_label": "請求位址", "claude_models_fetch_refresh": "重新取得", "claude_models_fetch_loading": "正在從 Claude /v1/models 取得模型清單...", @@ -530,7 +549,7 @@ "claude_models_search_empty": "沒有符合的模型,請更換關鍵字試試。", "claude_models_fetch_added": "已新增 {{count}} 個新模型", "claude_test_title": "連通性測試", - "claude_test_hint": "按 Anthropic 規範向 /v1/messages 發送測試請求,驗證目前設定是否可用。", + "claude_test_hint": "發送一次測試請求,確認目前 Claude 設定是否可用。", "claude_test_select_placeholder": "從目前模型清單選擇", "claude_test_select_empty": "目前未設定模型,請先新增模型", "claude_test_action": "測試", @@ -564,7 +583,9 @@ "vertex_add_modal_title": "新增 Vertex API 設定", "vertex_add_modal_key_label": "API 金鑰:", "vertex_add_modal_key_placeholder": "請輸入 Vertex API 金鑰", + "vertex_key_required": "請輸入 Vertex API 金鑰", "vertex_add_modal_url_label": "Base URL:", + "vertex_base_url_required": "請輸入 Vertex Base URL", "vertex_add_modal_url_placeholder": "例如: https://example.com/api", "vertex_add_modal_proxy_label": "代理 URL(選填):", "vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080", @@ -572,6 +593,7 @@ "vertex_edit_modal_key_label": "API 金鑰:", "vertex_edit_modal_url_label": "Base URL:", "vertex_edit_modal_proxy_label": "代理 URL(選填):", + "vertex_delete_title": "刪除 Vertex 設定", "vertex_delete_confirm": "確定要刪除這個 Vertex 設定嗎?", "vertex_models_label": "模型別名:", "vertex_models_add_btn": "新增映射", @@ -598,6 +620,7 @@ "openai_add_modal_url_placeholder": "例如: https://openrouter.ai/api/v1", "openai_add_modal_keys_label": "API 金鑰", "openai_edit_modal_keys_label": "API 金鑰", + "openai_key_required": "請至少新增一個 API 金鑰", "openai_keys_hint": "每個金鑰可搭配一個選填代理位址,更便於管理。", "openai_keys_add_btn": "新增金鑰", "openai_key_placeholder": "輸入 sk- 開頭的金鑰", @@ -609,11 +632,11 @@ "openai_models_add_btn": "新增模型", "openai_models_fetch_button": "從 /models 取得", "openai_models_fetch_title": "從 /models 選擇模型", - "openai_models_fetch_hint": "使用上方 Base URL 呼叫 /models 端點,附帶第一個 API Key(Bearer)與自訂請求標頭。", + "openai_models_fetch_hint": "會使用上方位址和第一個金鑰讀取模型列表;如設定了額外請求標頭,也會一起帶上。", "openai_models_fetch_url_label": "請求位址", "openai_models_fetch_refresh": "重新取得", "openai_models_fetch_loading": "正在從 /models 取得模型清單...", - "openai_models_fetch_empty": "未取得到模型,請檢查端點或驗證資訊。", + "openai_models_fetch_empty": "未取得模型,請檢查位址、金鑰或請求標頭。", "openai_models_fetch_error": "取得模型失敗", "openai_models_fetch_back": "返回編輯", "openai_models_fetch_apply": "新增已選模型", @@ -626,12 +649,14 @@ "openai_edit_modal_name_label": "供應商名稱:", "openai_edit_modal_url_label": "Base URL:", "openai_edit_modal_models_label": "模型清單(name[, alias] 每行一個):", + "openai_delete_title": "刪除 OpenAI 供應商", "openai_delete_confirm": "確定要刪除這個 OpenAI 供應商嗎?", "openai_keys_count": "金鑰數量", "openai_models_count": "模型數量", "openai_test_title": "連通性測試", "openai_test_hint": "使用目前設定向 /chat/completions 請求,驗證是否可用。", "openai_test_model_placeholder": "選擇或輸入要測試的模型", + "openai_test_model_required": "請選擇要測試的模型", "openai_test_action": "發送測試", "openai_test_running": "正在發送測試請求...", "openai_test_timeout": "測試請求逾時({{seconds}} 秒)。", @@ -658,6 +683,9 @@ "table_col_models": "模型", "table_col_recent": "近期請求", "table_col_actions": "操作", + "priority_decrease": "降低優先級", + "priority_edit": "編輯優先級", + "priority_increase": "提高優先級", "filter_all": "全部", "filter_kind_aria": "依提供商類型篩選", "search_placeholder": "搜尋名稱 / 位址 / 金鑰", @@ -708,7 +736,7 @@ "auth_files": { "title": "驗證檔案管理", "title_section": "驗證檔案", - "description": "這裡集中管理 CLI Proxy 支援的所有 JSON 驗證檔案(如 Qwen、Gemini、Vertex 等),上傳後即可在執行時啟用對應的 AI 服務。", + "description": "這裡管理各 AI 服務的登入檔案,例如 Qwen、Gemini、Vertex。上傳後,對應服務就可以在面板中使用。", "upload_button": "上傳檔案", "paste_button": "貼上 JSON", "paste_title": "貼上驗證 JSON", @@ -722,9 +750,9 @@ "paste_cpa_placeholder": "{\n \"type\": \"codex\",\n \"access_token\": \"...\"\n}", "paste_session_placeholder": "{\n \"user\": { \"email\": \"...\" },\n \"account\": { \"id\": \"...\" },\n \"accessToken\": \"...\",\n \"sessionToken\": \"...\"\n}", "paste_sub2api_placeholder": "{\n \"exported_at\": \"...\",\n \"proxies\": [],\n \"accounts\": [\n {\n \"platform\": \"openai\",\n \"type\": \"oauth\",\n \"credentials\": { \"access_token\": \"...\" }\n }\n ]\n}", - "paste_cpa_hint": "驗證為 JSON 物件後會原樣儲存。", - "paste_session_hint": "ChatGPT Web session JSON 會先轉換為 CPA Codex 驗證檔案再上傳。Token 屬於敏感登入憑證。", - "paste_sub2api_hint": "sub2api 官方帳號匯出 JSON 會提取 OpenAI OAuth 帳號並轉換為 CPA Codex 驗證檔案。Token 屬於敏感登入憑證。", + "paste_cpa_hint": "確認內容是有效 JSON 後會直接儲存。", + "paste_session_hint": "ChatGPT 網頁登入資訊會先轉換成 CPA 可用的 Codex 登入檔案再上傳。Token 屬於敏感登入資訊,請勿分享。", + "paste_sub2api_hint": "sub2api 匯出的帳號 JSON 會提取 OpenAI OAuth 帳號,並轉換成 CPA 可用的 Codex 登入檔案。Token 屬於敏感登入資訊,請勿分享。", "paste_error_file_name": "檔案名稱不可為空", "paste_error_file_extension": "檔案名稱必須以 .json 結尾", "paste_error_file_name_invalid": "請使用不含路徑分隔符或保留字元的 .json 檔案名稱", @@ -782,7 +810,7 @@ "pagination_next": "下一頁", "pagination_info": "第 {{current}} / {{total}} 頁 · 共 {{count}} 個檔案", "search_label": "搜尋憑證", - "search_placeholder": "搜尋名稱、類型、認證索引、帳號 ID、方案或錯誤狀態,支援 * 萬用字元", + "search_placeholder": "搜尋名稱、類型、帳號、方案或錯誤狀態,支援 * 萬用字元", "problem_filter_label": "問題篩選", "problem_filter_only": "僅顯示有問題憑證", "disabled_filter_only": "僅顯示已停用憑證", @@ -864,16 +892,18 @@ "codex_status_badge_monthly_limited_title": "Codex 月額度視窗已達到上限,通常需要等待月額度恢復。", "codex_status_badge_disabled_reset": "恢復 {{reset}}", "codex_status_badge_disabled_reset_title": "這個已停用 Codex 帳號有已知額度恢復時間:{{reset}}。", - "codex_status_badge_observed_quota": "用量 Header 額度", - "codex_status_badge_observed_quota_title": "最近用量回應 Header 記錄了額度或恢復時間。此資訊來自歷史請求,不代表即時主動查詢結果。", - "codex_status_badge_observed_error": "用量 Header 錯誤", - "codex_status_badge_observed_error_title": "最近用量回應 Header 記錄了認證、額度或限流錯誤。此資訊來自歷史請求,用於輔助排查。", + "codex_status_badge_observed_quota": "最近請求中的額度", + "codex_status_badge_observed_quota_title": "最近請求回傳過額度或恢復時間。此資訊來自歷史請求,不代表即時查詢結果。", + "codex_status_badge_observed_error": "最近請求中的錯誤", + "codex_status_badge_observed_error_title": "最近請求回傳過登入、額度或限流錯誤。此資訊來自歷史請求,僅用於輔助排查。", "status_toggle_label": "啟用", "status_enabled_success": "已啟用「{{name}}」", "status_disabled_success": "已停用「{{name}}」", "batch_status_success": "已成功更新 {{count}} 個檔案", "batch_status_partial": "成功 {{success}} 個,失敗 {{failed}} 個", "batch_delete_title": "刪除已選檔案", + "delete_title": "刪除檔案", + "delete_all_title": "刪除全部檔案", "batch_delete_confirm": "確定要刪除 {{count}} 個檔案嗎?", "batch_selected": "已選 {{count}} 項", "batch_select_all": "全選", @@ -893,11 +923,14 @@ "batch_priority_invalid": "請輸入整數優先順序", "batch_websockets_enable": "啟用 WebSockets", "batch_websockets_disable": "停用 WebSockets", + "card_tools_title": "工具", + "quota_refresh_single": "重新整理額度", + "quota_refresh_hint": "僅重新整理此憑證的額度", "prefix_proxy_button": "驗證檔案詳情 / 編輯", "auth_field_editor_title": "驗證檔案詳情 / 編輯 - {{name}}", "prefix_proxy_loading": "正在載入驗證檔案...", "prefix_proxy_info_label": "驗證檔案資訊(info)", - "prefix_proxy_source_label": "驗證檔案 JSON(預覽)", + "prefix_proxy_source_label": "登入檔案內容(預覽)", "prefix_proxy_invalid_content_label": "下載內容(已截斷)", "prefix_label": "前綴(prefix)", "proxy_url_label": "代理 URL(proxy_url)", @@ -906,6 +939,8 @@ "priority_label": "優先順序(priority)", "priority_placeholder": "例如: 10 或 -1", "priority_hint": "僅支援整數;無效值會被忽略。數值越大優先順序越高。", + "websockets_label": "WebSockets", + "websockets_hint": "為該驗證檔案開啟 Responses API 的 websocket 傳輸。", "excluded_models_label": "排除模型(excluded_models)", "excluded_models_placeholder": "用逗號或換行分隔,例如: model-a, gpt-5-*, *-preview", "excluded_models_hint": "儲存為陣列;會自動 trim、小寫、去重並排序。", @@ -918,17 +953,17 @@ "note_display": "備註", "headers_label": "自訂請求標頭(headers)", "headers_placeholder": "{\n \"Header-Name\": \"value\"\n}", - "headers_hint": "以 JSON 物件格式輸入自訂 HTTP 請求標頭,例如:{\"X-My-Header\": \"value\"}", + "headers_hint": "按 JSON 格式填寫需要額外傳送的請求標頭,例如:{\"X-My-Header\": \"value\"}", "headers_invalid_json": "自訂請求標頭必須是有效的 JSON。", "headers_invalid_object": "自訂請求標頭必須是 JSON 物件。", "headers_invalid_value": "每個自訂請求標頭的值都必須是字串。", - "prefix_proxy_invalid_json": "該驗證檔案不是 JSON 物件,無法編輯欄位。", - "prefix_proxy_html_challenge": "下載到的是 HTML 驗證頁面,不是驗證 JSON 物件。請重新驗證或替換該驗證檔案後再編輯欄位。", + "prefix_proxy_invalid_json": "該登入檔案不是可編輯的 JSON 內容。", + "prefix_proxy_html_challenge": "下載到的是驗證頁面,不是登入檔案內容。請重新登入或替換檔案後再編輯。", "prefix_proxy_saved_success": "已更新驗證檔案「{{name}}」", "quota_refresh_success": "已重新整理「{{name}}」的配額", "quota_refresh_failed": "重新整理「{{name}}」的配額失敗:{{message}}", "quota_cooldown_badge": "冷卻至 {{recoverAt}}", - "quota_cooldown_badge_title": "此認證檔案處於 CPAMP 管理的額度冷卻中,將被自動恢復。它不是 CPA 的原生停用狀態。Owner:{{owner}}。預計恢復:{{recoverAt}}。" + "quota_cooldown_badge_title": "此帳號因額度用完被 CPAMP 暫時停用,預計 {{recoverAt}} 自動恢復;這不是你手動停用的狀態。來源:{{owner}}。" }, "antigravity_quota": { "title": "Antigravity 配額", @@ -939,7 +974,22 @@ "load_failed": "配額取得失敗:{{message}}", "missing_auth_index": "驗證檔案缺少 auth_index", "empty_models": "暫無配額資料", + "plan_label": "方案", + "group_gemini_models": "Gemini 模型", + "group_claude_gpt_models": "Claude 和 GPT 模型", + "group_models_description": "此群組包含:{{models}}", + "weekly_limit": "週限額", + "daily_limit": "日限額", + "five_hour_limit": "5 小時限額", + "monthly_limit": "月限額", + "quota_available": "配額可用", + "remaining_percent": "剩餘 {{percent}}%", + "refreshes_in": "{{duration}} 後重新整理", "refresh_available": "可重新整理", + "duration_day_hour": "{{days}} 天 {{hours}} 小時", + "duration_hour_minute": "{{hours}} 小時 {{minutes}} 分鐘", + "duration_minute": "{{minutes}} 分鐘", + "duration_less_than_minute": "小於 1 分鐘", "refresh_button": "重新整理配額", "fetch_all": "取得全部" }, @@ -1011,6 +1061,7 @@ "reset_credits_unknown": "未記錄", "reset_credits_earliest_expiry": "最早過期 {{time}}", "reset_credits_expiry_label": "主動重置過期時間", + "reset_credits_invalid_payload": "主動重置次數介面回傳格式無效", "reset_credit_expiry_item": "第 {{index}} 次", "active_limit_label": "生效限額", "credits_label": "Credits", @@ -1079,9 +1130,12 @@ "refresh_button": "重新整理配額", "fetch_all": "取得全部", "monthly_limit": "月度帳單限額", + "monthly_credits": "月度配額", "plan_label": "方案", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", + "pay_as_you_go_label": "按量付費", + "pay_as_you_go_disabled": "未啟用", "on_demand_cap": "按需上限", "usage_amount": "剩餘 {{remaining}} / {{limit}}", "reset_unknown": "重置時間未知" @@ -1127,6 +1181,7 @@ "save_success": "模型停用已更新", "save_failed": "更新模型停用失敗", "delete": "刪除供應商", + "delete_title": "刪除停用項", "delete_confirm": "確定要刪除 {{provider}} 的模型停用嗎?", "delete_success": "已刪除該供應商的模型停用", "delete_failed": "刪除模型停用失敗", @@ -1163,6 +1218,7 @@ "save_success": "模型別名已更新", "save_failed": "更新模型別名失敗", "delete": "刪除供應商", + "delete_title": "刪除映射", "delete_confirm": "確定要刪除 {{provider}} 的模型別名嗎?", "delete_link_title": "取消連結", "delete_link_confirm": "確定取消 {{sourceModel}}({{provider}})到別名 {{alias}} 的映射?", @@ -1298,6 +1354,8 @@ "oauth_callback_status_success": "回調 URL 已提交,等待驗證中...", "oauth_callback_status_error": "回調 URL 提交失敗:", "missing_state": "無法取得驗證狀態參數", + "login_another_account": "登入另一個帳號", + "view_auth_files": "查看驗證檔案", "iflow_oauth_title": "iFlow OAuth", "iflow_oauth_button": "開始 iFlow 登入", "iflow_oauth_hint": "透過 OAuth 流程登入 iFlow 服務,自動取得並儲存驗證檔案。", @@ -1331,7 +1389,7 @@ "model_prices": { "eyebrow": "模型價格", "title": "模型價格管理", - "subtitle": "集中維護 Manager Server 中的模型單價,自動同步高可信價格,並手動確認相似候選。", + "subtitle": "集中維護 Manager Server 中的模型單價,自動同步可靠匹配,並手動確認相似價格。", "back_to_monitoring": "返回請求監控", "summary": "模型價格摘要", "summary_total": "模型總數", @@ -1350,7 +1408,7 @@ "usage_service_required": "需要 Manager Server", "sync_model_count": "{{count}} 個模型參與同步", "manual_title": "手動價格", - "manual_hint": "儲存時使用本地模型名作為 key;手動儲存標記為 manual,確認候選時會保留實際價格來源與來源模型 ID。", + "manual_hint": "儲存時使用本地模型名作為 key;手動儲存會單獨標記,確認相似價格時會保留實際價格來源與來源模型 ID。", "search_placeholder": "搜尋模型或來源", "filter_all": "全部", "filter_missing": "缺價格", @@ -1358,11 +1416,11 @@ "filter_saved": "已儲存", "calls": "呼叫", "source": "來源", - "needs_confirmation": "需要確認候選", + "needs_confirmation": "需要確認價格", "no_price": "未設定價格", - "candidate_select": "選擇候選價格", + "candidate_select": "選擇價格", "confirm_candidate": "確認", - "candidate_confirmed": "候選價格已確認", + "candidate_confirmed": "價格已確認", "add_manual": "手動新增", "unmatched_title": "未匹配模型", "empty": "沒有符合條件的模型價格" @@ -1380,6 +1438,7 @@ "rate_30m": "近 30 分鐘速率", "model_name": "模型名稱", "model_price_settings": "模型定價設定", + "model_price_settings_short": "定價", "saved_prices": "已儲存的定價", "requests_trend": "請求趨勢", "tokens_trend": "Token 使用趨勢", @@ -1393,6 +1452,7 @@ "range_7d": "最近 7 天", "filter_all": "全部", "clear_filters": "清空篩選", + "clear_filters_short": "清空", "refresh": "重新整理", "export": "匯出資料", "import": "匯入資料", @@ -1468,7 +1528,7 @@ "time": "延遲", "avg_time": "平均延遲", "total_time": "總延遲", - "latency_unit_hint": "耗時取自後端欄位 {{field}},按 {{unit}} 解讀後再格式化顯示。", + "latency_unit_hint": "耗時取自服務回傳的 {{field}},按 {{unit}} 解讀後再格式化顯示。", "duration_unit_d": "天", "duration_unit_h": "時", "duration_unit_m": "分", @@ -1502,7 +1562,7 @@ "monitoring": { "eyebrow": "Runtime Overview", "title": "請求監控", - "subtitle": "圍繞模型層呼叫、上游渠道、認證憑證與估算花費建立統一視圖。首頁預設以模型呼叫為統計口徑,同時補充近似任務桶,避免把 usage.total_requests 誤讀成業務請求數。", + "subtitle": "這裡統計模型請求、帳號、渠道和預估花費。模型請求不等同於業務請求;任務數為系統估算,僅供參考。", "connection_status": "連線狀態", "usage_switch": "使用統計開關", "logging_switch": "檔案記錄開關", @@ -1520,8 +1580,9 @@ "custom_range_end": "結束時間", "custom_range_required": "請選擇完整的開始與結束時間。", "custom_range_invalid": "結束時間不能早於開始時間。", - "search_placeholder": "搜尋帳號、標籤、認證索引、模型、渠道、來源、憑證、API Key、介面路徑", + "search_placeholder": "搜尋帳號、標籤、模型、渠道、來源、API Key 或介面路徑", "auto_refresh": "自動刷新", + "auto_refresh_short": "自動", "auto_refresh_off": "關閉", "auto_refresh_5s": "5 秒", "auto_refresh_10s": "10 秒", @@ -1536,17 +1597,26 @@ "filter_status": "狀態", "filter_header_trace": "Header Trace", "filter_all_accounts": "全部帳號", + "filter_all_accounts_short": "全部帳號", "filter_all_providers": "全部提供方", + "filter_all_providers_short": "全部提供方", "filter_all_models": "全部模型", + "filter_all_models_short": "全部模型", "filter_all_channels": "全部渠道", + "filter_all_channels_short": "全部渠道", "filter_all_api_keys": "全部呼叫方密鑰", + "filter_all_api_keys_short": "全部呼叫方密鑰", "filter_all_statuses": "全部狀態", + "filter_all_statuses_short": "全部狀態", "filter_all_header_traces": "全部 Header Trace", "filter_all_header_traces_short": "Trace", "filter_status_success": "僅成功", + "filter_status_success_short": "成功", "filter_status_failed": "僅失敗", + "filter_status_failed_short": "失敗", "active_filters_hint": "已啟用 {{count}} 個篩選條件,目前命中 {{rows}} 條呼叫。", "clear_filters": "清空篩選", + "clear_filters_short": "清空", "open_usage": "開啟使用統計", "open_logs": "開啟記錄檢視", "open_system": "開啟中心資訊", @@ -1563,30 +1633,38 @@ "top_model": "熱點模型", "top_channel": "熱點渠道", "usage_disabled_title": "尚未啟用使用統計", - "usage_disabled_body": "請求監控依賴 Manager Server 或相容用量資料。請先開啟「使用統計」並確認 Manager Server 正在執行,否則此處只能顯示連線與設定狀態。", + "usage_disabled_body": "請求監控需要 Manager Server 提供用量資料。請先開啟「使用統計」,並確認 Manager Server 正在執行。", "request_monitoring_disabled_title": "請求監控未啟用", "request_monitoring_unavailable_title": "請求監控服務不可用", - "request_monitoring_disabled_body": "CPA Manager Plus 目前沒有啟動請求採集器。需要使用監控頁時,請到 CPA Manager Plus 設定中啟用請求監控。", - "request_monitoring_service_unavailable_body": "目前 Manager Server 無法提供請求監控。請檢查初始化、採集器狀態和已綁定 CPA 連線。", + "request_monitoring_disabled_body": "CPA Manager Plus 目前沒有讀取請求記錄。需要使用監控頁時,請到 CPA Manager Plus 設定中啟用請求監控。", + "request_monitoring_service_unavailable_body": "目前 Manager Server 無法提供請求監控。請檢查初始化狀態、請求記錄讀取狀態和已綁定的 CPA 連線。", "request_monitoring_not_configured_body": "請求監控只在 Manager Server 託管面板完成初始化後可用。CPA 控制面板方案不支援用量統計類功能。", - "empty_diagnostics_body": "如果 CPA 已產生請求但這裡仍為空,請確認已啟用用量發布,檢查 Manager Server /status,並確保同一個 CPA 執行個體只有一個 Manager Server 消費用量佇列。", + "empty_diagnostics_body": "如果 CPA 已有請求但這裡沒有資料,請確認請求監控已開啟、Manager Server 執行正常,並且同一個 CPA 只由一個 Manager Server 讀取用量。", "empty_diagnostics_link": "為什麼沒有資料?", "zero_token_notice_title": "偵測到零 Token 模型呼叫", "zero_token_notice_body": "目前範圍內有 {{count}} 次零 Token 模型呼叫({{models}})。這類模型通常不是純 Token 計費,當前花費只能部分估算。", "model_calls": "模型呼叫數", + "model_calls_short": "呼叫", "total_calls": "總呼叫", + "total_calls_short": "呼叫", "success_rate": "成功率", + "success_rate_short": "成功", "success_calls": "成功總數", + "success_calls_short": "成功", "failure_calls": "失敗總數", + "failure_calls_short": "失敗", "total_tokens": "總 Tokens", + "total_tokens_short": "Token", "avg_rpm": "平均 RPM", "avg_tpm": "平均 TPM", "avg_daily_requests": "日均呼叫", "approx_tasks": "近似任務數", "call_success_rate": "呼叫成功率", + "call_success_rate_short": "成功", "task_success_rate": "近似任務成功率", "avg_latency": "平均延遲", "estimated_cost": "Token 型估算花費", + "estimated_cost_short": "花費", "estimated_cost_hint": "僅涵蓋已設定單價且按 Token 計費的模型。", "estimated_cost_missing": "尚未設定模型單價,暫時無法顯示花費。", "zero_token_model_calls": "零 Token 模型呼叫", @@ -1594,9 +1672,13 @@ "token_signal_title": "Token 訊號", "token_signal_desc": "把輸入、輸出、推理與快取複用拆開來看,判斷真實消耗結構與快取效率。", "input_tokens": "輸入 Tokens", + "input_tokens_short": "輸入", "output_tokens": "輸出 Tokens", + "output_tokens_short": "輸出", "reasoning_tokens": "推理 Tokens", + "reasoning_tokens_short": "推理", "cached_tokens": "快取 Tokens", + "cached_tokens_short": "快取", "cache_read_tokens": "快取讀取 Tokens", "cache_creation_tokens": "快取建立 Tokens", "cache_read_tokens_short": "讀取", @@ -1622,7 +1704,7 @@ "cache_reuse_rate": "命中率", "cache_hit_rate": "命中率", "reasoning_share": "推理占比", - "hot_endpoint": "熱點介面", + "hot_endpoint": "高頻請求路徑", "accounts_suffix": "帳號", "groups_suffix": "監控組", "success_suffix": "成功", @@ -1630,10 +1712,11 @@ "approx_task_suffix": "近似任務", "recent_window_suffix": "最近 30 分鐘視窗", "failure_suffix": "失敗", - "metric_scope_note": "首頁 KPI 預設採用篩選範圍內的模型呼叫口徑,任務指標按 時間戳 + 來源 + 憑證 近似聚合。", + "metric_scope_note": "頂部指標統計目前篩選範圍內的模型請求。任務數按請求時間、來源和帳號近似合併,僅供參考。", "last_sync": "最近刷新", + "last_sync_short": "刷新", "requests_trend_title": "呼叫與 Token 趨勢", - "requests_trend_desc": "按 {{granularity}} 匯總請求量與 Token 體量,用於判斷波峰、負載節奏與成本變化。", + "requests_trend_desc": "按 {{granularity}} 匯總請求量和 Token 用量,幫助判斷高峰、負載節奏和成本變化。", "granularity_hour": "小時", "granularity_day": "天", "model_mix_title": "模型呼叫結構", @@ -1643,7 +1726,7 @@ "hourly_tokens_title": "小時 Token 分佈", "hourly_tokens_desc": "查看 Token 消耗在哪些時段最集中,輔助容量與費用判斷。", "channel_table_title": "渠道執行面板", - "channel_table_desc": "將使用統計與 openai-compatibility、auth-files 合併後,查看各渠道的吞吐、成功率與延遲。", + "channel_table_desc": "按渠道匯總請求量、成功率和延遲,方便判斷哪些渠道更穩定。", "model_table_title": "模型性能排行", "model_table_desc": "面向模型維度觀察請求量、成功率、Token 與時延。", "failure_table_title": "失敗來源分析", @@ -1657,8 +1740,11 @@ "clear_account_focus": "取消帳號聚焦", "data_tabs_aria_label": "資料視圖切換", "data_tab_accounts": "帳號彙總", + "data_tab_accounts_short": "帳號", "data_tab_api_keys": "呼叫方密鑰彙總", + "data_tab_api_keys_short": "API Key", "data_tab_realtime": "即時監控", + "data_tab_realtime_short": "即時", "data_tab_accounts_badge_title": "帳號數:{{count}}", "data_tab_api_keys_badge_title": "呼叫方密鑰數:{{count}}", "data_tab_realtime_badge_title": "{{failed}} 失敗 / 共 {{total}} 次請求", @@ -1667,6 +1753,7 @@ "api_key_summary_desc": "依呼叫方密鑰彙總目前篩選範圍內的呼叫次數、成功失敗、總 Token 與預估花費,可展開查看 Token 結構與模型使用明細。", "api_key_summary_description": "這裡的密鑰指呼叫方 / Client API Key,優先顯示別名;展開後可查看輸入、輸出、快取 Token,以及該呼叫方密鑰下各模型的呼叫與花費。", "api_key_summary_col_key": "呼叫方密鑰", + "api_key_summary_col_key_short": "Client Key", "api_key_summary_keys_count": "共 {{count}} 個呼叫方密鑰", "api_key_available": "可用", "api_key_unknown_label": "未知呼叫方密鑰", @@ -1680,9 +1767,11 @@ "account_overview_show_full_accounts_hint": "顯示完整帳號標識", "account_overview_show_masked_accounts_hint": "顯示脫敏帳號標識", "account_overview_col_account": "帳號 / Provider", + "account_overview_col_account_short": "帳號", "account_overview_col_success": "成功", "account_overview_col_failure": "失敗", "account_overview_col_cost": "預計花費", + "account_overview_col_cost_short": "花費", "account_overview_row_menu_label": "更多操作", "account_overview_row_menu_refresh_quota": "重新整理額度", "account_overview_sort_label": "排序", @@ -1730,7 +1819,7 @@ "codex_inspection_mode_server": "服務端巡檢", "codex_inspection_mode_current": "目前方式:{{mode}}", "codex_inspection_mode_local_desc": "在目前瀏覽器連線的本機流程中執行巡檢,依賴目前連線和瀏覽器工作階段,適合快速檢查目前裝置上的帳號狀態。", - "codex_inspection_mode_server_desc": "提交到 Manager Server 執行巡檢,支援定時、背景執行和歷史記錄,適合批次與統一稽核。", + "codex_inspection_mode_server_desc": "交給 Manager Server 檢查帳號,支援定時、背景執行和歷史記錄,適合批次檢查。", "codex_inspection_execution_mode": "執行方式", "codex_inspection_pause": "暫停", "codex_inspection_resume": "繼續巡檢", @@ -1816,7 +1905,7 @@ "codex_inspection_config_overview_title": "目前巡檢設定", "codex_inspection_config_overview_edit": "編輯設定", "codex_inspection_settings_title": "Codex 巡檢設定", - "codex_inspection_settings_desc": "設定 Codex 帳號巡檢的目標類型、併發、逾時與閾值,儲存後會直接用於目前瀏覽器中的後續巡檢。", + "codex_inspection_settings_desc": "設定本機巡檢要檢查哪些帳號、同時檢查多少個、逾時時間和額度閾值。儲存後會用於目前瀏覽器後續巡檢。", "codex_inspection_settings_saved": "巡檢設定已儲存", "codex_inspection_settings_reset": "已恢復預設巡檢設定", "codex_inspection_settings_reset_button": "恢復預設", @@ -1897,7 +1986,7 @@ "server_codex_inspection_run_confirm_title": "立即執行服務端巡檢", "server_codex_inspection_run_confirm_body": "服務端會依目前設定巡檢帳號;若啟用了自動處理策略,可能會直接禁用或刪除帳號。確認繼續嗎?", "server_codex_inspection_config_title": "服務端巡檢設定", - "server_codex_inspection_config_desc": "設定後端排程任務的觸發方式、探測並發、逾時與自動處理策略。", + "server_codex_inspection_config_desc": "設定定時檢查的觸發方式、檢查並發、逾時與自動處理策略。", "server_codex_inspection_config_saved": "服務端巡檢設定已保存", "server_codex_inspection_config_invalid": "請檢查巡檢設定,時間點需為 HH:mm,數字項需在有效範圍內。", "server_codex_inspection_open_config": "設定", @@ -2009,9 +2098,10 @@ "model_pricing_desc": "直接在監控頁維護模型單價,帳號、模型與即時日誌花費會立即聯動更新。", "price_storage_hint": "單價設定會優先寫入 SQLite;舊版瀏覽器本機定價會自動遷移。", "workbench_title": "呼叫工作台", - "workbench_desc": "按近似任務桶聚合最近呼叫,便於快速查看一次請求涉及的模型、結果、花費與介面路徑。", + "workbench_desc": "把最近的相關呼叫合併展示,方便查看一次任務涉及的模型、結果、花費和介面路徑。", "workbench_rows": "任務桶數", "recent_failures": "最近失敗呼叫", + "recent_failures_short": "失敗", "filtering_hint": "目前篩選「{{query}}」,命中 {{count}} 條模型呼叫。", "all_data_hint": "目前範圍共 {{count}} 條模型呼叫。", "other_models": "其他模型", @@ -2021,12 +2111,16 @@ "auth_indexes": "驗證索引", "column_type": "類型", "column_source_api_key": "來源 / API Key", + "column_source_api_key_short": "API Key", "column_model": "模型", "column_status": "狀態", "request_type": "請求類型", "request_status": "請求狀態", + "request_status_short": "狀態", "recent_status": "最近狀態", + "recent_status_short": "狀態", "latest_request_time": "最近請求時間", + "latest_request_time_short": "最新", "column_time": "時間", "focus_account": "聚焦帳號", "clear_focus": "取消聚焦", @@ -2040,6 +2134,7 @@ "column_auths": "憑證數", "column_models": "模型數", "column_success_rate": "成功率", + "column_success_rate_short": "成功", "column_tokens": "Tokens", "column_cost": "花費", "column_latency": "延遲", @@ -2051,9 +2146,11 @@ "column_failure_rate": "失敗率", "column_last_seen": "最近出現", "column_result": "結果", - "column_endpoints": "介面路徑", + "column_endpoints": "請求路徑", "this_call_usage": "本次用量", + "this_call_usage_short": "用量", "this_call_cost": "本次花費", + "this_call_cost_short": "花費", "state_disabled": "已停用", "state_active": "活躍", "state_inactive": "未生效", @@ -2066,16 +2163,20 @@ "source": "來源", "calls": "呼叫數", "log_rows": "日誌行", + "log_rows_short": "日誌", "realtime_api_key_label": "API Key", "realtime_api_key_hash": "API Key 雜湊", "realtime_api_key_masked": "脫敏金鑰", "pagination_prev": "上一頁", + "pagination_prev_short": "上一頁", "pagination_next": "下一頁", + "pagination_next_short": "下一頁", "pagination_info": "第 {{current}} / {{total}} 頁 · 顯示 {{start}}-{{end}} / {{count}}", "pagination_jump_prefix": "前往", "pagination_jump_label": "跳轉到頁碼", "pagination_jump_suffix": "頁", "page_size_label": "每頁", + "page_size_label_short": "每頁", "page_size_option": "{{count}} 行/頁", "load_more_events": "載入更多事件", "no_more_events": "沒有更多事件", @@ -2089,7 +2190,7 @@ "download_button": "下載記錄", "error_log_button": "選擇錯誤記錄", "error_logs_modal_title": "錯誤請求記錄", - "error_logs_description": "這裡展示 CPA 側記錄的請求錯誤記錄,通常用於排查 4xx/5xx、認證失敗、限額、上游異常等問題。請選擇檔案下載後查看具體原因。", + "error_logs_description": "這裡展示 CPA 側記錄的請求錯誤記錄,通常用於排查 4xx/5xx、認證失敗、限額、外部服務異常等問題。請選擇檔案下載後查看具體原因。", "error_logs_request_log_enabled": "目前已開啟請求記錄,按 CPA 介面約定錯誤請求記錄清單會始終為空。如需查看獨立錯誤記錄檔案,請關閉請求記錄後重新整理。", "error_logs_empty": "暫無錯誤請求記錄檔案", "error_logs_load_error": "載入錯誤記錄清單失敗", @@ -2105,6 +2206,7 @@ "log_content": "記錄內容", "loading": "正在載入記錄...", "load_error": "載入記錄失敗", + "clear_confirm_title": "清空記錄", "clear_confirm": "確定要清空所有記錄嗎?此操作無法還原!", "clear_success": "記錄已清空", "download_success": "記錄下載成功", @@ -2193,12 +2295,12 @@ "manager": { "title": "CPA Manager Plus 設定", "description": "管理 CPA Manager Plus 自身設定,和 CPA 設定分開儲存。", - "boundary_hint": "這裡儲存 CPAM 自身執行設定;CPA 用量統計和佇列保留時間仍屬於 CPA 設定。", + "boundary_hint": "這裡儲存 CPAMP 自身設定;CPA 用量統計和資料保留時間仍屬於 CPA 設定。", "load_failed": "CPA Manager Plus 設定載入失敗", "save_success": "CPA Manager Plus 設定已儲存", "number_invalid": "{{label}} 必須是正整數", "service_base_required": "無法解析 Manager Server 位址,請從 Manager Server 託管面板開啟。", - "poll_interval_retention_error": "採集輪詢間隔必須小於等於 CPA 用量佇列保留時間", + "poll_interval_retention_error": "讀取間隔不能超過 CPA 保存用量資料的時間", "runtime_title": "執行環境", "runtime_embedded": "同源內建 Manager Server", "runtime_external": "CPA 控制面板模式", @@ -2231,9 +2333,9 @@ "cpa_management_key_hide": "隱藏金鑰", "cpa_management_key_clear": "清空輸入", "cpa_management_key_saving": "正在用新金鑰存取 CPA 驗證…", - "cpa_connection_risk_inline": "修改 CPA 連結位址或管理金鑰會影響代理、用量採集和監控資料,請只在確認目標 CPA 正確時儲存。", + "cpa_connection_risk_inline": "修改 CPA 連結位址或管理金鑰會影響代理和監控資料,請確認目標 CPA 正確後再儲存。", "cpa_connection_risk_title": "確認修改 CPA 連線", - "cpa_connection_risk_message": "你正在修改 Manager Server 的 CPA 連線。目前 CPA:{{currentBase}};儲存後 CPA:{{nextBase}}。這會影響代理、用量採集和監控資料,請確認目標 CPA 位址和 Management Key 完全正確後再繼續。", + "cpa_connection_risk_message": "你正在修改 Manager Server 的 CPA 連線。目前 CPA:{{currentBase}};儲存後 CPA:{{nextBase}}。這會影響代理和監控資料,請確認目標 CPA 位址和 Management Key 完全正確後再繼續。", "cpa_connection_risk_confirm": "確認並承擔風險", "current_cpa_base": "目前 CPA 位址", "bound_cpa_base": "Manager Server 已綁定 CPA", @@ -2251,17 +2353,17 @@ "status_binding_required": "需要重設 Manager Server 初始化", "status_binding_required_short": "需重設", "request_monitoring_title": "請求監控", - "request_monitoring_hint": "開啟後會啟用 CPA 用量統計並啟動採集器;關閉後會停止 CPAM 採集器,但不會關閉 CPA 用量統計或清空 CPA 佇列。", + "request_monitoring_hint": "開啟後會啟用 CPA 用量統計,並開始讀取請求記錄;關閉後會停止讀取,但不會關閉 CPA 用量統計,也不會清空 CPA 已保留的資料。", "request_monitoring_enabled": "啟用請求監控", "request_monitoring_dependency": "請先完成 Manager Server 初始化並保留已綁定 CPA 連線,再啟用請求監控。", - "request_monitoring_queue_note": "如果 CPA 用量統計仍開啟,在 CPA 佇列保留時間內再次啟用請求監控,可能會採集到關閉 CPAM 採集器期間保留的資料。", + "request_monitoring_queue_note": "如果 CPA 用量統計仍開啟,短時間內再次啟用請求監控,可能會讀取到關閉期間 CPA 保留的資料。", "collector_mode": "採集模式", "collector_mode_auto": "自動", "collector_mode_http": "HTTP", "collector_mode_resp": "RESP", "collector_mode_subscribe": "RESP Pub/Sub", - "poll_interval_ms": "採集輪詢間隔(毫秒)", - "poll_interval_hint": "必須小於等於 CPA 用量佇列保留時間,目前按 {{seconds}} 秒驗證。", + "poll_interval_ms": "讀取間隔(毫秒)", + "poll_interval_hint": "讀取間隔不能超過 CPA 保存用量資料的時間,目前按 {{seconds}} 秒驗證。", "batch_size": "每批最多拉取筆數", "query_limit": "用量查詢返回上限", "config_source": "設定來源", @@ -2269,7 +2371,7 @@ "config_source_db": "SQLite", "config_source_none": "未儲存", "cpa_usage_enabled": "CPA 用量統計", - "cpa_retention": "CPA 佇列保留時間", + "cpa_retention": "CPA 資料保留時間", "cpa_retention_value": "{{seconds}} 秒" }, "visual": { @@ -2318,7 +2420,7 @@ "commercial_mode": "商業模式", "commercial_mode_desc": "停用高開銷中介軟體以支援高並行", "usage_statistics_enabled": "啟用用量統計", - "usage_statistics_enabled_desc": "允許 CPA 發布請求用量事件,供 Manager Server 持續消費", + "usage_statistics_enabled_desc": "允許 CPA 記錄請求用量,供 Manager Server 持續讀取", "logging_to_file": "寫入記錄檔", "logging_to_file_desc": "將記錄儲存到檔案", "plugins_enabled": "啟用插件系統", @@ -2328,8 +2430,8 @@ "plugin_store_sources": "插件商店來源", "plugin_store_sources_desc": "每行一個插件商店 manifest URL,會寫入 CPA 的 plugins.store-sources 陣列。", "plugin_store_auth": "插件商店認證規則", - "plugin_store_auth_desc": "為私有 registry、GitHub Release metadata 或下載位址設定認證標頭。", - "plugin_store_auth_hint": "這裡只填寫環境變數名稱,不填寫真實 token;後端會從執行環境讀取值。", + "plugin_store_auth_desc": "為私有插件來源、GitHub Release 資訊或下載位址設定存取標頭。", + "plugin_store_auth_hint": "這裡只填寫環境變數名稱,不填寫真實 token;服務會從執行環境讀取值。", "store_auth_empty": "暫未設定認證規則。公開插件來源不需要設定。", "store_auth_rule": "認證規則", "store_auth_add": "新增認證規則", @@ -2337,7 +2439,7 @@ "store_auth_type": "認證類型", "store_auth_apply_to": "套用到", "store_auth_apply_to_hint": "不選擇時套用到所有請求類型。", - "store_auth_apply_registry": "Registry", + "store_auth_apply_registry": "插件列表", "store_auth_apply_metadata": "Metadata", "store_auth_apply_artifact": "Artifact", "store_auth_type_none": "無認證", @@ -2353,8 +2455,8 @@ "store_auth_allow_insecure": "允許 HTTP 非安全位址", "logs_max_size": "記錄檔大小限制(MB)", "error_logs_max_files": "錯誤記錄保留檔案數", - "redis_usage_queue_retention": "用量佇列保留時間(秒)", - "redis_usage_queue_retention_hint": "CPA 預設 60 秒,最大 3600 秒;留空使用預設值。請保持 Manager Server 持續執行。", + "redis_usage_queue_retention": "用量資料保留時間(秒)", + "redis_usage_queue_retention_hint": "CPA 預設保留 60 秒,最大 3600 秒;留空使用預設值。請保持 Manager Server 持續執行。", "antigravity_signature_cache": "啟用 Antigravity 簽名快取", "antigravity_signature_cache_desc": "優先使用並校驗快取的 thinking block 簽名", "antigravity_signature_strict": "嚴格校驗旁路簽名", @@ -2399,8 +2501,8 @@ "session_affinity_ttl": "會話黏性 TTL", "force_model_prefix": "強制模型前綴", "force_model_prefix_desc": "未帶前綴的模型請求只使用無前綴憑證", - "passthrough_headers": "透傳上游回應 Header", - "passthrough_headers_desc": "將過濾後的上游回應 Header 轉發給下游客戶端", + "passthrough_headers": "轉發外部服務回應標頭", + "passthrough_headers_desc": "將過濾後的外部服務回應標頭轉發給客戶端", "disable_cooling": "停用冷卻調度", "disable_cooling_desc": "全域停用驗證/模型失敗後的冷卻視窗", "session_affinity": "會話黏性路由", @@ -2430,7 +2532,7 @@ }, "payload": { "title": "Payload 設定", - "description": "預設值、原始 JSON 規則、覆寫規則與篩選規則", + "description": "設定請求參數的預設值、覆寫值和篩選規則", "default_rules": "預設規則", "default_rules_desc": "當請求中未指定參數時,使用這些預設值", "default_raw_rules": "預設 Raw 規則", @@ -2440,7 +2542,7 @@ "override_raw_rules": "覆寫 Raw 規則", "override_raw_rules_desc": "始終按原始 JSON 片段覆寫參數值,適合 response_format、schema 等複雜欄位", "filter_rules": "篩選規則", - "filter_rules_desc": "透過 JSON Path 預篩選上游請求體,自動剔除不合規/冗餘參數(Request Sanitization)" + "filter_rules_desc": "按 JSON 路徑在轉發前移除不需要或不支援的參數" } }, "api_keys": { @@ -2552,8 +2654,8 @@ "cpa_connection_required_for_monitoring": "啟用請求監控時必須填寫 CPA 位址和管理金鑰。", "management_api_validation_failed": "CPA Management API 驗證失敗,請檢查 CPA 位址和管理金鑰。", "management_api_config_failed": "讀取 CPA 設定失敗,請檢查 Management API。", - "cpa_usage_retention_invalid": "CPA 用量佇列保留時間必須大於 0。", - "poll_interval_exceeds_retention": "採集輪詢間隔必須小於等於 CPA 用量佇列保留時間。", + "cpa_usage_retention_invalid": "CPA 用量資料保留時間必須大於 0。", + "poll_interval_exceeds_retention": "讀取間隔不能超過 CPA 保存用量資料的時間。", "invalid_time_zone": "時區儲存失敗,請檢查時區名稱或確認執行環境已安裝 tzdata。", "enable_cpa_usage_statistics_failed": "啟用 CPA 用量統計失敗。", "setup_env_managed": "初始化設定由環境變數管理。", @@ -2590,7 +2692,7 @@ "recent_empty": "暫無最近訪問。", "updated_at": "更新時間 {{time}}", "overview_updated_at": "更新時間", - "overview_selected_bucket": "目前時間桶", + "overview_selected_bucket": "目前時間段", "overview_no_bucket_selected": "未選中", "overview_active_filters": "已選 {{count}} 項篩選", "view_daily_board": "每日看板", @@ -2633,11 +2735,11 @@ "heatmap_scale_byHour": "按小時歸一化", "heatmap_color_value": "顏色值", "token_structure_title": "Token 結構", - "token_structure_hint": "按時間桶堆疊展示輸入、輸出、快取和推理 Token。", + "token_structure_hint": "按時間段展示輸入、輸出、快取和推理 Token。", "entity_trend_title": "實體趨勢", - "entity_trend_hint": "Top 實體曲線按整體佔比近似估算,並非逐實體真實時間序列。", + "entity_trend_hint": "主要項目的趨勢按整體占比估算,並非每一項的真實逐時資料。", "selected_credential_trend_title": "選中憑證趨勢", - "selected_credential_trend_hint": "僅展示目前選中憑證的真實時間桶趨勢,不做整體佔比估算。", + "selected_credential_trend_hint": "僅展示目前選中憑證的真實時間段趨勢,不做整體佔比估算。", "insights_title": "用量洞察", "insights_hint": "基於目前分析切片產生可行動觀察。", "insights_empty": "目前範圍未發現明顯風險。", @@ -2660,7 +2762,7 @@ "provider_usage_share_title": "Provider 用量占比", "provider_health_title": "Provider 健康", "quota_status_title": "配額狀態", - "quota_status_hint": "上游無配額資料時,根據近期用量產生估算狀態。", + "quota_status_hint": "外部服務沒有回傳配額資料時,根據近期用量產生估算狀態。", "quota_status_normal": "正常", "quota_status_warning": "預警", "quota_status_exhausted": "已耗盡", @@ -2737,7 +2839,7 @@ "summary_cost_meta": "按模型價格估算", "summary_vs_previous": "較上週期", "trend_title": "趨勢分析", - "trend_hint": "點擊時間桶可查看異常分析,並跳轉到請求明細。", + "trend_hint": "點擊時間段可查看異常分析,並跳轉到請求明細。", "trend_peak_request_bucket": "峰值請求桶", "trend_average_bucket_requests": "平均每桶請求", "trend_request_change": "請求環比", @@ -2758,19 +2860,19 @@ "anomaly_latency_spike": "平均延遲升高", "anomaly_unknown": "異常指標", "possible_causes": "可能原因", - "cause_traffic": "該時間桶內流量或批次任務增加。", + "cause_traffic": "該時間段內流量或批次任務增加。", "cause_batch": "Prompt、模型或用戶端變更導致 Token 消耗變化。", "cause_cache": "快取使用變化,導致未命中輸入 Token 增加。", "cause_request_spike": "請求數顯著上升,優先檢查批次任務、自動化腳本或突發用戶端流量。", - "cause_request_drop": "請求數明顯下降,可能是用戶端停止呼叫、佇列耗盡或上游限流導致。", + "cause_request_drop": "請求數明顯下降,可能是用戶端停止呼叫、資料暫時為空或外部服務限流導致。", "cause_cost_spike": "成本增幅較大,檢查高價模型、長上下文或失敗重試是否集中出現。", "cause_cost_drop": "成本明顯下降,可能是請求減少、切換到低價模型或輸出長度降低。", "cause_token_per_request_spike": "平均每請求 Token 升高,重點檢查 Prompt、上下文拼接和 max_tokens 設定。", "cause_cache_hit_drop": "快取命中比例下降,可能是 Prompt 結構變化或快取前綴不穩定。", "cause_cache_growth": "快取 Token 成長,表示該時間段復用快取較多,可對比實際成本是否同步下降。", - "cause_failure_rate_spike": "失敗率明顯升高,重點檢查上游錯誤、限流或認證設定。", + "cause_failure_rate_spike": "失敗率明顯升高,重點檢查外部服務錯誤、限流或認證設定。", "cause_latency_spike": "平均延遲明顯升高,可能是模型回應變慢、網路抖動或請求排隊增加。", - "cause_no_clear_anomaly": "該時間桶與前一桶差異不大,可繼續查看請求明細確認單條異常。", + "cause_no_clear_anomaly": "該時間段與前一段差異不大,可繼續查看請求明細確認單條異常。", "common_views_title": "常用視圖", "common_view_7d": "近 7 天總覽", "common_view_cost": "成本排查", @@ -2781,10 +2883,10 @@ "overview_trend_hint": "請求、Token 與成本在同一時間範圍內聯動展示。", "health_trend_title": "請求健康趨勢", "anomaly_points_title": "異常時間點", - "anomaly_points_hint": "後端基於請求、成本、Token、快取、失敗率和延遲變化聚合出的異常桶。", - "drilldown_preview_title": "請求下鑽預覽", - "drilldown_preview_hint": "選中時間桶後展示該桶內的真實請求樣本。", - "drilldown_preview_empty": "點擊趨勢圖中的時間桶查看請求樣本。", + "anomaly_points_hint": "根據請求、成本、Token、快取、失敗率和延遲變化識別出的異常時段。", + "drilldown_preview_title": "請求樣本預覽", + "drilldown_preview_hint": "選中時間段後展示該時間段內的真實請求樣本。", + "drilldown_preview_empty": "點擊趨勢圖中的時間段查看請求樣本。", "model_overview_title": "模型消耗概覽", "api_key_overview_title": "客戶端 Key 消耗概覽", "credential_overview_title": "憑證消耗概覽", @@ -2958,7 +3060,7 @@ }, "plugin_management": { "title": "插件管理", - "description": "查看已發現和已註冊的插件,管理實例開關、設定欄位和資源入口。", + "description": "查看已安裝和可用的插件,管理啟停、設定和入口。", "refresh": "重新整理", "install_plugin": "安裝插件", "tabs_aria_label": "插件管理視圖", @@ -2966,12 +3068,12 @@ "tab_store": "插件商店", "load_failed": "載入插件失敗", "config_load_failed": "讀取插件設定失敗", - "config_not_found": "無法讀取插件設定,後端未找到該插件。", - "unsupported_backend": "目前後端未暴露插件管理 API。請使用包含插件管理介面的新版後端建置,並重新啟動服務。", + "config_not_found": "無法讀取插件設定,目前服務沒有找到該插件。", + "unsupported_backend": "目前服務版本不支援插件管理。請更新 CPA 或 Manager Server 後重新啟動。", "global_status": "全域狀態", "global_enabled": "已啟用", "global_disabled": "已停用", - "global_disabled_hint": "目前 plugins.enabled 為 false,插件實例即使啟用也不會生效。", + "global_disabled_hint": "目前已關閉插件總開關,單個插件即使啟用也不會生效。", "plugins_dir": "插件目錄", "installed_count": "已安裝", "effective_count": "已生效", @@ -2985,18 +3087,18 @@ "meta_version": "版本", "meta_author": "作者", "meta_path": "路徑", - "discovered": "發現插件", - "effective": "生效插件", + "discovered": "已發現", + "effective": "正在生效", "search_placeholder": "搜尋插件 ID、名稱、作者或資源...", "search_label": "搜尋插件", "no_plugins": "暫無插件", - "no_plugins_desc": "未從插件目錄或執行時註冊表中發現插件。", + "no_plugins_desc": "目前插件目錄中還沒有可用插件。", "configured": "已設定", "not_configured": "未設定", "registered": "已註冊", "not_registered": "未註冊", "oauth": "OAuth", - "status_effective": "生效中", + "status_effective": "正在生效", "status_global_disabled": "全域停用", "status_disabled": "已停用", "status_inactive": "未生效", @@ -3028,14 +3130,14 @@ "config_title": "設定 {{name}}", "base_settings": "基礎設定", "enabled": "啟用", - "enabled_hint": "控制 plugins.configs 中該插件實例的 enabled 欄位。", + "enabled_hint": "控制這個插件是否啟用。", "priority": "優先級", - "config_fields": "設定欄位", - "no_config_fields": "該插件沒有宣告可視化設定欄位。", + "config_fields": "設定項", + "no_config_fields": "該插件沒有可在介面中編輯的設定項。", "select_placeholder": "請選擇", - "array_item_placeholder": "輸入陣列項目", - "add_array_item": "新增陣列項目", - "remove_array_item": "刪除陣列項目", + "array_item_placeholder": "輸入一項內容", + "add_array_item": "新增一項", + "remove_array_item": "刪除這一項", "toggle_success": "插件狀態已更新", "toggle_failed": "插件狀態更新失敗", "save_success": "插件設定已儲存", @@ -3044,25 +3146,25 @@ "invalid_priority": "請輸入整數優先級", "invalid_number": "請輸入有效數字", "invalid_integer": "請輸入有效整數", - "invalid_json": "請輸入有效 JSON", - "expected_array": "請輸入 JSON 陣列", + "invalid_json": "請輸入有效的 JSON 內容", + "expected_array": "請輸入 JSON 列表", "expected_object": "請輸入 JSON 物件", - "invalid_enum": "請選擇宣告的枚舉值" + "invalid_enum": "請選擇列表中的可用選項" }, "plugin_store": { "title": "插件商店", - "description": "瀏覽插件註冊表,為目前後端安裝或更新插件。", + "description": "瀏覽可用插件,並為目前服務安裝或更新。", "refresh": "重新整理", "retry": "重試", "load_failed": "插件商店載入失敗", "loading_title": "正在載入插件商店", "loading_desc": "正在讀取插件來源與安裝狀態", - "unsupported_backend": "目前後端未提供插件商店 API。請使用包含插件商店介面的新後端建置,並重新啟動服務。", - "registry_failed": "插件註冊表請求失敗", + "unsupported_backend": "目前服務版本不支援插件商店。請更新 CPA 或 Manager Server 後重新啟動。", + "registry_failed": "插件來源請求失敗", "global_status": "全域狀態", "global_enabled": "已啟用", "global_disabled": "已停用", - "global_disabled_hint": "目前 plugins.enabled 為 false,已安裝的插件不會生效。", + "global_disabled_hint": "目前已關閉插件總開關,已安裝插件不會生效。", "plugins_dir": "插件目錄", "stat_available": "可用插件", "sources": "來源", @@ -3074,7 +3176,7 @@ "meta_platforms": "平台", "source_name": "來源:{{source}}", "cli_proxy_api_source": "CLIProxyAPI 來源", - "source_errors_title": "來源警告", + "source_errors_title": "插件來源警告", "unknown_source": "未知來源", "source_required": "該插件存在於多個商店來源中,請使用帶來源資訊的插件卡片重試。", "search_placeholder": "搜尋插件 ID、名稱、作者或標籤...", @@ -3093,20 +3195,20 @@ "platforms": "{{platforms}}", "auth_required": "需要認證", "auth_configured": "認證已設定", - "auth_required_hint": "該插件來源或下載位址需要先設定 plugins.store-auth。", + "auth_required_hint": "這個插件來源需要先設定存取憑據後才能安裝。", "install": "安裝", "update": "更新", "manage": "管理", "reinstall_plugin": "重新安裝", "delete_plugin": "刪除", "install_confirm_title": "安裝插件", - "install_confirm_message": "將從插件註冊表下載 {{target}} 並安裝到本機插件目錄,是否繼續?", - "install_security_warning_title": "第三方插件會以後端完整權限執行", - "install_security_warning_message": "插件會在代理服務內部執行程式碼,可讀取你的憑證與流量。請僅安裝你信任的插件,尤其要警惕任何並非由官方組織 router-for-me 發布的插件。", + "install_confirm_message": "將下載 {{target}} 並安裝到本機插件目錄,是否繼續?", + "install_security_warning_title": "第三方插件可以存取服務內的資料", + "install_security_warning_message": "插件會在代理服務內部執行,可能讀取你的憑證、請求和回應。請只安裝你信任的插件,尤其要謹慎安裝非官方 router-for-me 組織發布的插件。", "update_confirm_title": "更新插件", "update_confirm_message": "將下載並安裝 {{target}} 的最新版本,是否繼續?", "install_version_label": "指定版本(可選)", - "install_version_hint": "留空時使用後端預設/最新版本;目前列表版本為 {{version}}。", + "install_version_hint": "留空時使用服務預設或最新版本;目前列表版本為 {{version}}。", "install_version_latest": "最新版本", "reinstall_confirm_title": "重新安裝插件", "reinstall_confirm_message": "將先刪除 {{target}},再從插件商店重新安裝。若刪除成功但安裝失敗,插件會保持已刪除狀態。是否繼續?", @@ -3126,15 +3228,15 @@ "restart_required_banner": "以下插件需重新啟動服務後才能套用新版本:{{plugins}}", "open_repository": "開啟儲存庫", "open_homepage": "開啟首頁", - "no_plugins": "註冊表為空", - "no_plugins_desc": "插件註冊表未回傳任何插件。", + "no_plugins": "暫無可用插件", + "no_plugins_desc": "目前插件來源沒有回傳任何插件。", "no_matches": "沒有符合的插件", "no_matches_desc": "目前搜尋或篩選條件下沒有插件。", "clear_filters": "清除篩選", "gate_title": "安裝 {{name}}", "gate_title_update": "更新 {{name}}", - "gate_warning": "第三方插件會以後端完整權限執行。", - "gate_effect_runs_code": "該插件會在代理後端內執行第三方程式碼,並可能存取憑證、請求和回應。", + "gate_warning": "第三方插件可以存取服務內的資料。", + "gate_effect_runs_code": "該插件會在代理服務內部執行第三方程式碼,並可能存取憑證、請求和回應。", "gate_effect_no_review": "它並非由官方組織 router-for-me 發布或審核,後續版本的行為可能變化。", "gate_effect_restart": "安裝會修改本機插件目錄,並可能需要重新啟動服務。", "gate_untrusted_alert": "僅在你完全信任該插件的作者、儲存庫和來源時繼續。", @@ -3151,12 +3253,12 @@ "title": "插件頁面", "page_count": "{{count}} 個頁面", "load_failed": "載入插件頁面失敗", - "unsupported_backend": "目前後端未暴露插件頁面 API。請使用包含插件管理介面的新版後端建置,並重新啟動服務。", + "unsupported_backend": "目前服務版本不支援插件頁面。請更新 CPA 或 Manager Server 後重新啟動。", "unavailable": "插件頁面不可用", "not_found": "未找到插件頁面", - "not_found_desc": "該插件頁面未在目前後端插件列表中宣告。", + "not_found_desc": "目前服務沒有宣告這個插件頁面。", "empty_src": "插件頁面地址為空", - "empty_src_desc": "該插件選單未提供可嵌入的頁面地址。" + "empty_src_desc": "該插件選單沒有提供可開啟的頁面地址。" }, "system_info": { "title": "系統資訊", @@ -3170,13 +3272,13 @@ "not_loaded": "未載入", "seconds_ago": "秒前", "models_title": "可用模型清單", - "models_desc": "展示 /models 回傳的模型,並自動使用伺服器儲存的 API Key 進行驗證。", + "models_desc": "展示目前服務可用的模型,並自動使用已儲存的 API Key。", "models_loading": "正在載入可用模型...", "models_empty": "未從 /models 取得到模型資料", "models_error": "取得模型清單失敗", "models_count": "可用模型 {{count}} 個", "version_check_title": "版本檢查", - "version_check_desc": "呼叫 /latest-version 介面比對伺服器版本,提示是否有可用更新。", + "version_check_desc": "檢查伺服器版本,並提示是否有可用更新。", "version_current_label": "目前版本", "version_latest_label": "最新版本", "version_check_button": "檢查更新", @@ -3251,6 +3353,7 @@ "openai_test_model_required": "請選擇要測試的模型", "data_refreshed": "資料重新整理成功", "connection_required": "請先建立連線", + "load_failed": "載入失敗", "refresh_failed": "重新整理失敗", "save_failed": "儲存失敗", "update_failed": "更新失敗", @@ -3302,11 +3405,11 @@ "accountPolicy": { "section_title": "帳號處理策略", "section_hint": "只影響新進入的請求監控事件,已排隊任務會繼續完成。", - "runtime_note": "未被環境變數鎖定的開關會儲存到目前 Manager Server,且無需重啟即可生效。環境變數仍會鎖定對應開關。", + "runtime_note": "沒有被啟動環境鎖定的開關會儲存到目前 Manager Server,且無需重啟即可生效。被環境變數鎖定的開關仍需修改服務環境變數。", "group_quota_title": "額度處理", "group_quota_description": "控制請求監控識別到額度耗盡後的認證檔案冷卻與恢復行為。", "group_auth_issues_title": "認證異常處理", - "group_auth_issues_description": "控制 token 被撤銷、認證不可用、工作區停用等異常進入待處理流程後的處理方式。", + "group_auth_issues_description": "控制登入失效、帳號不可用、工作區停用等異常的記錄和處理方式。", "refresh": "重新整理", "load_failed_title": "載入失敗", "load_failed": "載入失敗:{{message}}", @@ -3329,55 +3432,55 @@ "source_env": "使用環境變數", "source_database": "使用 UI 設定", "codexQuotaCooldown_title": "Codex 額度冷卻處理", - "codexQuotaCooldown_summary": "確認 Codex 短視窗額度耗盡後,暫時停用對應認證檔案,並在重置時間後恢復。", + "codexQuotaCooldown_summary": "確認 Codex 短時間額度用完後,暫時停用對應登入檔案,並在額度恢復後重新啟用。", "codexQuotaCooldown_toggle": "啟用額度冷卻處理", - "codexQuotaCooldown_description": "請求監控確認 Codex 短視窗 usage-limit 後,會暫時停用對應認證檔案,並在重置時間後自動恢復。", + "codexQuotaCooldown_description": "請求監控確認 Codex 短時間額度用完後,會暫時停用對應登入檔案,並在額度恢復後自動啟用。", "codexQuotaCooldown_behavior": [ "只處理 Codex", - "只處理 429 + usage_limit_reached", - "必須帶 resets_at 或 resets_in_seconds", - "會暫時停用認證檔案", + "只處理明確的額度用完錯誤", + "必須能判斷恢復時間", + "會暫時停用登入檔案", "重置時間後自動恢復", - "只恢復 CPAMP 自己停用的", - "不會恢復使用者手動停用的認證檔案" + "只恢復 CPAMP 自動停用的帳號", + "不會恢復使用者手動停用的登入檔案" ], - "authIssueQueue_title": "認證異常待處理佇列", - "authIssueQueue_summary": "將高置信度認證異常記錄為候選項,供你在認證異常頁面繼續處理。", - "authIssueQueue_toggle": "記錄認證異常候選", - "authIssueQueue_description": "請求監控發現 token 被撤銷、認證不可用或工作區停用時,會記錄為認證異常候選,等待你手動處理。", + "authIssueQueue_title": "認證異常待處理列表", + "authIssueQueue_summary": "把明確的登入或帳號異常記錄下來,方便你在認證異常頁面繼續處理。", + "authIssueQueue_toggle": "記錄認證異常", + "authIssueQueue_description": "請求監控發現帳號登入失效、認證不可用或工作區停用時,會記錄下來等待你手動處理。", "authIssueQueue_behavior": [ - "認證異常時記錄候選", + "發現認證異常時記錄下來", "在認證異常頁面手動處理", - "未開啟時不記錄新候選", - "既有的待處理候選仍可繼續處理" + "未開啟時不記錄新異常", + "既有的待處理異常仍可繼續處理" ], "authIssueAutoDisable_title": "自動停用異常認證檔案", - "authIssueAutoDisable_summary": "對高置信度 delete / reauth 候選先自動停用認證檔案,但仍保留人工確認流程。", - "authIssueAutoDisable_toggle": "自動停用高置信度異常認證檔案", - "authIssueAutoDisable_description": "開啟後,高置信度異常候選會先自動停用對應認證檔案,仍需你後續手動處理。", + "authIssueAutoDisable_summary": "發現明確的失效或需重新登入帳號時,先自動停用對應登入檔案,但仍保留人工確認流程。", + "authIssueAutoDisable_toggle": "自動停用明確異常的登入檔案", + "authIssueAutoDisable_description": "開啟後,明確異常的帳號會先自動停用對應登入檔案,仍需你後續手動處理。", "authIssueAutoDisable_behavior": [ - "只對高置信度 delete / reauth 候選自動停用", - "review 不會自動停用", - "普通 payment/quota 402 不處理", - "自動停用前重新讀取認證檔案並校驗身分", + "只對明確需要刪除或重新登入的帳號自動停用", + "僅需檢查的帳號不會自動停用", + "普通付款或額度錯誤不處理", + "自動停用前會重新讀取登入檔案並確認帳號", "只停用,不刪除", "不會自動恢復", "需要你後續手動處理" ], - "authIssueAutoDisable_dependency_note": "需先開啟「認證異常待處理佇列」。", - "authIssueAutoDisable_configured_dependency_note": "已開啟,待「認證異常待處理佇列」開啟後生效。", + "authIssueAutoDisable_dependency_note": "需先開啟「認證異常待處理列表」。", + "authIssueAutoDisable_configured_dependency_note": "已開啟,待「認證異常待處理列表」開啟後生效。", "authIssueAutoDisable_confirm_title": "開啟自動停用異常認證檔案?", - "authIssueAutoDisable_confirm_body": "開啟後,Manager Server 會在請求監控發現高置信度認證異常時先停用對應認證檔案。", + "authIssueAutoDisable_confirm_body": "開啟後,Manager Server 會在請求監控發現明確認證異常時先停用對應登入檔案。", "authIssueAutoDisable_confirm_disable_only": "只會停用符合條件的認證檔案,不會刪除認證檔案。", "authIssueAutoDisable_confirm_no_recovery": "不會自動恢復,仍需要你在認證異常頁面繼續處理。", - "authIssueAutoDisable_confirm_requires_queue": "該能力依賴「認證異常待處理佇列」生效。", + "authIssueAutoDisable_confirm_requires_queue": "該能力依賴「認證異常待處理列表」生效。", "authIssueAutoDisable_confirm_button": "開啟自動停用", - "authIssueAutoDisable_confirm": "確認開啟認證異常自動停用?它只會停用符合條件的認證檔案,不會刪除,也不會自動恢復,仍需要你後續手動處理。", + "authIssueAutoDisable_confirm": "確認開啟認證異常自動停用?它只會停用符合條件的登入檔案,不會刪除,也不會自動恢復,仍需要你後續手動處理。", "open_auth_issues": "查看認證異常", "effective_label": "目前生效", "advanced_summary": "詳情", - "env_locked_hint": "此開關由環境變數鎖定,請修改服務環境變數並重新啟動。", + "env_locked_hint": "此開關由服務啟動環境鎖定,請修改環境變數並重新啟動。", "env_locked_reason": "由環境變數 {{envKey}} 鎖定。修改該變數並重新啟動服務後才能變更此開關。", - "gating_note": "關閉某項策略只會阻止後續請求監控事件進入處理流程;已經在佇列中或正在執行的任務會繼續完成。" + "gating_note": "關閉某項策略只會影響之後發現的新問題;已經排隊或正在處理的任務會繼續完成。" } } diff --git a/apps/web/src/services/api/antigravitySubscription.test.ts b/apps/web/src/services/api/antigravitySubscription.test.ts index bce0b08ae..1b1c0611a 100644 --- a/apps/web/src/services/api/antigravitySubscription.test.ts +++ b/apps/web/src/services/api/antigravitySubscription.test.ts @@ -2,6 +2,26 @@ import { describe, expect, it } from 'vitest'; import { parseAntigravitySubscriptionSummary } from './antigravitySubscription'; describe('parseAntigravitySubscriptionSummary', () => { + it('parses g1-pro-tier as a Pro plan', () => { + const summary = parseAntigravitySubscriptionSummary({ + currentTier: { + id: 'free-tier', + name: 'Antigravity', + }, + paidTier: { + id: 'g1-pro-tier', + name: 'Antigravity Pro', + }, + }); + + expect(summary).toMatchObject({ + plan: 'pro', + tierId: 'g1-pro-tier', + tierName: 'Antigravity Pro', + source: 'paid', + }); + }); + it('parses free-tier subscription data from a proxied body string', () => { const summary = parseAntigravitySubscriptionSummary({ body: JSON.stringify({ diff --git a/apps/web/src/services/api/antigravitySubscription.ts b/apps/web/src/services/api/antigravitySubscription.ts index c7ea3e086..02a650c58 100644 --- a/apps/web/src/services/api/antigravitySubscription.ts +++ b/apps/web/src/services/api/antigravitySubscription.ts @@ -1,11 +1,10 @@ import { apiCallApi, getApiCallErrorMessage } from './apiCall'; import { - ANTIGRAVITY_CODE_ASSIST_URL, + ANTIGRAVITY_CODE_ASSIST_URLS, ANTIGRAVITY_REQUEST_HEADERS, - createStatusError, - normalizeStringValue, - parseAntigravityPayload, -} from '@/utils/quota'; +} from '@/utils/quota/constants'; +import { createStatusError, getStatusFromError } from '@/utils/quota/formatters'; +import { normalizeStringValue, parseAntigravityPayload } from '@/utils/quota/parsers'; export type AntigravitySubscriptionPlan = 'free' | 'pro' | 'ultra' | 'ultra-lite' | 'unknown'; @@ -96,7 +95,7 @@ export const parseAntigravitySubscriptionSummary = ( if (!effectiveTier?.id && !effectiveTier?.name) return null; const rawCredits = isRecord(paidTierPayload) - ? paidTierPayload.availableCredits ?? paidTierPayload.available_credits + ? (paidTierPayload.availableCredits ?? paidTierPayload.available_credits) : undefined; return { @@ -112,18 +111,32 @@ export const parseAntigravitySubscriptionSummary = ( export const antigravitySubscriptionApi = { async get(authIndex: string): Promise { - const result = await apiCallApi.request({ - authIndex, - method: 'POST', - url: ANTIGRAVITY_CODE_ASSIST_URL, - header: { ...ANTIGRAVITY_REQUEST_HEADERS }, - data: CODE_ASSIST_REQUEST_BODY, - }); - - if (result.statusCode < 200 || result.statusCode >= 300) { - throw createStatusError(getApiCallErrorMessage(result), result.statusCode); + let lastError = ''; + let lastStatus: number | undefined; + + for (const url of ANTIGRAVITY_CODE_ASSIST_URLS) { + try { + const result = await apiCallApi.request({ + authIndex, + method: 'POST', + url, + header: { ...ANTIGRAVITY_REQUEST_HEADERS }, + data: CODE_ASSIST_REQUEST_BODY, + }); + + if (result.statusCode < 200 || result.statusCode >= 300) { + lastError = getApiCallErrorMessage(result); + lastStatus = result.statusCode; + continue; + } + + return parseAntigravitySubscriptionSummary(result.body ?? result.bodyText); + } catch (err: unknown) { + lastError = err instanceof Error ? err.message : 'Unknown error'; + lastStatus = getStatusFromError(err) ?? lastStatus; + } } - return parseAntigravitySubscriptionSummary(result.body ?? result.bodyText); + throw createStatusError(lastError || 'Unknown error', lastStatus); }, }; diff --git a/apps/web/src/services/api/codexQuota.ts b/apps/web/src/services/api/codexQuota.ts index 03fd09381..619266caf 100644 --- a/apps/web/src/services/api/codexQuota.ts +++ b/apps/web/src/services/api/codexQuota.ts @@ -3,9 +3,9 @@ import type { TFunction } from 'i18next'; import type { AuthFileItem, CodexUsagePayload } from '@/types'; import { CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL, - CODEX_REQUEST_HEADERS, CODEX_USAGE_URL, } from '@/utils/quota/constants'; +import { buildCodexUsageRequestHeaders } from '@/utils/quota/codexRequestHeaders'; import { createStatusError } from '@/utils/quota/formatters'; import { normalizeAuthIndex, parseCodexUsagePayload } from '@/utils/quota/parsers'; import { fetchCodexQuota, type CodexQuotaData } from '@/utils/quota/providerRequests'; @@ -24,26 +24,7 @@ export type CodexUsageRawResult = { payload: CodexUsagePayload | null; }; -export const buildCodexUsageRequestHeaders = ( - accountId?: string | null, - options: { userAgent?: string } = {} -): Record => { - const headers: Record = { - ...CODEX_REQUEST_HEADERS, - }; - - const trimmedAccountId = String(accountId ?? '').trim(); - if (trimmedAccountId) { - headers['Chatgpt-Account-Id'] = trimmedAccountId; - } - - const userAgent = String(options.userAgent ?? '').trim(); - if (userAgent) { - headers['User-Agent'] = userAgent; - } - - return headers; -}; +export { buildCodexUsageRequestHeaders }; export const requestCodexUsageRaw = async ({ authIndex, diff --git a/apps/web/src/types/quota.ts b/apps/web/src/types/quota.ts index 32e289f07..56f288c55 100644 --- a/apps/web/src/types/quota.ts +++ b/apps/web/src/types/quota.ts @@ -286,6 +286,12 @@ export interface AntigravityQuotaGroup { buckets: AntigravityQuotaBucket[]; } +export interface AntigravityQuotaSubscription { + plan: string | null; + tierName: string | null; + tierId: string | null; +} + export interface AntigravityQuotaBucket { id: string; label: string; @@ -298,6 +304,7 @@ export interface AntigravityQuotaBucket { export interface AntigravityQuotaState { status: 'idle' | 'loading' | 'success' | 'error'; groups: AntigravityQuotaGroup[]; + subscription?: AntigravityQuotaSubscription | null; serverTimeOffsetMs?: number | null; error?: string; errorStatus?: number; @@ -427,7 +434,10 @@ export interface XaiBillingPayload { export interface XaiBillingSummary { monthlyLimitCents: number | null; usedCents: number | null; + includedUsedCents: number | null; onDemandCapCents: number | null; + onDemandUsedCents: number | null; + onDemandUsedPercent: number | null; billingPeriodStart?: string; billingPeriodEnd?: string; usedPercent: number | null; diff --git a/apps/web/src/utils/quota/builders.test.ts b/apps/web/src/utils/quota/builders.test.ts index 86ac78808..41252d8cd 100644 --- a/apps/web/src/utils/quota/builders.test.ts +++ b/apps/web/src/utils/quota/builders.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildAntigravityQuotaGroups } from './builders'; +import { buildAntigravityQuotaGroups, buildKimiQuotaRows } from './builders'; describe('buildAntigravityQuotaGroups', () => { it('builds Antigravity groups from the real models payload shape', () => { @@ -41,7 +41,7 @@ describe('buildAntigravityQuotaGroups', () => { apiProvider: 'API_PROVIDER_GOOGLE_GEMINI', modelProvider: 'MODEL_PROVIDER_GOOGLE', }, - 'chat_20706': { + chat_20706: { quotaInfo: { remainingFraction: 1, }, @@ -115,3 +115,33 @@ describe('buildAntigravityQuotaGroups', () => { expect(groups.some((group) => group.models?.includes('chat_20706'))).toBe(false); }); }); + +describe('buildKimiQuotaRows', () => { + it('normalizes singular, plural, second, and empty duration units', () => { + const rows = buildKimiQuotaRows({ + limits: [ + { window: { duration: 30, timeUnit: 'SECONDS' }, detail: { used: 1, limit: 10 } }, + { window: { duration: 45, timeUnit: 'SECOND' }, detail: { used: 1, limit: 10 } }, + { window: { duration: 60, timeUnit: 'MINUTES' }, detail: { used: 1, limit: 10 } }, + { window: { duration: 30, timeUnit: 'MINUTE' }, detail: { used: 1, limit: 10 } }, + { window: { duration: 6, timeUnit: 'HOURS' }, detail: { used: 1, limit: 10 } }, + { window: { duration: 1, timeUnit: 'HOUR' }, detail: { used: 1, limit: 10 } }, + { window: { duration: 7, timeUnit: 'DAYS' }, detail: { used: 1, limit: 10 } }, + { window: { duration: 1, timeUnit: 'DAY' }, detail: { used: 1, limit: 10 } }, + { window: { duration: 90, timeUnit: '' }, detail: { used: 1, limit: 10 } }, + ], + }); + + expect(rows.map((row) => row.labelParams?.duration)).toEqual([ + '30s', + '45s', + '1h', + '30m', + '6h', + '1h', + '7d', + '1d', + '90s', + ]); + }); +}); diff --git a/apps/web/src/utils/quota/builders.ts b/apps/web/src/utils/quota/builders.ts index 76cb7ca8b..57f2ab5a3 100644 --- a/apps/web/src/utils/quota/builders.ts +++ b/apps/web/src/utils/quota/builders.ts @@ -30,8 +30,7 @@ export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): { const remainingFraction = normalizeQuotaFraction(remainingValue); const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time; const resetTime = typeof resetValue === 'string' ? resetValue : undefined; - const displayName = - normalizeStringValue(entry.displayName ?? entry.display_name) ?? undefined; + const displayName = normalizeStringValue(entry.displayName ?? entry.display_name) ?? undefined; return { remainingFraction, @@ -288,13 +287,7 @@ export function buildAntigravityQuotaGroupsFromModels( models, payload ), - buildAntigravitySharedGroup( - 'gemini', - 'Gemini', - geminiModelIds, - models, - payload - ), + buildAntigravitySharedGroup('gemini', 'Gemini', geminiModelIds, models, payload), ].filter((group): group is AntigravityQuotaGroup => group !== null); } @@ -412,11 +405,11 @@ function kimiResetHint(data: Record): string | undefined { function kimiDurationToken(duration: number, rawTimeUnit: unknown): string { const unit = typeof rawTimeUnit === 'string' ? rawTimeUnit.trim().toUpperCase() : ''; - if (unit === 'MINUTES') { + if (unit === 'MINUTE' || unit === 'MINUTES') { return duration % 60 === 0 ? `${duration / 60}h` : `${duration}m`; } - if (unit === 'HOURS') return `${duration}h`; - if (unit === 'DAYS') return `${duration}d`; + if (unit === 'HOUR' || unit === 'HOURS') return `${duration}h`; + if (unit === 'DAY' || unit === 'DAYS') return `${duration}d`; return `${duration}s`; } @@ -498,8 +491,12 @@ export function buildKimiQuotaRows(payload: KimiUsagePayload): KimiQuotaRow[] { const limits = payload.limits; if (Array.isArray(limits)) { limits.forEach((item, idx) => { - const detail = (item.detail && typeof item.detail === 'object' ? item.detail : item) as KimiUsageDetail | KimiLimitItem; - const window = (item.window && typeof item.window === 'object' ? item.window : {}) as KimiLimitWindow; + const detail = (item.detail && typeof item.detail === 'object' ? item.detail : item) as + | KimiUsageDetail + | KimiLimitItem; + const window = ( + item.window && typeof item.window === 'object' ? item.window : {} + ) as KimiLimitWindow; const fallbackLabel = kimiLimitLabel(item, detail, window, idx); const row = toKimiUsageRow(detail as Record, fallbackLabel); if (row) { diff --git a/apps/web/src/utils/quota/codexRequestHeaders.ts b/apps/web/src/utils/quota/codexRequestHeaders.ts new file mode 100644 index 000000000..419ee2bdc --- /dev/null +++ b/apps/web/src/utils/quota/codexRequestHeaders.ts @@ -0,0 +1,31 @@ +import { CODEX_REQUEST_HEADERS } from './constants'; + +export const buildCodexUsageRequestHeaders = ( + accountId?: string | null, + options: { userAgent?: string } = {} +): Record => { + const headers: Record = { + ...CODEX_REQUEST_HEADERS, + }; + + const trimmedAccountId = String(accountId ?? '').trim(); + if (trimmedAccountId) { + headers['Chatgpt-Account-Id'] = trimmedAccountId; + } + + const userAgent = String(options.userAgent ?? '').trim(); + if (userAgent) { + headers['User-Agent'] = userAgent; + } + + return headers; +}; + +export const buildCodexResetCreditsRequestHeaders = ( + accountId?: string | null +): Record => ({ + ...buildCodexUsageRequestHeaders(accountId), + Accept: 'application/json', + 'OpenAI-Beta': 'codex-1', + Originator: 'Codex Desktop', +}); diff --git a/apps/web/src/utils/quota/constants.ts b/apps/web/src/utils/quota/constants.ts index 2031d61ec..47129e353 100644 --- a/apps/web/src/utils/quota/constants.ts +++ b/apps/web/src/utils/quota/constants.ts @@ -57,14 +57,26 @@ export const TYPE_COLORS: Record = { }; // Antigravity API configuration -export const ANTIGRAVITY_QUOTA_URLS = [ +export const ANTIGRAVITY_QUOTA_SUMMARY_URLS = [ + 'https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary', + 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:retrieveUserQuotaSummary', + 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary', +]; + +export const ANTIGRAVITY_AVAILABLE_MODELS_URLS = [ 'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels', 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels', 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels', ]; -export const ANTIGRAVITY_CODE_ASSIST_URL = - 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; +export const ANTIGRAVITY_QUOTA_URLS = ANTIGRAVITY_QUOTA_SUMMARY_URLS; + +export const ANTIGRAVITY_CODE_ASSIST_URLS = [ + 'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist', + 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist', +]; + +export const ANTIGRAVITY_CODE_ASSIST_URL = ANTIGRAVITY_CODE_ASSIST_URLS[0]; export const ANTIGRAVITY_CLI_VERSION = '1.0.13'; export const ANTIGRAVITY_CLIENT_NAME = 'aidev_client'; diff --git a/apps/web/src/utils/quota/index.ts b/apps/web/src/utils/quota/index.ts index b38ab30b9..fa573ed74 100644 --- a/apps/web/src/utils/quota/index.ts +++ b/apps/web/src/utils/quota/index.ts @@ -9,5 +9,6 @@ export * from './formatters'; export * from './validators'; export * from './builders'; export * from './codexQuota'; +export * from './codexRequestHeaders'; export * from './resetCredits'; export * from './providerRequests'; diff --git a/apps/web/src/utils/quota/providerRequests.test.ts b/apps/web/src/utils/quota/providerRequests.test.ts index 51ce6b10e..a67305c47 100644 --- a/apps/web/src/utils/quota/providerRequests.test.ts +++ b/apps/web/src/utils/quota/providerRequests.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { mocks } = vi.hoisted(() => ({ mocks: { + getSubscription: vi.fn(), request: vi.fn(), }, })); @@ -15,16 +16,31 @@ vi.mock('@/services/api/apiCall', () => ({ `${result.statusCode} ${result.bodyText ?? ''}`.trim(), })); +vi.mock('@/services/api/antigravitySubscription', () => ({ + antigravitySubscriptionApi: { + get: mocks.getSubscription, + }, +})); + import { + ANTIGRAVITY_AVAILABLE_MODELS_URLS, + ANTIGRAVITY_QUOTA_SUMMARY_URLS, ANTIGRAVITY_USER_AGENT, CODEX_RATE_LIMIT_RESET_CREDITS_URL, CODEX_USAGE_URL, } from './constants'; -import { fetchAntigravityQuota, fetchCodexQuota } from './providerRequests'; +import { + buildXaiBillingSummary, + fetchAntigravityQuota, + fetchClaudeQuota, + fetchCodexQuota, +} from './providerRequests'; const t = ((key: string) => key) as TFunction; beforeEach(() => { + mocks.getSubscription.mockReset(); + mocks.getSubscription.mockResolvedValue(null); mocks.request.mockReset(); }); @@ -134,9 +150,235 @@ describe('fetchCodexQuota', () => { expect(result.rateLimitResetCredits).toEqual([]); expect(result.rateLimitResetCreditsError).toBe('502 bad gateway'); }); + + it('uses localized reset credit errors for invalid detail payloads', async () => { + mocks.request + .mockResolvedValueOnce({ + statusCode: 200, + hasStatusCode: true, + header: {}, + bodyText: '', + body: { + plan_type: 'plus', + rate_limit_reset_credits: { + available_count: 1, + }, + }, + }) + .mockResolvedValueOnce({ + statusCode: 200, + hasStatusCode: true, + header: {}, + bodyText: '', + body: { + unexpected: true, + }, + }); + + const result = await fetchCodexQuota( + { + name: 'codex.json', + type: 'codex', + authIndex: 'auth-1', + }, + t + ); + + expect(result.rateLimitResetCreditsAvailableCount).toBe(1); + expect(result.rateLimitResetCredits).toEqual([]); + expect(result.rateLimitResetCreditsError).toBe('codex_quota.reset_credits_invalid_payload'); + }); +}); + +describe('fetchClaudeQuota', () => { + it('keeps usage quota data when profile lookup fails', async () => { + mocks.request + .mockResolvedValueOnce({ + statusCode: 200, + hasStatusCode: true, + header: {}, + bodyText: '', + body: { + five_hour: { + utilization: 12, + resets_at: '2026-07-01T10:00:00Z', + }, + }, + }) + .mockRejectedValueOnce(new Error('profile unavailable')); + + const result = await fetchClaudeQuota( + { + name: 'claude.json', + type: 'claude', + authIndex: 'claude-1', + }, + t + ); + + expect(result.planType).toBeNull(); + expect(result.windows).toHaveLength(1); + expect(result.windows[0]).toMatchObject({ + id: 'five-hour', + usedPercent: 12, + }); + }); +}); + +describe('buildXaiBillingSummary', () => { + it('normalizes cents fields from mixed object and snake-case payloads', () => { + const summary = buildXaiBillingSummary({ + monthly_limit: { val: '15000' }, + used: { val: 3750 }, + on_demand_cap: '2500', + billing_period_end: '2026-07-31T00:00:00Z', + }); + + expect(summary).toMatchObject({ + monthlyLimitCents: 15000, + usedCents: 3750, + includedUsedCents: 3750, + onDemandCapCents: 2500, + onDemandUsedCents: 0, + onDemandUsedPercent: 0, + billingPeriodEnd: '2026-07-31T00:00:00Z', + usedPercent: 25, + }); + }); + + it('splits included and pay-as-you-go usage after monthly credits are exhausted', () => { + const summary = buildXaiBillingSummary({ + monthly_limit: 10000, + used: 12500, + on_demand_cap: 5000, + }); + + expect(summary).toMatchObject({ + monthlyLimitCents: 10000, + usedCents: 12500, + includedUsedCents: 10000, + onDemandCapCents: 5000, + onDemandUsedCents: 2500, + usedPercent: 100, + onDemandUsedPercent: 50, + }); + }); }); describe('fetchAntigravityQuota', () => { + it('uses quota summary data and includes subscription plan data', async () => { + mocks.getSubscription.mockResolvedValue({ + plan: 'pro', + tierName: 'Antigravity Pro', + tierId: 'g1-pro-tier', + }); + mocks.request.mockResolvedValueOnce({ + statusCode: 200, + hasStatusCode: true, + header: {}, + bodyText: '', + body: { + groups: [ + { + displayName: 'Gemini models', + buckets: [ + { + bucketId: 'gemini-weekly', + displayName: 'Weekly limit', + window: 'weekly', + remainingFraction: 0.7, + resetTime: '2026-07-02T00:00:00Z', + }, + ], + }, + ], + }, + }); + + const result = await fetchAntigravityQuota( + { + name: 'antigravity.json', + type: 'antigravity', + authIndex: 'ag-1', + project_id: 'project-1', + }, + t + ); + + expect(mocks.request).toHaveBeenCalledTimes(1); + expect(mocks.request.mock.calls[0][0]).toMatchObject({ + url: ANTIGRAVITY_QUOTA_SUMMARY_URLS[0], + }); + expect(mocks.getSubscription).toHaveBeenCalledWith('ag-1'); + expect(result.subscription).toEqual({ + plan: 'pro', + tierName: 'Antigravity Pro', + tierId: 'g1-pro-tier', + }); + expect(result.groups[0]).toMatchObject({ + label: 'Gemini models', + buckets: [ + { + label: 'Weekly limit', + remainingFraction: 0.7, + }, + ], + }); + }); + + it('falls back to available models when summary endpoints have no usable data', async () => { + ANTIGRAVITY_QUOTA_SUMMARY_URLS.forEach(() => { + mocks.request.mockResolvedValueOnce({ + statusCode: 404, + hasStatusCode: true, + header: {}, + bodyText: 'not found', + body: null, + }); + }); + mocks.request.mockResolvedValueOnce({ + statusCode: 200, + hasStatusCode: true, + header: {}, + bodyText: '', + body: { + models: { + 'claude-sonnet-4-6': { + displayName: 'Claude Sonnet 4.6', + quotaInfo: { remainingFraction: 0.5 }, + apiProvider: 'API_PROVIDER_ANTHROPIC_VERTEX', + }, + 'gemini-3-pro-high': { + displayName: 'Gemini 3 Pro', + quotaInfo: { remainingFraction: 0.8 }, + apiProvider: 'API_PROVIDER_GOOGLE_GEMINI', + }, + }, + agentModelSorts: [ + { + groups: [{ modelIds: ['gemini-3-pro-high'] }], + }, + ], + }, + }); + + const result = await fetchAntigravityQuota( + { + name: 'antigravity.json', + type: 'antigravity', + authIndex: 'ag-1', + project_id: 'project-1', + }, + t + ); + + expect(mocks.request).toHaveBeenCalledTimes(ANTIGRAVITY_QUOTA_SUMMARY_URLS.length + 1); + expect(mocks.request.mock.calls[mocks.request.mock.calls.length - 1]?.[0]).toMatchObject({ + url: ANTIGRAVITY_AVAILABLE_MODELS_URLS[0], + }); + expect(result.groups.map((group) => group.id)).toEqual(['claude-gpt', 'gemini']); + }); + it('sends the generated Antigravity user agent', async () => { mocks.request.mockResolvedValue({ statusCode: 403, diff --git a/apps/web/src/utils/quota/providerRequests.ts b/apps/web/src/utils/quota/providerRequests.ts index 7582ff37d..8967b6ac4 100644 --- a/apps/web/src/utils/quota/providerRequests.ts +++ b/apps/web/src/utils/quota/providerRequests.ts @@ -1,6 +1,7 @@ import type { TFunction } from 'i18next'; import type { AntigravityQuotaGroup, + AntigravityQuotaSubscription, AntigravityQuotaSummaryPayload, AuthFileItem, ClaudeExtraUsage, @@ -15,15 +16,19 @@ import type { XaiBillingSummary, } from '@/types'; import { apiCallApi, getApiCallErrorMessage } from '@/services/api/apiCall'; +import { + antigravitySubscriptionApi, + type AntigravitySubscriptionSummary, +} from '@/services/api/antigravitySubscription'; import { authFilesApi } from '@/services/api/authFiles'; import { - ANTIGRAVITY_QUOTA_URLS, + ANTIGRAVITY_AVAILABLE_MODELS_URLS, + ANTIGRAVITY_QUOTA_SUMMARY_URLS, ANTIGRAVITY_REQUEST_HEADERS, CLAUDE_PROFILE_URL, CLAUDE_REQUEST_HEADERS, CLAUDE_USAGE_URL, CLAUDE_USAGE_WINDOW_KEYS, - CODEX_REQUEST_HEADERS, CODEX_RATE_LIMIT_RESET_CREDITS_URL, CODEX_USAGE_URL, KIMI_REQUEST_HEADERS, @@ -46,6 +51,10 @@ import { } from './parsers'; import { resolveCodexChatgptAccountId, resolveCodexPlanType } from './resolvers'; import { buildCodexQuotaWindowInfos } from './codexQuota'; +import { + buildCodexResetCreditsRequestHeaders, + buildCodexUsageRequestHeaders, +} from './codexRequestHeaders'; import { normalizeCodexResetCreditsPayload } from './resetCredits'; const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn'; @@ -68,9 +77,43 @@ export type ClaudeQuotaData = { export type AntigravityQuotaData = { groups: AntigravityQuotaGroup[]; + subscription?: AntigravityQuotaSubscription | null; serverTimeOffsetMs: number | null; }; +const antigravitySubscriptionRequests = new Map< + string, + Promise +>(); + +const toAntigravityQuotaSubscription = ( + summary: AntigravitySubscriptionSummary | null +): AntigravityQuotaSubscription | null => { + if (!summary) return null; + return { + plan: summary.plan, + tierName: summary.tierName, + tierId: summary.tierId, + }; +}; + +const fetchAntigravityQuotaSubscription = ( + authIndex: string +): Promise => { + const existing = antigravitySubscriptionRequests.get(authIndex); + if (existing) return existing; + + const request = antigravitySubscriptionApi + .get(authIndex) + .then(toAntigravityQuotaSubscription) + .catch(() => null) + .finally(() => { + antigravitySubscriptionRequests.delete(authIndex); + }); + antigravitySubscriptionRequests.set(authIndex, request); + return request; +}; + export const resolveAntigravityProjectId = async (file: AuthFileItem): Promise => { const directProjectId = normalizeStringValue(file.project_id ?? file.projectId); if (directProjectId) return directProjectId; @@ -150,13 +193,14 @@ export const fetchAntigravityQuota = async ( const projectId = await resolveAntigravityProjectId(file); const requestBody = JSON.stringify({ project: projectId }); + const subscriptionPromise = fetchAntigravityQuotaSubscription(authIndex); let lastError = ''; let lastStatus: number | undefined; let priorityStatus: number | undefined; let hadSuccess = false; - for (const url of ANTIGRAVITY_QUOTA_URLS) { + for (const url of [...ANTIGRAVITY_QUOTA_SUMMARY_URLS, ...ANTIGRAVITY_AVAILABLE_MODELS_URLS]) { try { const result = await apiCallApi.request({ authIndex, @@ -192,6 +236,7 @@ export const fetchAntigravityQuota = async ( return { groups, + subscription: await subscriptionPromise, serverTimeOffsetMs: resolveResponseServerTimeOffsetMs(result.header), }; } catch (err: unknown) { @@ -207,7 +252,11 @@ export const fetchAntigravityQuota = async ( } if (hadSuccess) { - return { groups: [], serverTimeOffsetMs: null }; + return { + groups: [], + subscription: await subscriptionPromise, + serverTimeOffsetMs: null, + }; } throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus); @@ -228,26 +277,6 @@ export const buildCodexQuotaWindows = ( limitWindowSeconds: window.limitWindowSeconds, })); -const buildCodexUsageRequestHeaders = (accountId?: string | null): Record => { - const headers: Record = { - ...CODEX_REQUEST_HEADERS, - }; - const trimmedAccountId = String(accountId ?? '').trim(); - if (trimmedAccountId) { - headers['Chatgpt-Account-Id'] = trimmedAccountId; - } - return headers; -}; - -const buildCodexResetCreditsRequestHeaders = ( - accountId?: string | null -): Record => ({ - ...buildCodexUsageRequestHeaders(accountId), - Accept: 'application/json', - 'OpenAI-Beta': 'codex-1', - Originator: 'Codex Desktop', -}); - const resolveCodexRateLimitResetCreditsAvailableCount = ( payload: CodexUsagePayload ): number | null => { @@ -275,7 +304,8 @@ const resolveCodexResetCreditsAvailableCount = ( const fetchCodexResetCredits = async ( authIndex: string, - accountId?: string | null + accountId: string | null | undefined, + t: TFunction ): Promise => { try { const result = await apiCallApi.request( @@ -301,7 +331,7 @@ const fetchCodexResetCredits = async ( return { availableCount: null, credits: [], - error: 'Invalid Codex reset credits payload', + error: t('codex_quota.reset_credits_invalid_payload'), }; } @@ -351,7 +381,7 @@ export const fetchCodexQuota = async ( const planType = planTypeFromUsage ?? planTypeFromFile; const windows = buildCodexQuotaWindows(payload, t, planType); const usageResetCreditsAvailableCount = resolveCodexRateLimitResetCreditsAvailableCount(payload); - const resetCredits = await fetchCodexResetCredits(authIndex, accountId); + const resetCredits = await fetchCodexResetCredits(authIndex, accountId, t); return { planType, windows, @@ -552,15 +582,32 @@ export const buildXaiBillingSummary = ( return null; } + const includedUsedCents = + usedCents === null + ? null + : monthlyLimitCents !== null && monthlyLimitCents > 0 + ? Math.min(usedCents, monthlyLimitCents) + : usedCents; + const onDemandUsedCents = + usedCents !== null && monthlyLimitCents !== null + ? Math.max(0, usedCents - monthlyLimitCents) + : null; const usedPercent = - monthlyLimitCents !== null && monthlyLimitCents > 0 && usedCents !== null - ? (usedCents / monthlyLimitCents) * 100 + monthlyLimitCents !== null && monthlyLimitCents > 0 && includedUsedCents !== null + ? (includedUsedCents / monthlyLimitCents) * 100 + : null; + const onDemandUsedPercent = + onDemandCapCents !== null && onDemandCapCents > 0 && onDemandUsedCents !== null + ? (onDemandUsedCents / onDemandCapCents) * 100 : null; return { monthlyLimitCents, usedCents, + includedUsedCents, onDemandCapCents, + onDemandUsedCents, + onDemandUsedPercent, billingPeriodStart, billingPeriodEnd, usedPercent, diff --git a/apps/web/src/utils/usageHeaderSnapshots.ts b/apps/web/src/utils/usageHeaderSnapshots.ts index 103299edb..e5262b6d1 100644 --- a/apps/web/src/utils/usageHeaderSnapshots.ts +++ b/apps/web/src/utils/usageHeaderSnapshots.ts @@ -307,10 +307,102 @@ export const getHeaderSnapshotRecoverAtMs = ( const value = snapshot?.header_quota_recover_at_ms ?? snapshot?.response_metadata?.quota?.recover_at_ms ?? + snapshot?.response_metadata?.errors?.retry_after_recover_at_ms ?? null; return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null; }; +const getHeaderQuotaWindowResetAtMs = ( + snapshot: UsageHeaderSnapshot | null | undefined, + window: ResponseHeaderQuotaWindow | null | undefined +): number | null => { + const resetAtMs = readNumber(window?.reset_at_ms); + if (resetAtMs !== null && resetAtMs > 0) return resetAtMs; + + const resetAfterSeconds = readNumber(window?.reset_after_seconds); + const timestampMs = readNumber(snapshot?.timestamp_ms); + if (resetAfterSeconds !== null && resetAfterSeconds > 0 && timestampMs !== null) { + return timestampMs + resetAfterSeconds * 1000; + } + + return null; +}; + +const getHeaderQuotaWindowSourcesByKind = ( + quota: ResponseHeaderQuotaMetadata | undefined, + windowKind: string | null +): Array<'primary' | 'secondary'> => { + if (!quota || !windowKind) return []; + return (['primary', 'secondary'] as const).filter( + (source) => + classifyHeaderQuotaWindowKind(getHeaderQuotaWindowBySource(quota, source)) === windowKind + ); +}; + +const isHeaderQuotaWindowDepleted = ( + window: ResponseHeaderQuotaWindow | null | undefined +): boolean => { + const usedPercent = readNumber(window?.used_percent); + return usedPercent !== null && usedPercent >= 100; +}; + +const getHeaderQuotaTargetResetAtMsCandidates = ( + snapshot: UsageHeaderSnapshot | null | undefined +): number[] => { + const candidates: number[] = []; + const addCandidate = (value: number | null) => { + if (value !== null && Number.isFinite(value)) candidates.push(value); + }; + + addCandidate(getHeaderSnapshotRecoverAtMs(snapshot)); + + const quota = getHeaderSnapshotQuotaMetadata(snapshot); + if (!quota) return candidates; + + const sourceCandidates: Array<'primary' | 'secondary'> = []; + const addSourceCandidate = (value: unknown) => { + const source = normalizeHeaderQuotaWindowSource(value); + if (source && !sourceCandidates.includes(source)) sourceCandidates.push(source); + }; + + addSourceCandidate(quota.reached_window_source); + addSourceCandidate(quota.rate_limit_reached_type); + + const reachedWindowKind = getHeaderSnapshotReachedWindowKind(snapshot); + if (sourceCandidates.length === 0) { + getHeaderQuotaWindowSourcesByKind(quota, reachedWindowKind).forEach(addSourceCandidate); + } + + let targetedWindowCount = 0; + sourceCandidates.forEach((source) => { + targetedWindowCount += 1; + addCandidate( + getHeaderQuotaWindowResetAtMs(snapshot, getHeaderQuotaWindowBySource(quota, source)) + ); + }); + + if (targetedWindowCount > 0) return candidates; + + (['primary', 'secondary'] as const).forEach((source) => { + const window = getHeaderQuotaWindowBySource(quota, source); + if (isHeaderQuotaWindowDepleted(window)) { + addCandidate(getHeaderQuotaWindowResetAtMs(snapshot, window)); + } + }); + + return candidates; +}; + +export const isUsageHeaderQuotaSnapshotExpired = ( + snapshot: UsageHeaderSnapshot | null | undefined, + nowMs = Date.now() +): boolean => { + if (!hasUsageHeaderQuotaSignal(snapshot)) return false; + const candidates = getHeaderQuotaTargetResetAtMsCandidates(snapshot); + if (candidates.length === 0) return false; + return Math.max(...candidates) <= nowMs; +}; + export const getHeaderSnapshotErrorKind = ( snapshot: UsageHeaderSnapshot | null | undefined ): string => diff --git a/bin/native/cpa-manager-plusctl.ps1 b/bin/native/cpa-manager-plusctl.ps1 new file mode 100644 index 000000000..dd145bda6 --- /dev/null +++ b/bin/native/cpa-manager-plusctl.ps1 @@ -0,0 +1,688 @@ +$ErrorActionPreference = 'Stop' + +$AppName = 'cpa-manager-plus' +$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } +$DefaultRunDir = Join-Path $ScriptDir 'run' +$DefaultLogDir = Join-Path $ScriptDir 'logs' +$RunDirIsDefault = -not $env:CPA_MANAGER_PLUS_RUN_DIR +$LogDirIsDefault = -not $env:CPA_MANAGER_PLUS_LOG_DIR +$Binary = if ($env:CPA_MANAGER_PLUS_BIN) { $env:CPA_MANAGER_PLUS_BIN } else { Join-Path $ScriptDir "$AppName.exe" } +$RunDir = if ($env:CPA_MANAGER_PLUS_RUN_DIR) { $env:CPA_MANAGER_PLUS_RUN_DIR } else { $DefaultRunDir } +$LogDir = if ($env:CPA_MANAGER_PLUS_LOG_DIR) { $env:CPA_MANAGER_PLUS_LOG_DIR } else { $DefaultLogDir } +$PidFile = if ($env:CPA_MANAGER_PLUS_PID_FILE) { $env:CPA_MANAGER_PLUS_PID_FILE } else { Join-Path $RunDir "$AppName.pid" } +$LogFile = if ($env:CPA_MANAGER_PLUS_LOG_FILE) { $env:CPA_MANAGER_PLUS_LOG_FILE } else { Join-Path $LogDir "$AppName.log" } +$ErrLogFile = if ($env:CPA_MANAGER_PLUS_ERR_LOG_FILE) { $env:CPA_MANAGER_PLUS_ERR_LOG_FILE } else { Join-Path $LogDir "$AppName.err.log" } +$CurrentUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + +function Show-Usage { + Write-Host @" +Usage: .\cpa-manager-plusctl.ps1 [args...] + +Commands: + start [args...] Start cpa-manager-plus in the background + stop Stop the background process + restart Restart the background process + status Show process status + logs [lines|-f|--follow] + Print recent logs, or follow with -f/--follow + +Environment overrides: + CPA_MANAGER_PLUS_BIN Binary path + CPA_MANAGER_PLUS_RUN_DIR Runtime directory, default: .\run + CPA_MANAGER_PLUS_LOG_DIR Log directory, default: .\logs + CPA_MANAGER_PLUS_PID_FILE PID file path + CPA_MANAGER_PLUS_LOG_FILE stdout log file path + CPA_MANAGER_PLUS_ERR_LOG_FILE stderr log file path + +Note: + Prefer environment variables for runtime configuration. Windows argument + forwarding follows Start-Process parsing and may be shell-dependent for + complex values with spaces or quotes. +"@ +} + +function Resolve-NormalizedPath { + param([string]$Path) + + try { + return [System.IO.Path]::GetFullPath($Path) + } catch { + return $Path + } +} + +function Get-WellKnownSidValue { + param([System.Security.Principal.WellKnownSidType]$Type) + + return (New-Object System.Security.Principal.SecurityIdentifier -ArgumentList $Type, $null).Value +} + +function Get-IdentitySidValue { + param([System.Security.Principal.IdentityReference]$IdentityReference) + + if ($IdentityReference -is [System.Security.Principal.SecurityIdentifier]) { + return $IdentityReference.Value + } + + try { + return $IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value + } catch { + return $null + } +} + +function Test-ReparsePoint { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return $false + } + + $item = Get-Item -LiteralPath $Path -Force + return [bool]($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) +} + +function Test-UnsafeWritableDirectoryAcl { + param([string]$Path) + + $unsafeSidValues = @( + Get-WellKnownSidValue -Type ([System.Security.Principal.WellKnownSidType]::WorldSid) + Get-WellKnownSidValue -Type ([System.Security.Principal.WellKnownSidType]::AuthenticatedUserSid) + Get-WellKnownSidValue -Type ([System.Security.Principal.WellKnownSidType]::BuiltinUsersSid) + ) + $unsafeRights = [System.Security.AccessControl.FileSystemRights]'Write, WriteData, CreateFiles, AppendData, CreateDirectories, Delete, DeleteSubdirectoriesAndFiles, Modify, FullControl, ChangePermissions, TakeOwnership' + $acl = [System.IO.Directory]::GetAccessControl($Path) + + foreach ($rule in $acl.Access) { + if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { + continue + } + + $sidValue = Get-IdentitySidValue -IdentityReference $rule.IdentityReference + if (-not $sidValue -or $unsafeSidValues -notcontains $sidValue) { + continue + } + + if (($rule.FileSystemRights -band $unsafeRights) -ne 0) { + return $true + } + } + + return $false +} + +function Assert-SafeRuntimeDirectory { + param( + [string]$Path, + [switch]$ManageExisting + ) + + if (Test-ReparsePoint -Path $Path) { + throw "Refusing to use reparse-point runtime directory: $Path" + } + + $item = Get-Item -LiteralPath $Path -Force + if (-not $item.PSIsContainer) { + throw "Refusing to use non-directory runtime path: $Path" + } + + if (-not $ManageExisting -and (Test-UnsafeWritableDirectoryAcl -Path $Path)) { + throw "Refusing to use unsafe runtime directory: $Path must not be writable by Everyone, Authenticated Users, or Users." + } +} + +function Assert-SafeRuntimeFileTarget { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return + } + + if (Test-ReparsePoint -Path $Path) { + throw "Refusing to use reparse-point runtime file: $Path" + } + + $item = Get-Item -LiteralPath $Path -Force + if ($item.PSIsContainer) { + throw "Refusing to use directory as runtime file: $Path" + } +} + +function Assert-SafeRuntimeFileParent { + param([string]$Path) + + $parent = Split-Path -Parent $Path + if (-not $parent -or -not (Test-Path -LiteralPath $parent)) { + return + } + + $manageParent = Should-ManageExistingDirectory -Path $parent + Assert-SafeRuntimeDirectory -Path $parent -ManageExisting:$manageParent +} + +function Set-PrivateAcl { + param( + [string]$Path, + [switch]$Directory + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return + } + + $inheritFlags = if ($Directory) { + [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + } else { + [System.Security.AccessControl.InheritanceFlags]::None + } + + $acl = if ($Directory) { + New-Object System.Security.AccessControl.DirectorySecurity + } else { + New-Object System.Security.AccessControl.FileSecurity + } + + $acl.SetOwner($CurrentUserSid) + $acl.SetAccessRuleProtection($true, $false) + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $CurrentUserSid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritFlags, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) + if ($Directory) { + [System.IO.Directory]::SetAccessControl($Path, $acl) + } else { + [System.IO.File]::SetAccessControl($Path, $acl) + } +} + +function Ensure-PrivateDirectory { + param( + [string]$Path, + [switch]$ManageExisting + ) + + if (-not $Path) { + return + } + + if (Test-Path -LiteralPath $Path) { + Assert-SafeRuntimeDirectory -Path $Path -ManageExisting:$ManageExisting + if ($ManageExisting) { + Set-PrivateAcl -Path $Path -Directory + } + return + } + + New-Item -ItemType Directory -Force -Path $Path | Out-Null + Set-PrivateAcl -Path $Path -Directory +} + +function Should-ManageExistingDirectory { + param([string]$Path) + + if (-not $Path) { + return $false + } + + $normalizedPath = Resolve-NormalizedPath $Path + if ($RunDirIsDefault -and $normalizedPath -ieq (Resolve-NormalizedPath $RunDir)) { + return $true + } + if ($LogDirIsDefault -and $normalizedPath -ieq (Resolve-NormalizedPath $LogDir)) { + return $true + } + + return $false +} + +function Prepare-PrivateFile { + param([string]$Path) + + Assert-SafeRuntimeFileTarget -Path $Path + $parent = Split-Path -Parent $Path + if ($parent) { + $manageParent = Should-ManageExistingDirectory -Path $parent + Ensure-PrivateDirectory -Path $parent -ManageExisting:$manageParent + } + Assert-SafeRuntimeFileTarget -Path $Path + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -ItemType File -Force -Path $Path | Out-Null + } + + Set-PrivateAcl -Path $Path +} + +function Get-ProcessSnapshot { + param([int]$ProcessId) + + try { + $process = Get-Process -Id $ProcessId -ErrorAction Stop + } catch { + return $null + } + + $startTimeUtc = $null + try { + $startTimeUtc = $process.StartTime.ToUniversalTime().ToString('o') + } catch { + } + + $cimProcess = $null + try { + $cimProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction Stop + } catch { + } + + $binaryPath = $null + if ($cimProcess -and $cimProcess.ExecutablePath) { + $binaryPath = Resolve-NormalizedPath $cimProcess.ExecutablePath + } else { + try { + $binaryPath = Resolve-NormalizedPath $process.MainModule.FileName + } catch { + } + } + + $commandLine = $null + if ($cimProcess -and $cimProcess.CommandLine) { + $commandLine = $cimProcess.CommandLine.Trim() + } + + [pscustomobject]@{ + Pid = $process.Id + StartTimeUtc = $startTimeUtc + BinaryPath = $binaryPath + CommandLine = $commandLine + Process = $process + } +} + +function Read-PidRecord { + if (-not (Test-Path -LiteralPath $PidFile)) { + return $null + } + + Assert-SafeRuntimeFileTarget -Path $PidFile + Assert-SafeRuntimeFileParent -Path $PidFile + + $raw = (Get-Content -LiteralPath $PidFile -Raw).Trim() + if (-not $raw) { + return [pscustomobject]@{ Format = 'invalid' } + } + + if ($raw -match '^\d+$') { + return [pscustomobject]@{ + Format = 'legacy' + Pid = [int]$raw + } + } + + try { + $record = $raw | ConvertFrom-Json -ErrorAction Stop + } catch { + return [pscustomobject]@{ Format = 'invalid' } + } + + $pidValue = 0 + if (-not [int]::TryParse([string]$record.pid, [ref]$pidValue)) { + return [pscustomobject]@{ Format = 'invalid' } + } + + [pscustomobject]@{ + Format = 'metadata' + Pid = $pidValue + StartTimeUtc = [string]$record.startTimeUtc + BinaryPath = [string]$record.binaryPath + CommandLine = [string]$record.commandLine + } +} + +function Get-PidRecordState { + if (-not (Test-Path -LiteralPath $PidFile)) { + return [pscustomobject]@{ State = 'missing' } + } + + $record = Read-PidRecord + if (-not $record -or $record.Format -eq 'invalid') { + return [pscustomobject]@{ State = 'invalid'; Record = $record } + } + + $snapshot = Get-ProcessSnapshot -ProcessId $record.Pid + if (-not $snapshot) { + return [pscustomobject]@{ State = 'stale'; Record = $record } + } + + if ($record.Format -ne 'metadata' -or -not $record.StartTimeUtc) { + return [pscustomobject]@{ State = 'conflict'; Record = $record; Snapshot = $snapshot } + } + + if (-not $snapshot.StartTimeUtc -or $snapshot.StartTimeUtc -ne $record.StartTimeUtc) { + return [pscustomobject]@{ State = 'conflict'; Record = $record; Snapshot = $snapshot } + } + + if ($record.BinaryPath -and $snapshot.BinaryPath) { + if ((Resolve-NormalizedPath $snapshot.BinaryPath) -ieq (Resolve-NormalizedPath $record.BinaryPath)) { + return [pscustomobject]@{ State = 'active'; Record = $record; Snapshot = $snapshot } + } + + return [pscustomobject]@{ State = 'conflict'; Record = $record; Snapshot = $snapshot } + } + + if ($record.CommandLine -and $snapshot.CommandLine -and $record.CommandLine -eq $snapshot.CommandLine) { + return [pscustomobject]@{ State = 'active'; Record = $record; Snapshot = $snapshot } + } + + return [pscustomobject]@{ State = 'conflict'; Record = $record; Snapshot = $snapshot } +} + +function Write-PidRecord { + param([int]$ProcessId) + + $snapshot = Get-ProcessSnapshot -ProcessId $ProcessId + if (-not $snapshot -or -not $snapshot.StartTimeUtc -or (-not $snapshot.BinaryPath -and -not $snapshot.CommandLine)) { + return $false + } + + $tmpFile = "${PidFile}.tmp.$PID" + $record = [pscustomobject]@{ + pid = $snapshot.Pid + startTimeUtc = $snapshot.StartTimeUtc + binaryPath = $snapshot.BinaryPath + commandLine = $snapshot.CommandLine + } + + Prepare-PrivateFile -Path $tmpFile + $record | ConvertTo-Json -Compress | Set-Content -LiteralPath $tmpFile + Set-PrivateAcl -Path $tmpFile + Move-Item -LiteralPath $tmpFile -Destination $PidFile -Force + Set-PrivateAcl -Path $PidFile + return $true +} + +function Get-CurrentPowerShellPath { + try { + $currentProcess = Get-Process -Id $PID -ErrorAction Stop + if ($currentProcess.Path) { + return $currentProcess.Path + } + } catch { + } + + $candidate = Join-Path $PSHOME 'powershell.exe' + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + + return 'powershell.exe' +} + +function Start-DetachedProcess { + param([string[]]$AppArgs) + + $launchBase = "${PidFile}.launch.$PID" + $configFile = "${launchBase}.json" + $resultFile = "${launchBase}.result.json" + $launcherScript = "${launchBase}.ps1" + $powerShellPath = Get-CurrentPowerShellPath + + $launcher = @' +$ErrorActionPreference = 'Stop' + +try { + $config = Get-Content -LiteralPath $args[0] -Raw | ConvertFrom-Json -ErrorAction Stop + $arguments = @() + if ($null -ne $config.arguments) { + if ($config.arguments -is [array]) { + $arguments = @($config.arguments) + } else { + $arguments = @($config.arguments) + } + } + + $startInfo = @{ + FilePath = [string]$config.binary + WorkingDirectory = [string]$config.workingDirectory + RedirectStandardOutput = [string]$config.logFile + RedirectStandardError = [string]$config.errLogFile + WindowStyle = 'Hidden' + PassThru = $true + } + if ($arguments.Count -gt 0) { + $startInfo.ArgumentList = [string[]]$arguments + } + + $process = Start-Process @startInfo + [pscustomobject]@{ pid = $process.Id } | + ConvertTo-Json -Compress | + Set-Content -LiteralPath ([string]$config.resultFile) +} catch { + [pscustomobject]@{ error = $_.Exception.Message } | + ConvertTo-Json -Compress | + Set-Content -LiteralPath ([string]$config.resultFile) + exit 1 +} +'@ + + $config = [pscustomobject]@{ + binary = $Binary + workingDirectory = $ScriptDir + arguments = @($AppArgs) + logFile = $LogFile + errLogFile = $ErrLogFile + resultFile = $resultFile + } + + Prepare-PrivateFile -Path $configFile + Prepare-PrivateFile -Path $launcherScript + $config | ConvertTo-Json -Compress | Set-Content -LiteralPath $configFile + Set-PrivateAcl -Path $configFile + Set-Content -LiteralPath $launcherScript -Value $launcher + Set-PrivateAcl -Path $launcherScript + + $launcherProcess = Start-Process -FilePath $powerShellPath -ArgumentList @( + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + $launcherScript, + $configFile + ) -WindowStyle Hidden -PassThru + + $deadline = (Get-Date).AddSeconds(10) + while (-not (Test-Path -LiteralPath $resultFile)) { + if ($launcherProcess.HasExited -and -not (Test-Path -LiteralPath $resultFile)) { + throw "$AppName failed to launch. Launcher exited with code $($launcherProcess.ExitCode)." + } + + if ((Get-Date) -ge $deadline) { + Stop-Process -Id $launcherProcess.Id -ErrorAction SilentlyContinue + throw "$AppName failed to launch within 10 seconds." + } + + Start-Sleep -Milliseconds 100 + } + + $result = Get-Content -LiteralPath $resultFile -Raw | ConvertFrom-Json -ErrorAction Stop + if ($result.error) { + throw "$AppName failed to launch: $($result.error)" + } + + $processId = 0 + if (-not [int]::TryParse([string]$result.pid, [ref]$processId)) { + throw "$AppName failed to launch: launcher did not return a valid PID." + } + + Remove-Item -LiteralPath $configFile, $resultFile, $launcherScript -Force -ErrorAction SilentlyContinue + return $processId +} + +function Prepare-RuntimePaths { + Ensure-PrivateDirectory -Path $RunDir -ManageExisting:(Should-ManageExistingDirectory -Path $RunDir) + Ensure-PrivateDirectory -Path $LogDir -ManageExisting:(Should-ManageExistingDirectory -Path $LogDir) + Prepare-PrivateFile -Path $LogFile + Prepare-PrivateFile -Path $ErrLogFile +} + +function Start-App { + param([string[]]$AppArgs) + + if (-not (Test-Path -LiteralPath $Binary)) { + throw "Binary does not exist: $Binary" + } + + $state = Get-PidRecordState + switch ($state.State) { + 'active' { + Write-Host "$AppName is already running with PID $($state.Record.Pid)" + return + } + 'stale' { + Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue + } + 'invalid' { + Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue + } + 'conflict' { + throw "Refusing to start: $PidFile points to a running process that could not be strongly verified." + } + } + + Prepare-RuntimePaths + Prepare-PrivateFile -Path $PidFile + Clear-Content -LiteralPath $PidFile -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue + + $processId = Start-DetachedProcess -AppArgs $AppArgs + Start-Sleep -Seconds 1 + + if ((Write-PidRecord -ProcessId $processId) -and (Get-PidRecordState).State -eq 'active') { + Write-Host "$AppName started with PID $processId" + Write-Host "Log: $LogFile" + Write-Host "Error log: $ErrLogFile" + return + } + + Stop-Process -Id $processId -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue + Write-Error "$AppName failed to start. Check logs: $LogFile and $ErrLogFile" +} + +function Stop-App { + $state = Get-PidRecordState + switch ($state.State) { + 'missing' { + Write-Host "$AppName is not running" + return + } + 'stale' { + Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue + Write-Host "Removed stale PID file for $AppName" + return + } + 'invalid' { + Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue + Write-Host "Removed stale PID file for $AppName" + return + } + 'conflict' { + throw "Refusing to stop: $PidFile points to a running process that could not be strongly verified." + } + } + + Stop-Process -Id $state.Snapshot.Pid + for ($i = 0; $i -lt 10; $i++) { + Start-Sleep -Seconds 1 + if ((Get-PidRecordState).State -ne 'active') { + Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue + Write-Host "$AppName stopped" + return + } + } + + throw "$AppName did not stop within 10 seconds. PID: $($state.Snapshot.Pid)" +} + +function Show-Status { + $state = Get-PidRecordState + switch ($state.State) { + 'active' { + Write-Host "$AppName is running with PID $($state.Record.Pid)" + Write-Host "PID file: $PidFile" + Write-Host "Log: $LogFile" + return + } + 'missing' { + Write-Host "$AppName is not running" + exit 1 + } + 'stale' { + Write-Host "$AppName is not running; stale PID file: $PidFile" + exit 1 + } + 'invalid' { + Write-Host "$AppName is not running; stale PID file: $PidFile" + exit 1 + } + 'conflict' { + Write-Host "$AppName status is unknown; $PidFile points to a running process that could not be strongly verified." + exit 1 + } + } +} + +function Show-Logs { + param([string[]]$Options = @()) + + if ($Options.Count -gt 1) { + throw "Usage: .\cpa-manager-plusctl.ps1 logs [lines|-f|--follow]" + } + + $option = if ($Options.Count -eq 1) { $Options[0] } else { $null } + $lineCount = 80 + $follow = $false + + if ($option -eq '-f' -or $option -eq '--follow') { + $follow = $true + } elseif ($option) { + $parsedLineCount = 0 + if (-not [int]::TryParse($option, [ref]$parsedLineCount) -or $parsedLineCount -lt 1) { + throw "Invalid log line count: $option" + } + $lineCount = $parsedLineCount + } + + if (-not (Test-Path -LiteralPath $LogFile) -and -not (Test-Path -LiteralPath $ErrLogFile)) { + throw "Log files do not exist yet: $LogFile and $ErrLogFile" + } + + if ($follow) { + Get-Content -LiteralPath $LogFile, $ErrLogFile -Tail 80 -Wait -ErrorAction SilentlyContinue + return + } + + Get-Content -LiteralPath $LogFile, $ErrLogFile -Tail $lineCount -ErrorAction SilentlyContinue +} + +$Command = if ($args.Count -gt 0) { $args[0] } else { 'status' } +$AppArgs = if ($args.Count -gt 1) { $args[1..($args.Count - 1)] } else { @() } + +switch ($Command) { + 'start' { Start-App -AppArgs $AppArgs } + 'stop' { Stop-App } + 'restart' { + Stop-App + Start-App -AppArgs $AppArgs + } + 'status' { Show-Status } + 'logs' { Show-Logs -Options $AppArgs } + { $_ -in @('help', '-h', '--help') } { Show-Usage } + default { + Show-Usage + exit 1 + } +} diff --git a/bin/native/cpa-manager-plusctl.sh b/bin/native/cpa-manager-plusctl.sh new file mode 100755 index 000000000..5d36c318d --- /dev/null +++ b/bin/native/cpa-manager-plusctl.sh @@ -0,0 +1,579 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +app_name="cpa-manager-plus" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +default_run_dir="${script_dir}/run" +default_log_dir="${script_dir}/logs" +binary="${CPA_MANAGER_PLUS_BIN:-"${script_dir}/${app_name}"}" +run_dir="${CPA_MANAGER_PLUS_RUN_DIR:-"${default_run_dir}"}" +log_dir="${CPA_MANAGER_PLUS_LOG_DIR:-"${default_log_dir}"}" +pid_file="${CPA_MANAGER_PLUS_PID_FILE:-"${run_dir}/${app_name}.pid"}" +log_file="${CPA_MANAGER_PLUS_LOG_FILE:-"${log_dir}/${app_name}.log"}" +manage_run_dir="false" +manage_log_dir="false" + +if [ -z "${CPA_MANAGER_PLUS_RUN_DIR:-}" ]; then + manage_run_dir="true" +fi +if [ -z "${CPA_MANAGER_PLUS_LOG_DIR:-}" ]; then + manage_log_dir="true" +fi + +record_format="" +record_pid="" +record_start="" +record_binary="" +record_command="" +record_state="" + +usage() { + cat < [args...] + +Commands: + start [args...] Start cpa-manager-plus in the background + stop Stop the background process + restart Restart the background process + status Show process status + logs [lines|-f|--follow] + Print recent logs, or follow with -f/--follow + +Environment overrides: + CPA_MANAGER_PLUS_BIN Binary path + CPA_MANAGER_PLUS_RUN_DIR Runtime directory, default: ./run + CPA_MANAGER_PLUS_LOG_DIR Log directory, default: ./logs + CPA_MANAGER_PLUS_PID_FILE PID file path + CPA_MANAGER_PLUS_LOG_FILE Log file path +EOF +} + +reset_record() { + record_format="" + record_pid="" + record_start="" + record_binary="" + record_command="" + record_state="" +} + +read_pid_record() { + reset_record + + if [ ! -f "${pid_file}" ]; then + return 1 + fi + + reject_unsafe_runtime_file "${pid_file}" + reject_unsafe_runtime_file_parent "${pid_file}" + + local line saw_metadata=0 + while IFS= read -r line || [ -n "${line}" ]; do + case "${line}" in + pid=*) + record_pid="${line#pid=}" + saw_metadata=1 + ;; + start=*) + record_start="${line#start=}" + saw_metadata=1 + ;; + binary=*) + record_binary="${line#binary=}" + saw_metadata=1 + ;; + command=*) + record_command="${line#command=}" + saw_metadata=1 + ;; + '') + ;; + *) + if [ "${saw_metadata}" -eq 0 ] && [[ "${line}" =~ ^[0-9]+$ ]]; then + record_pid="${line}" + record_format="legacy" + fi + ;; + esac + done <"${pid_file}" + + if ! [[ "${record_pid}" =~ ^[0-9]+$ ]]; then + return 1 + fi + + if [ "${saw_metadata}" -eq 1 ]; then + record_format="metadata" + fi + + return 0 +} + +is_pid_running() { + local pid="$1" + kill -0 "${pid}" >/dev/null 2>&1 +} + +resolve_path() { + local path="$1" + if command -v realpath >/dev/null 2>&1; then + realpath "${path}" 2>/dev/null || printf '%s\n' "${path}" + return + fi + if command -v readlink >/dev/null 2>&1; then + readlink -f "${path}" 2>/dev/null || printf '%s\n' "${path}" + return + fi + + if [ -d "${path}" ]; then + ( + cd "${path}" 2>/dev/null && pwd -P + ) || printf '%s\n' "${path}" + return + fi + + ( + cd "$(dirname "${path}")" 2>/dev/null && printf '%s/%s\n' "$(pwd -P)" "$(basename "${path}")" + ) || printf '%s\n' "${path}" +} + +process_start_marker() { + local pid="$1" + + if [ -r "/proc/${pid}/stat" ]; then + awk '{print $22}' "/proc/${pid}/stat" 2>/dev/null + return + fi + + ps -ww -p "${pid}" -o lstart= 2>/dev/null | awk '{$1=$1;print}' +} + +process_executable_path() { + local pid="$1" + local resolved_process + if [ -L "/proc/${pid}/exe" ]; then + resolved_process="$(resolve_path "/proc/${pid}/exe")" + [ -n "${resolved_process}" ] && printf '%s\n' "${resolved_process}" + return + fi + + if command -v lsof >/dev/null 2>&1; then + lsof -a -p "${pid}" -d txt -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 + return + fi +} + +process_command_line() { + local pid="$1" + ps -ww -p "${pid}" -o command= 2>/dev/null | sed 's/^[[:space:]]*//' +} + +set_pid_record_state() { + local current_binary current_command current_start + + if [ ! -f "${pid_file}" ]; then + record_state="missing" + return + fi + + if ! read_pid_record; then + record_state="invalid" + return + fi + + if ! is_pid_running "${record_pid}"; then + record_state="stale" + return + fi + + if [ "${record_format}" != "metadata" ] || [ -z "${record_start}" ]; then + record_state="conflict" + return + fi + + current_start="$(process_start_marker "${record_pid}")" + if [ -z "${current_start}" ] || [ "${current_start}" != "${record_start}" ]; then + record_state="conflict" + return + fi + + current_binary="$(process_executable_path "${record_pid}" || true)" + if [ -n "${current_binary}" ] && [ -n "${record_binary}" ]; then + if [ "${current_binary}" = "${record_binary}" ]; then + record_state="active" + return + fi + + record_state="conflict" + return + fi + + current_command="$(process_command_line "${record_pid}")" + if [ -n "${current_command}" ] && [ -n "${record_command}" ] && [ "${current_command}" = "${record_command}" ]; then + record_state="active" + return + fi + + record_state="conflict" +} + +running_pid() { + set_pid_record_state + if [ "${record_state}" = "active" ]; then + printf '%s\n' "${record_pid}" + return 0 + fi + + return 1 +} + +recorded_instance_running() { + local current_start + + if ! read_pid_record; then + return 1 + fi + + if [ "${record_format}" != "metadata" ] || [ -z "${record_start}" ]; then + return 1 + fi + + if ! is_pid_running "${record_pid}"; then + return 1 + fi + + current_start="$(process_start_marker "${record_pid}")" + [ -n "${current_start}" ] && [ "${current_start}" = "${record_start}" ] +} + +ensure_private_dir() { + local dir="$1" + local manage_existing="${2:-false}" + + if [ -z "${dir}" ]; then + return 0 + fi + + if [ "${dir}" = "." ]; then + dir="$(pwd -P)" + fi + + if [ -d "${dir}" ]; then + if [ -L "${dir}" ]; then + echo "Refusing to use symlinked runtime directory: ${dir}" >&2 + exit 1 + fi + + if [ "${manage_existing}" = "true" ]; then + chmod 700 "${dir}" + else + reject_unsafe_custom_dir "${dir}" + fi + return 0 + fi + + (umask 077 && mkdir -p "${dir}") + chmod 700 "${dir}" +} + +path_mode() { + local path="$1" + + if stat -c '%a' "${path}" >/dev/null 2>&1; then + stat -c '%a' "${path}" + return + fi + + stat -f '%Lp' "${path}" +} + +is_group_or_world_writable_mode() { + local mode="$1" + local perms group other + + perms="${mode}" + while [ "${#perms}" -lt 3 ]; do + perms="0${perms}" + done + perms="${perms: -3}" + group="${perms:1:1}" + other="${perms:2:1}" + + [ $((10#${group} & 2)) -ne 0 ] || [ $((10#${other} & 2)) -ne 0 ] +} + +reject_unsafe_custom_dir() { + local dir="$1" + local mode + + mode="$(path_mode "${dir}")" + if is_group_or_world_writable_mode "${mode}"; then + echo "Refusing to use unsafe runtime directory: ${dir} must not be group- or world-writable." >&2 + exit 1 + fi +} + +reject_unsafe_runtime_file() { + local file="$1" + + if [ -L "${file}" ]; then + echo "Refusing to use symlinked runtime file: ${file}" >&2 + exit 1 + fi + + if [ -e "${file}" ] && [ ! -f "${file}" ]; then + echo "Refusing to use non-regular runtime file: ${file}" >&2 + exit 1 + fi +} + +is_managed_runtime_dir() { + local dir="$1" + + { [ "${dir}" = "${run_dir}" ] && [ "${manage_run_dir}" = "true" ]; } || + { [ "${dir}" = "${log_dir}" ] && [ "${manage_log_dir}" = "true" ]; } +} + +reject_unsafe_runtime_file_parent() { + local file="$1" + local parent_dir + + parent_dir="$(dirname "${file}")" + if [ -z "${parent_dir}" ] || [ ! -e "${parent_dir}" ]; then + return 0 + fi + + if [ -L "${parent_dir}" ]; then + echo "Refusing to use symlinked runtime directory: ${parent_dir}" >&2 + exit 1 + fi + + if [ ! -d "${parent_dir}" ]; then + echo "Refusing to use non-directory runtime path: ${parent_dir}" >&2 + exit 1 + fi + + if ! is_managed_runtime_dir "${parent_dir}"; then + reject_unsafe_custom_dir "${parent_dir}" + fi +} + +prepare_private_file() { + local file="$1" + local parent_dir + + reject_unsafe_runtime_file "${file}" + parent_dir="$(dirname "${file}")" + if [ "${parent_dir}" = "${run_dir}" ]; then + ensure_private_dir "${parent_dir}" "${manage_run_dir}" + elif [ "${parent_dir}" = "${log_dir}" ]; then + ensure_private_dir "${parent_dir}" "${manage_log_dir}" + else + ensure_private_dir "${parent_dir}" "false" + fi + + (umask 077 && touch "${file}") + chmod 600 "${file}" +} + +prepare_runtime_paths() { + ensure_private_dir "${run_dir}" "${manage_run_dir}" + ensure_private_dir "${log_dir}" "${manage_log_dir}" + prepare_private_file "${log_file}" +} + +write_pid_record() { + local pid="$1" + local current_binary current_command current_start tmp_file + + current_start="$(process_start_marker "${pid}")" + current_binary="$(process_executable_path "${pid}" || true)" + current_command="$(process_command_line "${pid}")" + + if [ -z "${current_start}" ] || { [ -z "${current_binary}" ] && [ -z "${current_command}" ]; }; then + return 1 + fi + + tmp_file="${pid_file}.tmp.$$" + prepare_private_file "${tmp_file}" + { + printf 'pid=%s\n' "${pid}" + printf 'start=%s\n' "${current_start}" + printf 'binary=%s\n' "${current_binary}" + printf 'command=%s\n' "${current_command}" + } >"${tmp_file}" + chmod 600 "${tmp_file}" + mv -f "${tmp_file}" "${pid_file}" + chmod 600 "${pid_file}" +} + +start_app() { + local launch_binary="${binary}" + if [[ "${launch_binary}" != /* ]]; then + launch_binary="$(resolve_path "${launch_binary}")" + fi + + if [ ! -x "${launch_binary}" ]; then + echo "Binary is not executable: ${binary}" >&2 + exit 1 + fi + + set_pid_record_state + case "${record_state}" in + active) + echo "${app_name} is already running with PID ${record_pid}" + return 0 + ;; + missing) + ;; + stale | invalid) + rm -f "${pid_file}" + ;; + conflict) + echo "Refusing to start: ${pid_file} points to a running process that could not be strongly verified." >&2 + exit 1 + ;; + esac + + prepare_runtime_paths + if [ "$(dirname "${pid_file}")" = "${run_dir}" ]; then + ensure_private_dir "${run_dir}" "${manage_run_dir}" + else + ensure_private_dir "$(dirname "${pid_file}")" "false" + fi + + rm -f "${pid_file}" + + local pid + (cd "${script_dir}" && exec nohup "${launch_binary}" "$@") >>"${log_file}" 2>&1 & + pid="$!" + + sleep 1 + if write_pid_record "${pid}" && pid="$(running_pid)"; then + echo "${app_name} started with PID ${pid}" + echo "Log: ${log_file}" + return 0 + fi + + kill "${pid}" >/dev/null 2>&1 || true + rm -f "${pid_file}" + echo "${app_name} failed to start. Recent log output:" >&2 + if [ -f "${log_file}" ]; then + tail -n 40 "${log_file}" >&2 + fi + exit 1 +} + +stop_app() { + set_pid_record_state + case "${record_state}" in + missing) + echo "${app_name} is not running" + return 0 + ;; + stale | invalid) + rm -f "${pid_file}" + echo "Removed stale PID file for ${app_name}" + return 0 + ;; + conflict) + echo "Refusing to stop: ${pid_file} points to a running process that could not be strongly verified." >&2 + exit 1 + ;; + esac + + local pid="${record_pid}" + + kill "${pid}" + for _ in 1 2 3 4 5 6 7 8 9 10; do + if ! recorded_instance_running; then + rm -f "${pid_file}" + echo "${app_name} stopped" + return 0 + fi + sleep 1 + done + + echo "${app_name} did not stop within 10 seconds. PID: ${pid}" >&2 + exit 1 +} + +status_app() { + set_pid_record_state + case "${record_state}" in + active) + echo "${app_name} is running with PID ${record_pid}" + echo "PID file: ${pid_file}" + echo "Log: ${log_file}" + return 0 + ;; + missing) + echo "${app_name} is not running" + return 1 + ;; + stale | invalid) + echo "${app_name} is not running; stale PID file: ${pid_file}" + return 1 + ;; + conflict) + echo "${app_name} status is unknown; ${pid_file} points to a running process that could not be strongly verified." + return 1 + ;; + esac +} + +show_logs() { + if [ "$#" -gt 1 ]; then + echo "Usage: $(basename "$0") logs [lines|-f|--follow]" >&2 + exit 1 + fi + + local option="${1:-80}" + if [ "${option}" != "-f" ] && [ "${option}" != "--follow" ] && ! [[ "${option}" =~ ^[1-9][0-9]*$ ]]; then + echo "Invalid log line count: ${option}" >&2 + exit 1 + fi + + if [ ! -f "${log_file}" ]; then + echo "Log file does not exist yet: ${log_file}" >&2 + exit 1 + fi + + if [ "${option}" = "-f" ] || [ "${option}" = "--follow" ]; then + tail -n 80 -f "${log_file}" + return 0 + fi + + tail -n "${option}" "${log_file}" +} + +command="${1:-status}" +if [ "$#" -gt 0 ]; then + shift +fi + +case "${command}" in + start) + start_app "$@" + ;; + stop) + stop_app + ;; + restart) + stop_app + start_app "$@" + ;; + status) + status_app + ;; + logs) + show_logs "$@" + ;; + help | -h | --help) + usage + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/bin/release/package-native.sh b/bin/release/package-native.sh index 1be317543..b3b925b7f 100755 --- a/bin/release/package-native.sh +++ b/bin/release/package-native.sh @@ -7,6 +7,7 @@ out_dir="${OUT_DIR:-"${repo_root}/dist/native"}" web_html="${WEB_HTML:-"${repo_root}/apps/web/dist/index.html"}" binary_name="cpa-manager-plus" server_src="${repo_root}/apps/manager-server" +native_script_src="${repo_root}/bin/native" if [ ! -f "${web_html}" ]; then echo "missing ${web_html}; run npm run build first" >&2 @@ -52,6 +53,12 @@ for target in "${targets[@]}"; do cp "${repo_root}/README_CN.md" "${package_dir}/README_CN.md" cp -R "${repo_root}/docs" "${package_dir}/docs" cp "${repo_root}/LICENSE" "${package_dir}/LICENSE" + if [ "${goos}" = "windows" ]; then + cp "${native_script_src}/cpa-manager-plusctl.ps1" "${package_dir}/cpa-manager-plusctl.ps1" + else + cp "${native_script_src}/cpa-manager-plusctl.sh" "${package_dir}/cpa-manager-plusctl" + chmod 0755 "${package_dir}/cpa-manager-plusctl" + fi if [ "${goos}" = "windows" ]; then ( diff --git a/docs/release-notes/v1.10.1-en.md b/docs/release-notes/v1.10.1-en.md new file mode 100644 index 000000000..c7203aa49 --- /dev/null +++ b/docs/release-notes/v1.10.1-en.md @@ -0,0 +1,47 @@ +# CPA Manager Plus v1.10.1 + +> 17 commits · 36 files changed · +3084 / -174 + +> [中文 ->](https://github.com/seakee/CPA-Manager-Plus/blob/v1.10.1/docs/release-notes/v1.10.1-zh.md) + +## Overview + +This release focuses on native package background control, quota provider adaptation, and more accurate cost aggregation. Native packages now include hardened background start/stop scripts, Antigravity and Codex quota displays cover more provider edge cases, and Manager Server falls back to requested model alias pricing when resolved model pricing is unavailable so channel aggregate costs no longer show zero for prefixed aliases. + +## Highlights + +### Features + +- Add native package background control scripts for Unix and Windows deployments, including start, stop, status, and PID file management support (`native`). +- Prefer Antigravity quota summary data and show subscription plan details on quota cards, while still falling back to available model data when summaries are absent (`web/quota`). +- Run bulk provider quota refreshes through a bounded concurrency queue to reduce burst pressure during paged or all-account refreshes (`web/quota`). + +### Fixes + +- Harden native background control scripts with PID ownership checks, safe parent-directory validation, custom directory preservation, and Windows behavior that avoids ACL cmdlet compatibility issues (`native`). +- Harden Codex quota adapters by sharing request header construction between quota fetch and reset-credit APIs, preserving existing usage quota data when reset-credit details fail, and localizing invalid payload errors (`web/quota`). +- Cover additional provider quota edge cases, including Kimi limit-window unit parsing, Claude profile-failure paths, and xAI billing normalization (`web/quota`). +- Fall back from resolved/upstream model names to requested model aliases in Manager Server cost calculation, fixing zero channel aggregate costs for prefixed model aliases (`manager-server/pricing`). + +### Docs + +- Move the native background control guide into the VitePress documentation site and update README, deployment pages, and sidebar links so users reach the maintained docs location (`docs/native`). + +### Tests + +- Add regression coverage for native control script safety, model pricing fallback, Antigravity subscription parsing, provider quota edge cases, and Codex reset-credit headers (`tests`). + +## Upgrade Notes + +- No database migration is required. +- Native package users can use the new `cpa-manager-plusctl.sh` / `cpa-manager-plusctl.ps1` scripts for background process control; keep custom PID and log directories owned and permissioned safely. +- This release tightens quota provider behavior, so after upgrading it is worth checking Antigravity, Codex, Kimi, Claude, and xAI quota cards. +- Suggested screenshots for this release: native background control command examples, Antigravity subscription quota cards, and channel statistics after the model-alias cost aggregation fix. + +## Acknowledgements + +- [@duangkuacha](https://github.com/duangkuacha) - Contributed native background control scripts and follow-up hardening for Unix and Windows control flows. + +--- + +**Full Changelog**: https://github.com/seakee/CPA-Manager-Plus/compare/v1.10.0...v1.10.1 diff --git a/docs/release-notes/v1.10.1-zh.md b/docs/release-notes/v1.10.1-zh.md new file mode 100644 index 000000000..df0e73689 --- /dev/null +++ b/docs/release-notes/v1.10.1-zh.md @@ -0,0 +1,47 @@ +# CPA Manager Plus v1.10.1 + +> 17 commits · 36 files changed · +3084 / -174 + +> [English ->](https://github.com/seakee/CPA-Manager-Plus/blob/v1.10.1/docs/release-notes/v1.10.1-en.md) + +## Overview + +本次发布聚焦 native package 的后台控制能力、配额提供商适配和计费聚合准确性。原生包新增并加固后台启动/停止脚本,Antigravity 与 Codex quota 展示覆盖更多边界场景,Manager Server 也会在模型别名场景下回退到请求模型价格,避免通道聚合成本显示为 0。 + +## Highlights + +### Features + +- 新增 native package 后台控制脚本,为 Unix 和 Windows 原生部署提供启动、停止、状态检查和 PID 文件管理能力(`native`)。 +- Antigravity quota 优先读取 summary 数据,并在 quota 卡片中展示订阅套餐信息,缺失 summary 时仍可回退到可用模型数据(`web/quota`)。 +- 批量 provider quota 刷新改为有界并发队列,降低分页或全量刷新时对上游 provider 的瞬时请求压力(`web/quota`)。 + +### Fixes + +- 原生后台控制脚本会校验 PID 文件所有权、父目录安全性和自定义目录处理,并避免 Windows ACL cmdlet 兼容性问题(`native`)。 +- 加固 Codex quota adapter:统一 quota fetch 与 reset-credit API 的请求 header,reset-credit 明细失败时保留已有 usage quota 数据,并补齐无效 payload 的本地化错误(`web/quota`)。 +- 补齐多个 provider quota 边界处理,包括 Kimi limit window 单位解析、Claude profile 失败路径和 xAI billing 归一化(`web/quota`)。 +- Manager Server 成本计算会在 resolved/upstream model 没有价格时回退到请求模型别名,修复带前缀别名模型的通道聚合成本为 0 的问题(`manager-server/pricing`)。 + +### Docs + +- 将 native 后台控制文档移入 VitePress 文档站,并更新 README、部署页面和侧边栏链接,避免用户落到已迁移的旧文档位置(`docs/native`)。 + +### Tests + +- 补充 native control 脚本安全性、模型价格回退、Antigravity 订阅解析、provider quota 边界和 Codex reset-credit header 相关回归测试(`tests`)。 + +## Upgrade Notes + +- 无需数据库迁移。 +- Native package 用户可以使用新增的 `cpa-manager-plusctl.sh` / `cpa-manager-plusctl.ps1` 管理后台运行;如果使用自定义 PID 或日志目录,请保持目录归属和权限安全。 +- 本次包含 quota provider 行为收敛,建议升级后重点检查 Antigravity、Codex、Kimi、Claude 和 xAI 账号的 quota 卡片显示。 +- 建议为本版本补充截图:native 后台控制命令示例、Antigravity subscription quota 卡片、模型别名成本聚合修复后的通道统计。 + +## Acknowledgements + +- [@duangkuacha](https://github.com/duangkuacha) - 贡献 native 后台控制脚本并持续加固 Unix/Windows 控制流程。 + +--- + +**Full Changelog**: https://github.com/seakee/CPA-Manager-Plus/compare/v1.10.0...v1.10.1 diff --git a/docs/release-notes/v1.10.2-en.md b/docs/release-notes/v1.10.2-en.md new file mode 100644 index 000000000..26977f94a --- /dev/null +++ b/docs/release-notes/v1.10.2-en.md @@ -0,0 +1,33 @@ +# CPA Manager Plus v1.10.2 + +> 8 commits · 15 files changed · +1579 / -104 + +> [中文 ->](https://github.com/seakee/CPA-Manager-Plus/blob/v1.10.2/docs/release-notes/v1.10.2-zh.md) + +## Overview + +This patch release focuses on auth-files stability and management-panel readability. Manager Server now streams CPA auth-files target lookups for large responses instead of depending on full in-memory reads that can be truncated, keeping quota and account-action flows accurate. The panel also refreshes Codex quota after cooldown recovery or stale usage-header detection and fills missing runtime locale keys across navigation, logs, monitoring, and auth-files UI. + +## Highlights + +### Fixes + +- Stream CPA auth-files target lookups in Manager Server quota and account-action paths, forwarding `auth_index` to newer CPA status updates while preserving fallbacks for existing CPA versions (`manager-server/auth-files`). +- Refresh Codex auth-file quota after CPAMP cooldown recovery or stale usage-header detection, so quota badges stop showing expired window data while preserving `authIndex` isolation (`web/auth-files`). +- Restore full navigation labels in the expanded sidebar while keeping short labels available for compact navigation contexts (`web/navigation`). +- Fill missing runtime translation keys for auth files, provider actions, navigation, logs, and monitoring, keeping English, Simplified Chinese, Traditional Chinese, and Russian key coverage aligned (`web/i18n`). + +### Tests + +- Add regression coverage for CPA auth-files streaming lookup, account-action candidate refresh, Codex cooldown quota refresh, sidebar labels, and i18n coverage (`tests`). + +## Upgrade Notes + +- No database migration is required. +- Large CPA auth-files lists should behave more reliably for quota refresh and account-action candidate checks after upgrading; fallback behavior remains for older CPA versions. +- Codex quota now refreshes after cooldown recovery, so after upgrading it is worth checking the Auth Files quota badge around recovery windows. +- Suggested screenshots for this release: Codex quota badge after cooldown recovery in Auth Files and full navigation labels in the expanded sidebar. + +--- + +**Full Changelog**: https://github.com/seakee/CPA-Manager-Plus/compare/v1.10.1...v1.10.2 diff --git a/docs/release-notes/v1.10.2-zh.md b/docs/release-notes/v1.10.2-zh.md new file mode 100644 index 000000000..b420e13b8 --- /dev/null +++ b/docs/release-notes/v1.10.2-zh.md @@ -0,0 +1,33 @@ +# CPA Manager Plus v1.10.2 + +> 8 commits · 15 files changed · +1579 / -104 + +> [English ->](https://github.com/seakee/CPA-Manager-Plus/blob/v1.10.2/docs/release-notes/v1.10.2-en.md) + +## Overview + +本次补丁发布聚焦 auth files 和管理面板可读性的稳定性修复。Manager Server 在 CPA auth-files 大响应场景下改为流式定位目标账号,避免读取被截断影响 quota 和账号操作;前端会在 Codex cooldown 恢复或过期 usage header 检测后主动刷新 quota,并补齐导航、日志、监控和 auth files 相关的多语言文案。 + +## Highlights + +### Fixes + +- Manager Server 的 quota 与账号操作路径改为流式查找 CPA auth-files 目标账号,并在新版 CPA 状态更新中传递 `auth_index`,避免大体积 auth-files 响应被截断后误判账号状态(`manager-server/auth-files`)。 +- Codex auth file quota 会在 CPAMP cooldown 恢复或 usage header 过期后自动刷新,避免 quota badge 继续显示旧窗口数据,同时保持 `authIndex` 隔离(`web/auth-files`)。 +- 展开的侧边栏恢复显示完整导航标签,短标签仍保留给紧凑导航场景使用(`web/navigation`)。 +- 补齐 auth files、provider actions、导航、日志和监控相关的缺失运行时翻译 key,保持 English、简体中文、繁体中文和 Russian key 覆盖一致(`web/i18n`)。 + +### Tests + +- 补充 CPA auth-files 流式查找、账号操作候选刷新、Codex cooldown quota 刷新、侧边栏标签和 i18n coverage 回归测试(`tests`)。 + +## Upgrade Notes + +- 无需数据库迁移。 +- 如果 CPA auth-files 列表很大,升级后 quota 刷新和账号操作候选检查应更稳定;旧版 CPA 继续保留 fallback 行为。 +- 本次修复会在 Codex cooldown 恢复后重新拉取 quota,升级后建议检查 Auth Files 页面 quota badge 是否随恢复窗口正常更新。 +- 建议为本版本补充截图:Auth Files 中 Codex cooldown 恢复后的 quota badge、展开侧边栏的完整导航标签。 + +--- + +**Full Changelog**: https://github.com/seakee/CPA-Manager-Plus/compare/v1.10.1...v1.10.2 diff --git a/docs/release-notes/v1.10.3-en.md b/docs/release-notes/v1.10.3-en.md new file mode 100644 index 000000000..00cb371f7 --- /dev/null +++ b/docs/release-notes/v1.10.3-en.md @@ -0,0 +1,47 @@ +# CPA Manager Plus v1.10.3 + +> 7 commits · 14 files changed · +1067 / -898 + +> [中文 ->](https://github.com/seakee/CPA-Manager-Plus/blob/v1.10.3/docs/release-notes/v1.10.3-zh.md) + +## Overview + +This patch release focuses on quota visibility, management-panel response stability, and clearer user-facing copy. The frontend now separates xAI monthly credits from pay-as-you-go usage, Manager Server adds `Content-Length` and static-file response semantics for `/management.html`, and the Chinese README now links directly to the Telegram community. + +## Highlights + +### Features + +- Show xAI quota summaries and monitoring account rows as separate monthly-credit and pay-as-you-go usage, including remaining on-demand quota when a cap is configured and a disabled state when it is not (`web/quota`). + +### Fixes + +- Add `Content-Length` for embedded management-panel responses and serve `PANEL_PATH` panel HTML with `http.ServeContent`, improving large single-file HTML delivery through nginx and reverse proxies while covering HEAD, range, and conditional response semantics (`manager-server/panel`). +- Remove Kimi amount labels from monitoring account quota entries so provider-specific quota displays do not mix unrelated amount labels (`web/monitoring`). + +### Docs + +- Add a Telegram Community and Feedback entry to the Chinese README so Chinese-language users can find the discussion and feedback channel directly (`docs/readme`). + +### Chore + +- Refine visible copy across zh-CN, zh-TW, en, and ru for request monitoring, auth issues, plugins, quota, model pricing, and analytics while keeping locale key coverage aligned (`web/i18n`). + +### Tests + +- Add or update regression coverage for xAI billing split behavior, monitoring mapping, provider quota requests, management-panel `Content-Length` compatibility, and locale key parity (`tests`). + +## Upgrade Notes + +- No database migration is required. +- Deployments that proxy `/management.html` should get more stable panel downloads after upgrading; if you use custom nginx caching or buffering rules, confirm the response now includes `Content-Length`. +- xAI quota display is more granular, so after upgrading it is worth checking the Quota and Monitoring views against provider-returned monthly-credit and pay-as-you-go data. +- This release includes visible quota display changes; consider adding screenshots of xAI quota and Monitoring views to the GitHub Release. + +## Acknowledgements + +- [@kcocoa](https://github.com/kcocoa) - Fixed Manager Server management-panel `Content-Length` and static-file response semantics. + +--- + +**Full Changelog**: https://github.com/seakee/CPA-Manager-Plus/compare/v1.10.2...v1.10.3 diff --git a/docs/release-notes/v1.10.3-zh.md b/docs/release-notes/v1.10.3-zh.md new file mode 100644 index 000000000..d2433950d --- /dev/null +++ b/docs/release-notes/v1.10.3-zh.md @@ -0,0 +1,47 @@ +# CPA Manager Plus v1.10.3 + +> 7 commits · 14 files changed · +1067 / -898 + +> [English ->](https://github.com/seakee/CPA-Manager-Plus/blob/v1.10.3/docs/release-notes/v1.10.3-en.md) + +## Overview + +本次补丁发布聚焦 quota 可见性、管理面板静态文件响应稳定性和用户文案打磨。前端现在能拆分 xAI 月度赠送额度与 pay-as-you-go 用量,Manager Server 为 `/management.html` 补齐 `Content-Length` 和静态文件响应语义,中文 README 也新增 Telegram 社区入口。 + +## Highlights + +### Features + +- xAI quota 摘要和监控账号行现在区分月度赠送额度与按量付费用量,并在配置 on-demand 上限时展示剩余额度;未配置上限时显示禁用状态(`web/quota`)。 + +### Fixes + +- Manager Server 的 embedded panel 和 `PANEL_PATH` 管理面板响应补齐 `Content-Length`,`PANEL_PATH` 路径改用 `http.ServeContent`,改善 nginx/反向代理代理大体积 single-file HTML 时的下载稳定性,并覆盖 HEAD、range 和 conditional response 语义(`manager-server/panel`)。 +- 监控账号 quota 条目移除 Kimi amount labels,避免不同 provider 的额度展示混杂(`web/monitoring`)。 + +### Docs + +- 中文 README 增加 Telegram Community and Feedback 入口,方便中文用户找到讨论与反馈渠道(`docs/readme`)。 + +### Chore + +- 优化 zh-CN、zh-TW、en、ru 的请求监控、auth issue、插件、quota、模型价格和分析等可见文案,降低技术术语暴露并保持 key 覆盖一致(`web/i18n`)。 + +### Tests + +- 补充 xAI billing split、监控映射、provider quota request、管理面板 `Content-Length` 兼容性和 locale key parity 相关回归验证(`tests`)。 + +## Upgrade Notes + +- 无需数据库迁移。 +- 反向代理 `/management.html` 的部署在升级后应获得更稳定的面板下载;如果有自定义 nginx 缓存或缓冲策略,建议确认响应头现在包含 `Content-Length`。 +- xAI quota 显示逻辑更精细,升级后建议检查 Quota 与 Monitoring 页面中月度额度和 pay-as-you-go 剩余额度是否符合 provider 返回数据。 +- 本次有可视 quota 显示变化,建议在 GitHub Release 补充 xAI quota 与 Monitoring 截图。 + +## Acknowledgements + +- [@kcocoa](https://github.com/kcocoa) - 修复 Manager Server 管理面板响应的 `Content-Length` 和静态文件语义。 + +--- + +**Full Changelog**: https://github.com/seakee/CPA-Manager-Plus/compare/v1.10.2...v1.10.3 diff --git a/docs/release-notes/v1.10.4-en.md b/docs/release-notes/v1.10.4-en.md new file mode 100644 index 000000000..c25cea403 --- /dev/null +++ b/docs/release-notes/v1.10.4-en.md @@ -0,0 +1,46 @@ +# CPA Manager Plus v1.10.4 + +> 11 commits · 30 files changed · +2743 / -177 + +> [中文 ->](https://github.com/seakee/CPA-Manager-Plus/blob/v1.10.4/docs/release-notes/v1.10.4-zh.md) + +## Overview + +This release focuses on account-history statistics and provider priority management. Manager Server adds SQLite-backed sidecar rollups for low-latency account summary reads, while the frontend moves provider priority changes into direct table editing and continues polishing monitoring quota details and Chinese filter copy. + +## Highlights + +### Features + +- Add `usage_rollup_checkpoints` and `usage_account_model_rollups` SQLite sidecar rollup tables, repository wiring, and Store access so Manager Server can derive account/model history totals from authoritative `usage_events` data (`manager-server/monitoring`). +- Add the `/v0/management/monitoring/account-history` API with up to 200 account targets, optional catch-up, checkpoint/pending state, request totals, success/failure counts, token totals, cost, success rate, and first/last seen times (`manager-server/monitoring`). +- Add a background account-history rollup worker that advances checkpoints on startup, incoming usage events, and periodic checks, avoiding synchronous raw-event scans for account summary reads (`manager-server/worker`). +- Add inline priority editing to the Provider table, covering Gemini, OpenAI-compatible, Codex, Claude, and Vertex providers while reusing the existing save, optimistic update, rollback, and cache refresh paths (`web/providers`). +- Simplify provider priority cells into lightweight click-to-edit value displays, removing inline step buttons and extra border chrome while constraining large values in the table column (`web/providers`). + +### Fixes + +- Move Codex quota source and fetch/observed time metadata into compact info tooltips, reducing inline noise in Monitoring account cards and the Quota panel while preserving quota refresh and header merge behavior (`web/monitoring`). +- Keep the request-monitoring token structure table focused on input, output, and cached token rows by removing separate cache creation/read rows to match the current monitoring layout (`web/monitoring`). +- Restore contextual Simplified and Traditional Chinese compact monitoring filter labels so multiple filters no longer collapse to identical all labels (`web/i18n`). + +### Tests + +- Add repository, controller, service, catch-up, read, and benchmark coverage for account-history rollups (`manager-server/tests`). +- Add frontend coverage for Provider table inline priority editing, Enter/Escape/blur commits, disabled state, and rollback behavior (`web/tests`). +- Add regression coverage for monitoring quota tooltips, token structure, and locale labels (`web/tests`). + +## Upgrade Notes + +- This release automatically creates `usage_rollup_checkpoints` and `usage_account_model_rollups` in the Manager Server SQLite database. No manual migration is required, but keep the normal SQLite files plus `data.key` backup before upgrading. +- `usage_events` remains the authoritative data source. The new rollup tables are a rebuildable read-acceleration layer. After the first startup on an existing database, background catch-up may briefly show `pending` while account history data is filled in batches. +- The account-history API accepts up to 200 account targets per request. External integrations that call it directly should batch larger target sets. +- This release includes visible provider priority editing and monitoring quota tooltip changes; consider adding screenshots of the provider table and monitoring quota tooltips to the GitHub Release. + +## Acknowledgements + +- [@bc19sam-afk](https://github.com/bc19sam-afk) - Contributed inline priority controls for the Provider table. + +--- + +**Full Changelog**: https://github.com/seakee/CPA-Manager-Plus/compare/v1.10.3...v1.10.4 diff --git a/docs/release-notes/v1.10.4-zh.md b/docs/release-notes/v1.10.4-zh.md new file mode 100644 index 000000000..192358b8f --- /dev/null +++ b/docs/release-notes/v1.10.4-zh.md @@ -0,0 +1,46 @@ +# CPA Manager Plus v1.10.4 + +> 11 commits · 30 files changed · +2743 / -177 + +> [English ->](https://github.com/seakee/CPA-Manager-Plus/blob/v1.10.4/docs/release-notes/v1.10.4-en.md) + +## Overview + +本次发布聚焦账号历史统计与 provider 优先级管理。Manager Server 新增基于 SQLite 侧边汇总的账号历史统计能力,为账号级摘要读取提供低延迟数据源;前端则把 provider 优先级调整收敛到表格内直接编辑,并继续打磨监控 quota 信息与中文筛选文案。 + +## Highlights + +### Features + +- Manager Server 新增 `usage_rollup_checkpoints` 和 `usage_account_model_rollups` SQLite 侧边汇总表、repository 与 Store 接入,用 `usage_events` 作为权威来源累计账号/模型历史请求、token、成本与成功率统计(`manager-server/monitoring`)。 +- 新增 `/v0/management/monitoring/account-history` API,支持最多 200 个账号目标、可选 catch-up,并返回 checkpoint/pending 状态、总请求数、成功/失败次数、总 token、成本、成功率和首末次出现时间(`manager-server/monitoring`)。 +- 新增账号历史汇总后台 worker,在服务启动、使用事件进入和定时检查时推进汇总 checkpoint,避免账号摘要读取同步扫描原始事件(`manager-server/worker`)。 +- Provider 表格新增 inline 优先级编辑;Gemini、OpenAI-compatible、Codex、Claude、Vertex provider 都可以直接在表格里修改 priority,并复用已有保存、乐观更新、失败回滚和缓存刷新路径(`web/providers`)。 +- Provider priority 单元格改为更轻量的点击编辑值展示,移除表格内 step 按钮和多余边框,并约束大数值显示,降低列表视觉噪音(`web/providers`)。 + +### Fixes + +- Codex quota 来源、抓取/观测时间移动到紧凑 info tooltip,减少监控账号卡和 Quota 面板的内联元数据噪音,同时保留 quota refresh 与 header merge 行为(`web/monitoring`)。 +- 请求监控 token structure 表格保留 input、output、cached 三类主 token 行,移除 cache creation/read 独立行,让展示与当前请求监控布局一致(`web/monitoring`)。 +- 请求监控 compact 筛选恢复简体中文和繁体中文的上下文标签,避免多个筛选项都显示为无上下文的“全部”(`web/i18n`)。 + +### Tests + +- 补充账号历史 rollup repository、controller、service、catch-up 与读取 benchmark 覆盖(`manager-server/tests`)。 +- 补充 Provider 表格 inline priority 编辑、Enter/Escape/blur 提交、禁用态和失败回滚相关前端覆盖(`web/tests`)。 +- 补充 monitoring quota tooltip、token structure 和 locale label 回归覆盖(`web/tests`)。 + +## Upgrade Notes + +- 本次会在 Manager Server SQLite 数据库中自动创建 `usage_rollup_checkpoints` 和 `usage_account_model_rollups`。无需手工迁移,但建议升级前按惯例备份 SQLite 文件与 `data.key`。 +- `usage_events` 仍是权威数据源;新增 rollup 表是可重建的读取加速层。升级后首次启动会后台 catch up 历史事件,大库可能短暂显示 `pending`,账号历史数据会逐批补齐。 +- 账号历史 API 单次最多接收 200 个账号目标;如果外部集成直接调用该 API,需要按批请求。 +- 本次包含 provider priority 编辑和 monitoring quota tooltip 的可视变化,建议在 GitHub Release 补充 provider table 与 monitoring quota 截图。 + +## Acknowledgements + +- [@bc19sam-afk](https://github.com/bc19sam-afk) - 贡献 provider 表格 inline priority controls。 + +--- + +**Full Changelog**: https://github.com/seakee/CPA-Manager-Plus/compare/v1.10.3...v1.10.4 diff --git a/tests/nativeControlScripts.test.mjs b/tests/nativeControlScripts.test.mjs new file mode 100644 index 000000000..60666f576 --- /dev/null +++ b/tests/nativeControlScripts.test.mjs @@ -0,0 +1,661 @@ +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const unixControlScript = path.join(repoRoot, 'bin/native/cpa-manager-plusctl.sh'); +const windowsControlScript = path.join(repoRoot, 'bin/native/cpa-manager-plusctl.ps1'); +const tempDirs = []; + +const findExecutable = (candidates) => candidates.find((candidate) => existsSync(candidate)); + +const windowsPowerShell = () => { + if (process.env.SystemRoot) { + return path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + } + return 'powershell.exe'; +}; + +const psQuote = (value) => `'${value.replace(/'/g, "''")}'`; + +const runUnixControl = (script, env, args, options = {}) => + execFileSync('bash', [script, ...args], { + env, + encoding: 'utf8', + ...options, + }); + +const runControl = (env, args, options = {}) => + runUnixControl(unixControlScript, env, args, options); + +const runPowerShell = (args, options = {}) => + execFileSync(windowsPowerShell(), ['-NoProfile', '-ExecutionPolicy', 'Bypass', ...args], { + encoding: 'utf8', + ...options, + }); + +const runPowerShellControl = (env, args, options = {}) => { + try { + return execFileSync(windowsPowerShell(), ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', windowsControlScript, ...args], { + env, + encoding: 'utf8', + ...options, + }); + } catch (error) { + throw new Error( + [ + `PowerShell control script failed: ${args.join(' ')}`, + `status: ${error.status ?? 'unknown'}`, + error.stdout ? `stdout:\n${error.stdout}` : '', + error.stderr ? `stderr:\n${error.stderr}` : '', + error.message, + ] + .filter(Boolean) + .join('\n\n'), + ); + } +}; + +const spawnPowerShellControl = (env, args) => { + const result = spawnSync( + windowsPowerShell(), + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', windowsControlScript, ...args], + { + env, + encoding: 'utf8', + }, + ); + if (result.status !== 0) { + throw new Error( + [ + `PowerShell control script exited with status ${result.status}`, + result.error ? `error: ${result.error.message}` : '', + result.stdout ? `stdout:\n${result.stdout}` : '', + result.stderr ? `stderr:\n${result.stderr}` : '', + ] + .filter(Boolean) + .join('\n\n'), + ); + } +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }); + } +}); + +describe('native control scripts', () => { + it('starts Unix processes from the package directory when invoked elsewhere', () => { + if (process.platform === 'win32') { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-cwd-')); + tempDirs.push(tempDir); + + const packageDir = path.join(tempDir, 'package'); + const callerDir = path.join(tempDir, 'caller'); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(callerDir, { recursive: true }); + + const controlScript = path.join(packageDir, 'cpa-manager-plusctl'); + const fakeBinary = path.join(packageDir, 'cpa-manager-plus'); + const cwdFile = path.join(tempDir, 'cwd.txt'); + const dataEnvFile = path.join(tempDir, 'data-env.txt'); + copyFileSync(unixControlScript, controlScript); + chmodSync(controlScript, 0o755); + writeFileSync( + fakeBinary, + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + 'pwd >"${CPA_MANAGER_PLUS_TEST_CWD_FILE}"', + 'printf "%s\\n" "${USAGE_DATA_DIR:-}" >"${CPA_MANAGER_PLUS_TEST_DATA_FILE}"', + 'sleep 30', + '', + ].join('\n'), + ); + chmodSync(fakeBinary, 0o755); + + const env = { + ...process.env, + CPA_MANAGER_PLUS_TEST_CWD_FILE: cwdFile, + CPA_MANAGER_PLUS_TEST_DATA_FILE: dataEnvFile, + USAGE_DATA_DIR: './data', + }; + + try { + runUnixControl(controlScript, env, ['start'], { cwd: callerDir }); + + expect(readFileSync(cwdFile, 'utf8').trim()).toBe(packageDir); + expect(readFileSync(dataEnvFile, 'utf8').trim()).toBe('./data'); + expect(runUnixControl(controlScript, env, ['status'], { cwd: callerDir })).toContain('is running with PID'); + expect(runUnixControl(controlScript, env, ['stop'], { cwd: callerDir })).toContain('stopped'); + } finally { + spawnSync('bash', [controlScript, 'stop'], { cwd: callerDir, env, encoding: 'utf8' }); + } + }); + + it('creates custom Unix PID/log parent directories with private runtime files', () => { + if (process.platform === 'win32') { + return; + } + + const sleepBinary = findExecutable(['/bin/sleep', '/usr/bin/sleep']); + if (!sleepBinary) { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-control-')); + tempDirs.push(tempDir); + + const pidFile = path.join(tempDir, 'custom-run', 'nested', 'manager.pid'); + const logFile = path.join(tempDir, 'custom-logs', 'nested', 'manager.log'); + const env = { + ...process.env, + CPA_MANAGER_PLUS_BIN: sleepBinary, + CPA_MANAGER_PLUS_RUN_DIR: path.join(tempDir, 'default-run'), + CPA_MANAGER_PLUS_LOG_DIR: path.join(tempDir, 'default-logs'), + CPA_MANAGER_PLUS_PID_FILE: pidFile, + CPA_MANAGER_PLUS_LOG_FILE: logFile, + }; + + try { + runControl(env, ['start', '30']); + + expect(existsSync(path.dirname(pidFile))).toBe(true); + expect(existsSync(path.dirname(logFile))).toBe(true); + expect(existsSync(pidFile)).toBe(true); + expect(existsSync(logFile)).toBe(true); + expect(statSync(pidFile).mode & 0o777).toBe(0o600); + expect(statSync(logFile).mode & 0o777).toBe(0o600); + + const pidRecord = readFileSync(pidFile, 'utf8'); + expect(pidRecord).toContain('pid='); + expect(pidRecord).toContain('start='); + expect(pidRecord).toContain('binary='); + expect(pidRecord).toContain('command='); + + expect(runControl(env, ['status'])).toContain('is running with PID'); + expect(runControl(env, ['stop'])).toContain('stopped'); + expect(existsSync(pidFile)).toBe(false); + } finally { + spawnSync('bash', [unixControlScript, 'stop'], { env, encoding: 'utf8' }); + } + }); + + it('does not change permissions on existing custom Unix run/log directories', () => { + if (process.platform === 'win32') { + return; + } + + const sleepBinary = findExecutable(['/bin/sleep', '/usr/bin/sleep']); + if (!sleepBinary) { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-existing-dir-')); + tempDirs.push(tempDir); + + const runDir = path.join(tempDir, 'existing-run'); + const logDir = path.join(tempDir, 'existing-logs'); + mkdirSync(runDir, { recursive: true }); + mkdirSync(logDir, { recursive: true }); + chmodSync(runDir, 0o755); + chmodSync(logDir, 0o755); + + const pidFile = path.join(runDir, 'cpa-manager-plus.pid'); + const logFile = path.join(logDir, 'cpa-manager-plus.log'); + const env = { + ...process.env, + CPA_MANAGER_PLUS_BIN: sleepBinary, + CPA_MANAGER_PLUS_RUN_DIR: runDir, + CPA_MANAGER_PLUS_LOG_DIR: logDir, + }; + + try { + runControl(env, ['start', '30']); + + expect(statSync(runDir).mode & 0o777).toBe(0o755); + expect(statSync(logDir).mode & 0o777).toBe(0o755); + expect(statSync(pidFile).mode & 0o777).toBe(0o600); + expect(statSync(logFile).mode & 0o777).toBe(0o600); + expect(runControl(env, ['stop'])).toContain('stopped'); + } finally { + spawnSync('bash', [unixControlScript, 'stop'], { env, encoding: 'utf8' }); + } + }); + + it('rejects unsafe Unix custom runtime parents and symlinked runtime files', () => { + if (process.platform === 'win32') { + return; + } + + const sleepBinary = findExecutable(['/bin/sleep', '/usr/bin/sleep']); + if (!sleepBinary) { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-unsafe-')); + tempDirs.push(tempDir); + + const unsafeDir = path.join(tempDir, 'unsafe-logs'); + mkdirSync(unsafeDir, { recursive: true }); + chmodSync(unsafeDir, 0o777); + + const unsafeParentResult = spawnSync('bash', [unixControlScript, 'start', '30'], { + env: { + ...process.env, + CPA_MANAGER_PLUS_BIN: sleepBinary, + CPA_MANAGER_PLUS_RUN_DIR: path.join(tempDir, 'run'), + CPA_MANAGER_PLUS_LOG_FILE: path.join(unsafeDir, 'manager.log'), + }, + encoding: 'utf8', + }); + + expect(unsafeParentResult.status).not.toBe(0); + expect(unsafeParentResult.stderr).toContain('unsafe runtime directory'); + + const logTarget = path.join(tempDir, 'target.log'); + const symlinkedLog = path.join(tempDir, 'symlink.log'); + writeFileSync(logTarget, ''); + symlinkSync(logTarget, symlinkedLog); + + const symlinkResult = spawnSync('bash', [unixControlScript, 'start', '30'], { + env: { + ...process.env, + CPA_MANAGER_PLUS_BIN: sleepBinary, + CPA_MANAGER_PLUS_RUN_DIR: path.join(tempDir, 'run-2'), + CPA_MANAGER_PLUS_LOG_FILE: symlinkedLog, + }, + encoding: 'utf8', + }); + + expect(symlinkResult.status).not.toBe(0); + expect(symlinkResult.stderr).toContain('symlinked runtime file'); + }); + + it('rejects invalid Unix log line counts', () => { + if (process.platform === 'win32') { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-logs-')); + tempDirs.push(tempDir); + + const logFile = path.join(tempDir, 'manager.log'); + writeFileSync(logFile, 'line\n'); + + for (const invalidLineCount of ['0', '-1', 'abc']) { + const result = spawnSync('bash', [unixControlScript, 'logs', invalidLineCount], { + env: { + ...process.env, + CPA_MANAGER_PLUS_LOG_FILE: logFile, + }, + encoding: 'utf8', + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Invalid log line count'); + } + }); + + it('refuses to stop a running process from an unverifiable legacy Unix PID file', () => { + if (process.platform === 'win32') { + return; + } + + const sleepBinary = findExecutable(['/bin/sleep', '/usr/bin/sleep']); + if (!sleepBinary) { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-conflict-')); + tempDirs.push(tempDir); + + const pidFile = path.join(tempDir, 'run', 'manager.pid'); + const logFile = path.join(tempDir, 'logs', 'manager.log'); + const unrelatedProcess = spawn(sleepBinary, ['5'], { + stdio: 'ignore', + }); + const unrelatedPid = unrelatedProcess.pid; + expect(unrelatedPid).toBeGreaterThan(0); + + const env = { + ...process.env, + CPA_MANAGER_PLUS_BIN: sleepBinary, + CPA_MANAGER_PLUS_PID_FILE: pidFile, + CPA_MANAGER_PLUS_LOG_FILE: logFile, + }; + + try { + rmSync(path.dirname(pidFile), { force: true, recursive: true }); + mkdirSync(path.dirname(pidFile), { recursive: true }); + writeFileSync(pidFile, `${unrelatedPid}\n`); + + const stopResult = spawnSync('bash', [unixControlScript, 'stop'], { + env, + encoding: 'utf8', + }); + + expect(stopResult.status).not.toBe(0); + expect(stopResult.stderr).toContain('Refusing to stop'); + expect(spawnSync('kill', ['-0', String(unrelatedPid)]).status).toBe(0); + } finally { + unrelatedProcess.kill(); + } + }); + + it('rejects symlinked Unix PID files on status and stop', () => { + if (process.platform === 'win32') { + return; + } + + const sleepBinary = findExecutable(['/bin/sleep', '/usr/bin/sleep']); + if (!sleepBinary) { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-pid-link-')); + tempDirs.push(tempDir); + + const pidFile = path.join(tempDir, 'run', 'manager.pid'); + const linkPidFile = path.join(tempDir, 'linked.pid'); + const logFile = path.join(tempDir, 'logs', 'manager.log'); + const realEnv = { + ...process.env, + CPA_MANAGER_PLUS_BIN: sleepBinary, + CPA_MANAGER_PLUS_PID_FILE: pidFile, + CPA_MANAGER_PLUS_LOG_FILE: logFile, + }; + const linkedEnv = { + ...realEnv, + CPA_MANAGER_PLUS_PID_FILE: linkPidFile, + }; + + try { + runControl(realEnv, ['start', '30']); + symlinkSync(pidFile, linkPidFile); + + for (const command of ['status', 'stop']) { + const result = spawnSync('bash', [unixControlScript, command], { + env: linkedEnv, + encoding: 'utf8', + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('symlinked runtime file'); + } + + expect(runControl(realEnv, ['status'])).toContain('is running with PID'); + expect(runControl(realEnv, ['stop'])).toContain('stopped'); + } finally { + spawnSync('bash', [unixControlScript, 'stop'], { env: realEnv, encoding: 'utf8' }); + } + }); + + it('rejects unsafe Unix PID parent directories on status and stop', () => { + if (process.platform === 'win32') { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-pid-parent-')); + tempDirs.push(tempDir); + + const unsafeDir = path.join(tempDir, 'unsafe-run'); + mkdirSync(unsafeDir, { recursive: true }); + chmodSync(unsafeDir, 0o777); + + const pidFile = path.join(unsafeDir, 'manager.pid'); + writeFileSync(pidFile, `${process.pid}\n`); + + const env = { + ...process.env, + CPA_MANAGER_PLUS_PID_FILE: pidFile, + CPA_MANAGER_PLUS_LOG_FILE: path.join(tempDir, 'logs', 'manager.log'), + }; + + for (const command of ['status', 'stop']) { + const result = spawnSync('bash', [unixControlScript, command], { + env, + encoding: 'utf8', + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('unsafe runtime directory'); + } + }); + + it('parses the Windows PowerShell control script', () => { + if (process.platform !== 'win32') { + return; + } + + runPowerShell([ + '-Command', + [ + '$tokens = $null', + '$errors = $null', + `[System.Management.Automation.Language.Parser]::ParseFile(${psQuote(windowsControlScript)}, [ref]$tokens, [ref]$errors) | Out-Null`, + 'if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 }', + ].join('; '), + ]); + }); + + it('starts Windows processes with custom paths, private files, logs, and stop', () => { + if (process.platform !== 'win32') { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-win-')); + tempDirs.push(tempDir); + + const pidFile = path.join(tempDir, 'custom-run', 'nested', 'manager.pid'); + const logFile = path.join(tempDir, 'custom-logs', 'nested', 'manager.log'); + const errLogFile = path.join(tempDir, 'custom-logs', 'nested', 'manager.err.log'); + const childScript = path.join(tempDir, 'child.js'); + writeFileSync(childScript, 'setTimeout(() => {}, 30000);\r\n'); + + const env = { + ...process.env, + CPA_MANAGER_PLUS_BIN: process.execPath, + CPA_MANAGER_PLUS_PID_FILE: pidFile, + CPA_MANAGER_PLUS_LOG_FILE: logFile, + CPA_MANAGER_PLUS_ERR_LOG_FILE: errLogFile, + }; + + try { + spawnPowerShellControl(env, ['start', childScript]); + + expect(existsSync(pidFile)).toBe(true); + expect(existsSync(logFile)).toBe(true); + expect(existsSync(errLogFile)).toBe(true); + expect(runPowerShellControl(env, ['status'])).toContain('is running with PID'); + + const pidRecord = JSON.parse(readFileSync(pidFile, 'utf8')); + expect(pidRecord.pid).toBeGreaterThan(0); + expect(pidRecord.startTimeUtc).toBeTruthy(); + expect(pidRecord.binaryPath || pidRecord.commandLine).toBeTruthy(); + + runPowerShell([ + '-Command', + [ + `foreach ($path in @(${[pidFile, logFile, errLogFile].map(psQuote).join(', ')})) {`, + ' $item = Get-Item -LiteralPath $path -Force;', + ' $acl = [System.IO.File]::GetAccessControl($item.FullName);', + ' if (-not $acl.AreAccessRulesProtected) { throw "ACL is not protected: $path" }', + '}', + ].join(' '), + ]); + + expect(runPowerShellControl(env, ['logs', '20'])).toBeDefined(); + const invalidLogsResult = spawnSync( + windowsPowerShell(), + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', windowsControlScript, 'logs', '0'], + { + env, + encoding: 'utf8', + }, + ); + expect(invalidLogsResult.status).not.toBe(0); + expect(invalidLogsResult.stderr).toContain('Invalid log line count'); + expect(runPowerShellControl(env, ['stop'])).toContain('stopped'); + expect(existsSync(pidFile)).toBe(false); + } finally { + spawnSync(windowsPowerShell(), ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', windowsControlScript, 'stop'], { + env, + encoding: 'utf8', + }); + } + }, 30000); + + it('rejects unsafe Windows custom runtime parents', () => { + if (process.platform !== 'win32') { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-win-unsafe-')); + tempDirs.push(tempDir); + + const unsafeDir = path.join(tempDir, 'unsafe-run'); + mkdirSync(unsafeDir, { recursive: true }); + const childScript = path.join(tempDir, 'child.js'); + writeFileSync(childScript, 'setTimeout(() => {}, 30000);\r\n'); + + runPowerShell([ + '-Command', + [ + `$path = ${psQuote(unsafeDir)}`, + '$item = Get-Item -LiteralPath $path -Force', + '$acl = [System.IO.Directory]::GetAccessControl($item.FullName)', + '$users = New-Object System.Security.Principal.SecurityIdentifier "S-1-5-32-545"', + '$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($users, "Modify", "ContainerInherit, ObjectInherit", "None", "Allow")', + '$acl.AddAccessRule($rule)', + '[System.IO.Directory]::SetAccessControl($item.FullName, $acl)', + ].join('; '), + ]); + + const result = spawnSync( + windowsPowerShell(), + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + windowsControlScript, + 'start', + childScript, + ], + { + env: { + ...process.env, + CPA_MANAGER_PLUS_BIN: process.execPath, + CPA_MANAGER_PLUS_RUN_DIR: path.join(tempDir, 'safe-run'), + CPA_MANAGER_PLUS_LOG_DIR: path.join(tempDir, 'safe-logs'), + CPA_MANAGER_PLUS_PID_FILE: path.join(unsafeDir, 'manager.pid'), + }, + encoding: 'utf8', + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('unsafe runtime directory'); + }); + + it('rejects reparse-point Windows PID files on status and stop', () => { + if (process.platform !== 'win32') { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-win-pid-link-')); + tempDirs.push(tempDir); + + const pidTargetDir = path.join(tempDir, 'pid-target'); + const pidReparsePath = path.join(tempDir, 'manager.pid'); + mkdirSync(pidTargetDir, { recursive: true }); + runPowerShell([ + '-Command', + `New-Item -ItemType Junction -Path ${psQuote(pidReparsePath)} -Target ${psQuote(pidTargetDir)} | Out-Null`, + ]); + + const env = { + ...process.env, + CPA_MANAGER_PLUS_PID_FILE: pidReparsePath, + }; + + for (const command of ['status', 'stop']) { + const result = spawnSync( + windowsPowerShell(), + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', windowsControlScript, command], + { + env, + encoding: 'utf8', + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('reparse-point runtime file'); + } + }); + + it('rejects unsafe Windows PID parent directories on status and stop', () => { + if (process.platform !== 'win32') { + return; + } + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cpamp-native-win-pid-parent-')); + tempDirs.push(tempDir); + + const unsafeDir = path.join(tempDir, 'unsafe-run'); + mkdirSync(unsafeDir, { recursive: true }); + const pidFile = path.join(unsafeDir, 'manager.pid'); + writeFileSync(pidFile, `${process.pid}\r\n`); + + runPowerShell([ + '-Command', + [ + `$path = ${psQuote(unsafeDir)}`, + '$item = Get-Item -LiteralPath $path -Force', + '$acl = [System.IO.Directory]::GetAccessControl($item.FullName)', + '$users = New-Object System.Security.Principal.SecurityIdentifier "S-1-5-32-545"', + '$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($users, "Modify", "ContainerInherit, ObjectInherit", "None", "Allow")', + '$acl.AddAccessRule($rule)', + '[System.IO.Directory]::SetAccessControl($item.FullName, $acl)', + ].join('; '), + ]); + + const env = { + ...process.env, + CPA_MANAGER_PLUS_PID_FILE: pidFile, + }; + + for (const command of ['status', 'stop']) { + const result = spawnSync( + windowsPowerShell(), + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', windowsControlScript, command], + { + env, + encoding: 'utf8', + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('unsafe runtime directory'); + } + }); +});